"""
PyQt5 自定义控件
- FileBrowser: 远程文件浏览 + 上传/下载/删除
- MonitorPanel: CPU/内存/磁盘/网络实时监控面板
- AIChatPanel: AI Agent 对话面板
"""
import os
import time
from pathlib import Path
from datetime import datetime
from typing import Optional, List, Dict
from PyQt5.QtCore import Qt, pyqtSignal, QSize, QTimer
from PyQt5.QtGui import QFont, QColor, QIcon, QPixmap, QPainter, QBrush, QKeySequence
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit,
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
QFileDialog, QMessageBox, QProgressBar, QTreeWidget, QTreeWidgetItem,
QTextEdit, QSplitter, QFrame, QSizePolicy, QGroupBox, QFormLayout,
QComboBox, QToolButton, QStyle, QApplication, QInputDialog,
QListWidget, QListWidgetItem, QTabWidget, QMenu, QShortcut, QSpinBox,
)
from core.ssh_client import SSHConnection
from core.monitor import SystemMonitor
from core.manager import ConnectionManager
from .workers import (
ListDirWorker, UploadWorker, DownloadWorker, MonitorWorker, AIWorker,
)
# ============================================================
# 文件浏览面板
# ============================================================
class FileBrowser(QWidget):
"""远程 SFTP 文件浏览:路径栏 + 工具栏 + 表格 + 状态栏"""
def __init__(self, manager: ConnectionManager, parent=None):
super().__init__(parent)
self.manager = manager
self.current_host_id: Optional[str] = None
self.current_path: str = "/"
self.cwd_history: List[str] = [] # 简单的前进/后退栈
self.list_worker: Optional[ListDirWorker] = None
self._build_ui()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(6)
# 路径栏
nav = QHBoxLayout()
self.btn_back = QPushButton("◀")
self.btn_back.setFixedWidth(32)
self.btn_back.clicked.connect(self._go_back)
self.btn_up = QPushButton("▲")
self.btn_up.setFixedWidth(32)
self.btn_up.clicked.connect(self._go_up)
self.path_edit = QLineEdit("/")
self.path_edit.returnPressed.connect(self._go_to_path)
self.btn_refresh = QPushButton("刷新")
self.btn_refresh.clicked.connect(lambda: self._refresh())
nav.addWidget(self.btn_back)
nav.addWidget(self.btn_up)
nav.addWidget(self.path_edit, 1)
nav.addWidget(self.btn_refresh)
layout.addLayout(nav)
# 工具栏
toolbar = QHBoxLayout()
self.btn_upload = QPushButton("⬆ 上传")
self.btn_upload.clicked.connect(self._upload)
self.btn_download = QPushButton("⬇ 下载")
self.btn_download.clicked.connect(self._download)
self.btn_mkdir = QPushButton("新建目录")
self.btn_mkdir.clicked.connect(self._mkdir)
self.btn_delete = QPushButton("删除")
self.btn_delete.clicked.connect(self._delete)
self.btn_rename = QPushButton("重命名")
self.btn_rename.clicked.connect(self._rename)
for w in (self.btn_upload, self.btn_download, self.btn_mkdir,
self.btn_delete, self.btn_rename):
toolbar.addWidget(w)
toolbar.addStretch(1)
self.progress = QProgressBar()
self.progress.setFixedWidth(180)
self.progress.setVisible(False)
toolbar.addWidget(self.progress)
layout.addLayout(toolbar)
# 文件表格
self.table = QTableWidget(0, 4)
self.table.setHorizontalHeaderLabels(["名称", "大小", "修改时间", "类型"])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.setAlternatingRowColors(True)
self.table.doubleClicked.connect(self._on_double_clicked)
layout.addWidget(self.table, 1)
# 状态栏
self.status_label = QLabel("未连接")
self.status_label.setStyleSheet("color: #888;")
layout.addWidget(self.status_label)
# ------- 连接管理 -------
def set_host(self, host_id: str):
self.current_host_id = host_id
self.current_path = "/"
self.path_edit.setText("/")
self.cwd_history.clear()
conn = self.manager.get_connection(host_id)
if conn and conn.connected:
self.status_label.setText(f"已连接: {conn.username}@{conn.host}")
self._refresh()
else:
self.status_label.setText("主机未连接,无法浏览")
self.table.setRowCount(0)
def refresh(self):
self._refresh()
# ------- 内部操作 -------
def _conn(self) -> Optional[SSHConnection]:
if not self.current_host_id:
return None
c = self.manager.get_connection(self.current_host_id)
if not c or not c.connected:
QMessageBox.warning(self, "未连接", "请先在「主机」面板连接当前主机")
return None
return c
def _go_back(self):
if len(self.cwd_history) > 1:
self.cwd_history.pop()
self.current_path = self.cwd_history[-1]
self.path_edit.setText(self.current_path)
self._refresh()
def _go_up(self):
p = self.current_path.rstrip("/")
if not p:
return
parent = os.path.dirname(p) or "/"
self.current_path = parent
self.path_edit.setText(parent)
self._refresh()
def _go_to_path(self):
path = self.path_edit.text().strip() or "/"
self.current_path = path
self._refresh()
def _refresh(self):
conn = self._conn()
if not conn:
return
if self.list_worker and self.list_worker.isRunning():
return
self.status_label.setText(f"加载中: {self.current_path}")
self.list_worker = ListDirWorker(conn, self.current_path)
self.list_worker.finished_with.connect(self._on_list_done)
self.list_worker.start()
def _on_list_done(self, path: str, entries: list):
if path != self.current_path:
return # 用户已跳转
self.table.setRowCount(0)
# 当前目录放第一行
cur = QTableWidgetItem(f"📁 .")
self.table.insertRow(0)
self.table.setItem(0, 0, cur)
self.table.setItem(0, 1, QTableWidgetItem("-"))
self.table.setItem(0, 2, QTableWidgetItem("-"))
self.table.setItem(0, 3, QTableWidgetItem("dir"))
for e in entries:
row = self.table.rowCount()
self.table.insertRow(row)
icon = "📁" if e["is_dir"] else "📄"
self.table.setItem(row, 0, QTableWidgetItem(f"{icon} {e['name']}"))
self.table.setItem(row, 1, QTableWidgetItem(
"-" if e["is_dir"] else SystemMonitor.format_bytes(e["size"])
))
try:
ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(e["mtime"]))
except Exception:
ts = "-"
self.table.setItem(row, 2, QTableWidgetItem(ts))
self.table.setItem(row, 3, QTableWidgetItem("dir" if e["is_dir"] else "file"))
if self.current_path not in self.cwd_history or self.cwd_history[-1] != self.current_path:
self.cwd_history.append(self.current_path)
self.status_label.setText(f"路径: {self.current_path} · 共 {len(entries)} 项")
def _on_double_clicked(self, idx):
row = idx.row()
if row == 0:
self._go_up()
return
name_item = self.table.item(row, 0)
if not name_item:
return
# 去掉 emoji 前缀
name = name_item.text().lstrip("📁📄 ").strip()
type_item = self.table.item(row, 3)
is_dir = type_item and type_item.text() == "dir"
if is_dir:
new_path = self.current_path.rstrip("/") + "/" + name
if not new_path.startswith("/"):
new_path = "/" + new_path
self.current_path = new_path
self.path_edit.setText(new_path)
self._refresh()
else:
self._download_for_item(name)
def _selected_remote_path(self) -> Optional[str]:
rows = self.table.selectionModel().selectedRows()
if not rows:
QMessageBox.information(self, "提示", "请先选中一个文件或目录")
return None
row = rows[0].row()
if row == 0:
return None
name_item = self.table.item(row, 0)
name = name_item.text().lstrip("📁📄 ").strip()
return self.current_path.rstrip("/") + "/" + name
def _upload(self):
conn = self._conn()
if not conn:
return
files, _ = QFileDialog.getOpenFileNames(self, "选择要上传的文件")
if not files:
return
for local in files:
base = os.path.basename(local)
remote = self.current_path.rstrip("/") + "/" + base
self._start_upload(conn, local, remote)
def _start_upload(self, conn, local, remote):
self.progress.setVisible(True)
self.progress.setValue(0)
worker = UploadWorker(conn, local, remote)
worker.progress.connect(lambda d, t: self.progress.setValue(
int(d / t * 100) if t else 0))
worker.finished_with.connect(
lambda ok, msg, w=worker: self._on_upload_done(ok, msg, w))
worker.start()
self._active_workers = getattr(self, "_active_workers", [])
self._active_workers.append(worker)
def _on_upload_done(self, ok, msg, worker):
self.progress.setVisible(False)
if ok:
self.status_label.setText(msg)
self._refresh()
else:
QMessageBox.critical(self, "上传失败", msg)
if worker in getattr(self, "_active_workers", []):
self._active_workers.remove(worker)
def _download(self):
rp = self._selected_remote_path()
if rp:
self._download_for_path(rp)
def _download_for_item(self, name: str):
rp = self.current_path.rstrip("/") + "/" + name
self._download_for_path(rp)
def _download_for_path(self, remote: str):
conn = self._conn()
if not conn:
return
default_name = os.path.basename(remote) or "download"
local, _ = QFileDialog.getSaveFileName(self, "保存到", default_name)
if not local:
return
self.progress.setVisible(True)
self.progress.setValue(0)
worker = DownloadWorker(conn, remote, local)
worker.progress.connect(lambda d, t: self.progress.setValue(
int(d / t * 100) if t else 0))
worker.finished_with.connect(
lambda ok, msg, w=worker: self._on_download_done(ok, msg, w))
worker.start()
self._active_workers = getattr(self, "_active_workers", [])
self._active_workers.append(worker)
def _on_download_done(self, ok, msg, worker):
self.progress.setVisible(False)
if ok:
self.status_label.setText(msg)
else:
QMessageBox.critical(self, "下载失败", msg)
if worker in getattr(self, "_active_workers", []):
self._active_workers.remove(worker)
def _mkdir(self):
conn = self._conn()
if not conn:
return
name, ok = QInputDialog.getText(self, "新建目录", "目录名:")
if not ok or not name.strip():
return
remote = self.current_path.rstrip("/") + "/" + name.strip()
ok2, msg = conn.mkdir(remote)
if ok2:
self._refresh()
else:
QMessageBox.critical(self, "创建失败", msg)
def _delete(self):
conn = self._conn()
if not conn:
return
rp = self._selected_remote_path()
if not rp:
return
if QMessageBox.question(
self, "确认删除",
f"确定要删除 {rp} 吗?\n目录将递归删除需用命令执行。",
QMessageBox.Yes | QMessageBox.No
) != QMessageBox.Yes:
return
ok, msg = conn.remove(rp)
if ok:
self._refresh()
else:
QMessageBox.critical(self, "删除失败", msg)
def _rename(self):
conn = self._conn()
if not conn:
return
rp = self._selected_remote_path()
if not rp:
return
new_name, ok = QInputDialog.getText(
self, "重命名", "新名称:", text=os.path.basename(rp))
if not ok or not new_name.strip():
return
new_path = self.current_path.rstrip("/") + "/" + new_name.strip()
ok2, msg = conn.rename(rp, new_path)
if ok2:
self._refresh()
else:
QMessageBox.critical(self, "重命名失败", msg)
# ============================================================
# 监控面板
# ============================================================
class MonitorPanel(QWidget):
"""实时监控:CPU、内存、磁盘、网络"""
def __init__(self, manager: ConnectionManager, parent=None):
super().__init__(parent)
self.manager = manager
self.current_host_id: Optional[str] = None
self.worker: Optional[MonitorWorker] = None
self._last_net: Dict[str, tuple] = {} # iface -> (rx, ts)
self._build_ui()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
# 顶部:主机信息 + 控制
head = QHBoxLayout()
self.host_label = QLabel("未连接")
self.host_label.setStyleSheet("font-size: 14pt; font-weight: bold;")
head.addWidget(self.host_label)
head.addStretch(1)
self.interval_combo = QComboBox()
self.interval_combo.addItems(["1 秒", "2 秒", "3 秒", "5 秒", "10 秒"])
self.interval_combo.setCurrentIndex(2)
self.interval_combo.setToolTip("自动刷新间隔(1-10 秒)")
head.addWidget(QLabel("刷新:"))
head.addWidget(self.interval_combo)
self.btn_refresh_now = QPushButton("⟳ 立即刷新")
self.btn_refresh_now.setToolTip("立即触发一次采集(不打断自动刷新)")
self.btn_refresh_now.clicked.connect(self._on_refresh_now)
head.addWidget(self.btn_refresh_now)
self.btn_toggle = QPushButton("开始监控")
self.btn_toggle.setCheckable(True)
self.btn_toggle.toggled.connect(self._on_toggle)
head.addWidget(self.btn_toggle)
layout.addLayout(head)
# 主机信息
self.info_label = QLabel("-")
self.info_label.setStyleSheet("color: #666;")
layout.addWidget(self.info_label)
# 用 Splitter 把 4 行指标卡 + 磁盘网络 放上半,进程表放下半
splitter = QSplitter(Qt.Vertical)
layout.addWidget(splitter, 1)
# ---- 上半:指标卡 + 磁盘 + 网络 ----
upper = QWidget()
uv = QVBoxLayout(upper)
uv.setContentsMargins(0, 0, 0, 0)
uv.setSpacing(10)
# CPU + 内存行
grid = QHBoxLayout()
grid.addWidget(self._build_card("CPU 使用率", "cpu_card"))
grid.addWidget(self._build_card("内存", "mem_card"))
uv.addLayout(grid)
# 负载 + 启动时间
grid2 = QHBoxLayout()
grid2.addWidget(self._build_card("系统负载", "load_card"))
grid2.addWidget(self._build_card("启动时间", "uptime_card"))
uv.addLayout(grid2)
# 进程统计 + 磁盘
grid_proc_disk = QHBoxLayout()
grid_proc_disk.addWidget(self._build_proc_summary())
self.disk_group = self._build_table_card("磁盘", ["挂载点", "已用/总大小", "使用率", "进度"])
grid_proc_disk.addWidget(self.disk_group, 2)
uv.addLayout(grid_proc_disk)
# 网络
self.net_group = self._build_table_card("网络", ["网卡", "↓ 接收", "↑ 发送", "速率"])
uv.addWidget(self.net_group)
splitter.addWidget(upper)
# ---- 下半:进程区(左侧表格 + 右侧详情/TOP) ----
self.proc_group = self._build_proc_table()
self.proc_side = self._build_proc_side()
proc_splitter = QSplitter(Qt.Horizontal)
proc_splitter.addWidget(self.proc_group)
proc_splitter.addWidget(self.proc_side)
proc_splitter.setSizes([800, 380])
splitter.addWidget(proc_splitter)
splitter.setSizes([400, 350])
def _build_card(self, title: str, name: str) -> QGroupBox:
box = QGroupBox(title)
v = QVBoxLayout(box)
big = QLabel("0%")
big.setObjectName(f"{name}_value")
big.setStyleSheet("font-size: 28pt; font-weight: bold;")
big.setAlignment(Qt.AlignCenter)
v.addWidget(big)
sub = QLabel("-")
sub.setObjectName(f"{name}_sub")
sub.setAlignment(Qt.AlignCenter)
sub.setStyleSheet("color: #888;")
v.addWidget(sub)
return box
def _build_table_card(self, title: str, headers: list) -> QGroupBox:
box = QGroupBox(title)
v = QVBoxLayout(box)
table = QTableWidget(0, len(headers))
table.setHorizontalHeaderLabels(headers)
table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
table.verticalHeader().setVisible(False)
table.setEditTriggers(QAbstractItemView.NoEditTriggers)
v.addWidget(table)
return box
def _build_proc_summary(self) -> QGroupBox:
"""进程总数 / 运行中 / 睡眠 / 僵尸 概览卡"""
box = QGroupBox("进程概览")
v = QVBoxLayout(box)
# 4 个数字一行
row1 = QHBoxLayout()
self.proc_total_value = QLabel("0")
self.proc_total_value.setStyleSheet("font-size: 18pt; font-weight: bold; color: #42a5f5;")
self.proc_total_value.setAlignment(Qt.AlignCenter)
row1.addWidget(self._wrap_with_caption(self.proc_total_value, "总数"))
self.proc_running_value = QLabel("0")
self.proc_running_value.setStyleSheet("font-size: 18pt; font-weight: bold; color: #66bb6a;")
self.proc_running_value.setAlignment(Qt.AlignCenter)
row1.addWidget(self._wrap_with_caption(self.proc_running_value, "运行"))
self.proc_sleep_value = QLabel("0")
self.proc_sleep_value.setStyleSheet("font-size: 18pt; font-weight: bold; color: #b0bec5;")
self.proc_sleep_value.setAlignment(Qt.AlignCenter)
row1.addWidget(self._wrap_with_caption(self.proc_sleep_value, "睡眠"))
self.proc_zombie_value = QLabel("0")
self.proc_zombie_value.setStyleSheet("font-size: 18pt; font-weight: bold; color: #ef5350;")
self.proc_zombie_value.setAlignment(Qt.AlignCenter)
row1.addWidget(self._wrap_with_caption(self.proc_zombie_value, "僵尸"))
v.addLayout(row1)
box.setMaximumWidth(380)
return box
def _wrap_with_caption(self, value_label: QLabel, caption: str) -> QWidget:
w = QWidget()
v = QVBoxLayout(w)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(2)
v.addWidget(value_label)
cap = QLabel(caption)
cap.setAlignment(Qt.AlignCenter)
cap.setStyleSheet("color: #888; font-size: 9pt;")
v.addWidget(cap)
return w
def _build_proc_table(self) -> QGroupBox:
"""进程列表:搜索 + 排序 + 树形切换 + 紧凑行 + 高亮 + 多选"""
box = QGroupBox("进程列表(500 条,多选用 Ctrl/Shift;右键批量杀;双击详情)")
v = QVBoxLayout(box)
v.setContentsMargins(6, 6, 6, 6)
v.setSpacing(4)
# 工具栏
toolbar = QHBoxLayout()
toolbar.setSpacing(4)
self.proc_search = QLineEdit()
self.proc_search.setPlaceholderText("🔍 过滤 (PID/USER/命令) 空格分隔多个关键字 AND 匹配")
self.proc_search.textChanged.connect(self._apply_proc_filter)
self.proc_search.setClearButtonEnabled(True)
toolbar.addWidget(self.proc_search, 2)
self.proc_sort_combo = QComboBox()
self.proc_sort_combo.addItems([
"按 CPU 降序", "按内存降序", "按 PID 升序",
"按启动时间(新→旧)", "按用户", "按命令名",
])
self.proc_sort_combo.currentIndexChanged.connect(self._apply_proc_filter)
toolbar.addWidget(self.proc_sort_combo)
self.proc_view_combo = QComboBox()
self.proc_view_combo.addItems(["📋 扁平", "🌳 树形"])
self.proc_view_combo.currentIndexChanged.connect(self._apply_proc_filter)
toolbar.addWidget(self.proc_view_combo)
self.proc_min_cpu = QSpinBox()
self.proc_min_cpu.setRange(0, 100)
self.proc_min_cpu.setValue(0)
self.proc_min_cpu.setSuffix("%+")
self.proc_min_cpu.setToolTip("只显示 CPU% ≥ 此值")
self.proc_min_cpu.setFixedWidth(70)
self.proc_min_cpu.valueChanged.connect(self._apply_proc_filter)
toolbar.addWidget(self.proc_min_cpu)
self.btn_batch_kill = QPushButton("☠ 批量杀")
self.btn_batch_kill.setToolTip("杀掉所有选中行(先 Ctrl/Shift 多选)")
self.btn_batch_kill.clicked.connect(self._proc_batch_kill)
toolbar.addWidget(self.btn_batch_kill)
v.addLayout(toolbar)
# 表格
self.proc_table = QTableWidget(0, 0) # 列数动态
self.proc_table.verticalHeader().setVisible(False)
self.proc_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.proc_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.proc_table.setSelectionMode(QAbstractItemView.ExtendedSelection) # 多选
self.proc_table.setAlternatingRowColors(True)
self.proc_table.verticalHeader().setDefaultSectionSize(20) # 紧凑行高
# 右键菜单
self.proc_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.proc_table.customContextMenuRequested.connect(self._proc_context_menu)
# 单击行 -> 详情面板
self.proc_table.itemSelectionChanged.connect(self._on_proc_row_selected)
# 双击行 -> 杀进程
self.proc_table.doubleClicked.connect(self._proc_kill_selected)
v.addWidget(self.proc_table, 1)
# 底部状态行
self.proc_status_label = QLabel("提示: Ctrl+A 全选, Shift+点击多选, 右键批量操作")
self.proc_status_label.setStyleSheet("color: #888; font-size: 9pt;")
v.addWidget(self.proc_status_label)
# 当前缓存的进程数据
self._proc_data: List[dict] = []
# 树形视图用的 PPID 索引
self._proc_tree_cache: Dict[int, dict] = {}
# 选中的 PID(多选)
self._selected_pids: List[int] = []
return box
def _build_proc_side(self) -> QWidget:
"""右侧栏:详情 + TOP 5 概览"""
w = QWidget()
v = QVBoxLayout(w)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(6)
# ---- 详情面板 ----
detail_box = QGroupBox("进程详情(点左侧行查看)")
dv = QVBoxLayout(detail_box)
self.proc_detail = QTextEdit()
self.proc_detail.setReadOnly(True)
self.proc_detail.setMaximumHeight(220)
self.proc_detail.setStyleSheet(
"QTextEdit { background: #1e1e1e; color: #e0e0e0;"
" font-family: Consolas, monospace; font-size: 9pt; }"
)
self.proc_detail.setPlainText("(点左侧任意进程行查看完整信息)")
dv.addWidget(self.proc_detail)
v.addWidget(detail_box)
# ---- TOP 5 CPU ----
cpu_box = QGroupBox("🔥 CPU TOP 5")
cv = QVBoxLayout(cpu_box)
self.proc_top_cpu = QListWidget()
self.proc_top_cpu.setStyleSheet(
"QListWidget { background: #1e1e1e; color: #e0e0e0; }"
)
self.proc_top_cpu.itemClicked.connect(self._on_top_item_clicked)
cv.addWidget(self.proc_top_cpu)
v.addWidget(cpu_box)
# ---- TOP 5 MEM ----
mem_box = QGroupBox("💾 内存 TOP 5")
mv = QVBoxLayout(mem_box)
self.proc_top_mem = QListWidget()
self.proc_top_mem.setStyleSheet(
"QListWidget { background: #1e1e1e; color: #e0e0e0; }"
)
self.proc_top_mem.itemClicked.connect(self._on_top_item_clicked)
mv.addWidget(self.proc_top_mem)
v.addWidget(mem_box)
v.addStretch(1)
return w
def _value_label(self, name: str) -> QLabel:
return self.findChild(QLabel, f"{name}_value")
def _sub_label(self, name: str) -> QLabel:
return self.findChild(QLabel, f"{name}_sub")
def set_host(self, host_id: str):
"""切换主机时停止旧监控并刷新 UI"""
self._stop_worker()
self.current_host_id = host_id
if not host_id:
self.host_label.setText("未连接")
return
h = self.manager.get_host(host_id)
if h:
self.host_label.setText(f"{h.get('name', h.get('host'))} ({h.get('host')})")
if self.btn_toggle.isChecked():
self._start_worker()
def _on_toggle(self, checked: bool):
if checked:
self.btn_toggle.setText("停止监控")
self._start_worker()
else:
self.btn_toggle.setText("开始监控")
self._stop_worker()
def _start_worker(self):
if not self.current_host_id:
return
conn = self.manager.get_connection(self.current_host_id)
if not conn or not conn.connected:
self.info_label.setText("⚠ 当前主机未连接")
return
idx = self.interval_combo.currentIndex()
interval_ms = [1000, 2000, 3000, 5000, 10000][idx]
self._stop_worker()
self._monitor_interval_ms = interval_ms
self._monitor_conn = conn
# 第一次立即采
self._kick_one_sample()
self.info_label.setText(f"已启动监控,每 {interval_ms // 1000} 秒刷新")
def _stop_worker(self):
# 取消 QTimer
if hasattr(self, "_monitor_timer") and self._monitor_timer:
try:
self._monitor_timer.stop()
except Exception:
pass
self._monitor_timer = None
# 停掉当前 worker
if self.worker:
try:
self.worker.stop()
if self.worker.isRunning():
self.worker.wait(500) # 不要等太久
except Exception:
pass
self.worker = None
self._last_net.clear()
def _kick_one_sample(self):
"""启动一个后台 worker 做一次采集;采完用 QTimer 调度下一次"""
if not getattr(self, "_monitor_conn", None):
return
if not self._monitor_conn.connected:
return
# 已经有 worker 在跑就不要重复起
if self.worker and self.worker.isRunning():
return
self.worker = MonitorWorker(self._monitor_conn, interval=0)
self.worker.sample_ready.connect(self._on_sample)
self.worker.error.connect(self._on_monitor_error)
self.worker.finished.connect(self._on_worker_finished)
self.worker.start()
def _on_refresh_now(self):
"""立即触发一次采集(不打断 QTimer 调度)"""
if not self.btn_toggle.isChecked():
QMessageBox.information(self, "提示", "请先点击「开始监控」")
return
self._kick_one_sample()
# 让 QTimer 知道我们刚采过——重新计时避免太快又采
if getattr(self, "_monitor_timer", None):
self._monitor_timer.start(getattr(self, "_monitor_interval_ms", 3000))
self.statusBar_msg = "已请求立即刷新" if hasattr(self, "statusBar_msg") else None # 兼容
def _on_worker_finished(self):
"""worker 跑完一次后,调度下一次(仅在监控开启时)"""
if not self.btn_toggle.isChecked():
return
if not getattr(self, "_monitor_timer", None):
self._monitor_timer = QTimer(self)
self._monitor_timer.setSingleShot(True)
self._monitor_timer.timeout.connect(self._kick_one_sample)
# 重新启动
self._monitor_timer.start(getattr(self, "_monitor_interval_ms", 3000))
def _on_monitor_error(self, msg: str):
self.info_label.setText(f"⚠ {msg}")
# 错误后等更久再试
if not self.btn_toggle.isChecked():
return
if not getattr(self, "_monitor_timer", None):
self._monitor_timer = QTimer(self)
self._monitor_timer.setSingleShot(True)
self._monitor_timer.timeout.connect(self._kick_one_sample)
self._monitor_timer.start(max(getattr(self, "_monitor_interval_ms", 3000) * 2, 5000))
def _on_sample(self, m: dict):
if m.get("error"):
self.info_label.setText(f"⚠ {m['error']}")
return
# 渲染优化:先关更新,最后一次开。500 行 × 12 列的 setRowCount 重绘
# 会触发大量 layout/styling 计算,关掉能省 100-300ms。
self.setUpdatesEnabled(False)
try:
self._render_sample(m)
finally:
self.setUpdatesEnabled(True)
def _render_sample(self, m: dict):
"""实际渲染一次采集数据。假定调用前已 setUpdatesEnabled(False)"""
# 主机
os_info = m.get("os", "")
krn = m.get("kernel", "")
host = m.get("hostname", "")
self.info_label.setText(f"主机: {host} · 系统: {os_info} · 内核: {krn}")
# CPU
cpu = m.get("cpu", 0)
self._value_label("cpu_card").setText(f"{cpu:.1f}%")
cores = m.get("cores", 1)
self._sub_label("cpu_card").setText(f"{cores} 核 CPU")
# 内存
mp = m.get("mem_percent", 0)
mu = SystemMonitor.format_bytes(m.get("mem_used", 0))
mt = SystemMonitor.format_bytes(m.get("mem_total", 0))
self._value_label("mem_card").setText(f"{mp:.1f}%")
self._sub_label("mem_card").setText(f"{mu} / {mt}")
# 负载
l1, l5, l15 = m.get("load1", 0), m.get("load5", 0), m.get("load15", 0)
self._value_label("load_card").setText(f"{l1:.2f}")
self._sub_label("load_card").setText(
f"5分钟: {l5:.2f} · 15分钟: {l15:.2f} (核心数={cores})"
)
# 启动时间
ut = SystemMonitor.format_uptime(m.get("uptime", 0))
self._value_label("uptime_card").setText(ut)
boot_ts = time.time() - m.get("uptime", 0)
self._sub_label("uptime_card").setText(
f"启动于: {time.strftime('%Y-%m-%d %H:%M', time.localtime(boot_ts))}"
)
# 进程概览
self.proc_total_value.setText(str(m.get("proc_total", 0)))
self.proc_running_value.setText(str(m.get("proc_running", 0)))
self.proc_sleep_value.setText(str(m.get("proc_sleep", 0)))
self.proc_zombie_value.setText(str(m.get("proc_zombie", 0)))
# 进程列表
self._proc_data = m.get("processes", [])
self._apply_proc_filter()
# 磁盘表
disks = m.get("disks", [])
disk_table: QTableWidget = self.disk_group.findChild(QTableWidget)
disk_table.setRowCount(len(disks))
for i, d in enumerate(disks):
disk_table.setItem(i, 0, QTableWidgetItem(d["mount"]))
used_s = SystemMonitor.format_bytes(d["used"])
total_s = SystemMonitor.format_bytes(d["total"])
disk_table.setItem(i, 1, QTableWidgetItem(f"{used_s} / {total_s}"))
pct = d["percent"]
disk_table.setItem(i, 2, QTableWidgetItem(f"{pct}%"))
bar = QProgressBar()
bar.setValue(min(pct, 100))
bar.setFormat(f"{pct}%")
disk_table.setCellWidget(i, 3, bar)
# 网络表(计算每秒速率)
nets = m.get("net", [])
net_table: QTableWidget = self.net_group.findChild(QTableWidget)
net_table.setRowCount(len(nets))
now = time.time()
for i, n in enumerate(nets):
rx, tx, iface = n["rx"], n["tx"], n["iface"]
rx_s, tx_s = "-", "-"
if iface in self._last_net:
last_rx, last_tx, last_ts = self._last_net[iface]
dt = max(now - last_ts, 0.001)
rx_speed = (rx - last_rx) / dt
tx_speed = (tx - last_tx) / dt
rx_s = f"{SystemMonitor.format_bytes(rx)} ({SystemMonitor.format_bytes(int(rx_speed))}/s)"
tx_s = f"{SystemMonitor.format_bytes(tx)} ({SystemMonitor.format_bytes(int(tx_speed))}/s)"
else:
rx_s = f"{SystemMonitor.format_bytes(rx)}"
tx_s = f"{SystemMonitor.format_bytes(tx)}"
self._last_net[iface] = (rx, tx, now)
net_table.setItem(i, 0, QTableWidgetItem(iface))
net_table.setItem(i, 1, QTableWidgetItem(rx_s))
net_table.setItem(i, 2, QTableWidgetItem(tx_s))
if iface in self._last_net and len(self._last_net[iface]) == 3:
last_rx, last_tx, last_ts = self._last_net[iface]
dt = max(now - last_ts, 0.001)
rx_speed = (rx - last_rx) / dt
tx_speed = (tx - last_tx) / dt
net_table.setItem(i, 3, QTableWidgetItem(
f"↓{SystemMonitor.format_bytes(int(rx_speed))}/s ↑{SystemMonitor.format_bytes(int(tx_speed))}/s"))
else:
net_table.setItem(i, 3, QTableWidgetItem("采样中..."))
# ============================================================
# 进程表:过滤 / 排序 / 树形 / TOP 5 / 详情 / 杀进程
# ============================================================
# 表格列定义(树形模式下第 1 列是缩进/连接线,普通模式第 1 列是 PID)
PROC_COLS = [
("PID", "pid"), # 扁平
("PPID", "ppid"),
("USER", "user"),
("CPU%", "pcpu"),
("MEM%", "pmem"),
("RSS", "rss"),
("STAT", "stat"),
("PRI", "pri"),
("NI", "nice"),
("启动", "etime"),
("CPU时间", "time"),
("命令", "comm"),
]
# 渲染上限:500 行采集但表格最多显示 N 行(树形不受限)
MAX_RENDER_ROWS = 200
def _apply_proc_filter(self):
"""根据搜索框 + 排序 + 视图模式 + CPU 过滤,刷新进程表和 TOP 5"""
if not hasattr(self, "proc_table"):
return
# 优化:500 行表格重建时阻塞信号 + 暂停更新,结束后再统一刷新
self.proc_table.blockSignals(True)
self.proc_table.setUpdatesEnabled(False)
try:
self._apply_proc_filter_impl()
finally:
self.proc_table.setUpdatesEnabled(True)
self.proc_table.blockSignals(False)
self.proc_table.viewport().update()
def _apply_proc_filter_impl(self):
query = self.proc_search.text().strip().lower() if hasattr(self, "proc_search") else ""
terms = [t for t in query.split() if t]
sort_idx = self.proc_sort_combo.currentIndex() if hasattr(self, "proc_sort_combo") else 0
view_idx = self.proc_view_combo.currentIndex() if hasattr(self, "proc_view_combo") else 0
is_tree = (view_idx == 1)
min_cpu = self.proc_min_cpu.value() if hasattr(self, "proc_min_cpu") else 0
data = list(self._proc_data)
# 过滤
if terms or min_cpu > 0:
def match(p):
if min_cpu > 0 and p.get("pcpu", 0) < min_cpu:
return False
if terms:
hay = f"{p.get('pid','')} {p.get('ppid','')} {p.get('user','')} {p.get('stat','')} {p.get('comm','')}".lower()
if not all(t in hay for t in terms):
return False
return True
data = [p for p in data if match(p)]
# 排序
if sort_idx == 0: # CPU
data.sort(key=lambda p: p.get("pcpu", 0), reverse=True)
elif sort_idx == 1: # MEM
data.sort(key=lambda p: p.get("pmem", 0), reverse=True)
elif sort_idx == 2: # PID
data.sort(key=lambda p: p.get("pid", 0))
elif sort_idx == 3: # 启动时间(新→旧)
data.sort(key=lambda p: p.get("etime", 0), reverse=True)
elif sort_idx == 4: # 用户
data.sort(key=lambda p: (p.get("user", ""), -p.get("pcpu", 0)))
elif sort_idx == 5: # 命令名
data.sort(key=lambda p: (p.get("comm", "").split()[0] if p.get("comm") else "", -p.get("pcpu", 0)))
# 渲染上限:扁平视图截断到 MAX_RENDER_ROWS,避免 setRowCount 500 太慢
if not is_tree and len(data) > self.MAX_RENDER_ROWS:
data = data[:self.MAX_RENDER_ROWS]
# 树形视图:按 PPID 排序 + 缩进
if is_tree:
data = self._build_tree_view(data)
else:
# 扁平:按 PPID 隐藏列不显示;直接就是排序后列表
pass
# 重置表格
n_cols = len(self.PROC_COLS) + (1 if is_tree else 0)
self.proc_table.setColumnCount(n_cols)
headers = (["▾"] if is_tree else []) + [h[0] for h in self.PROC_COLS]
self.proc_table.setHorizontalHeaderLabels(headers)
h = self.proc_table.horizontalHeader()
if is_tree:
h.setSectionResizeMode(0, QHeaderView.ResizeToContents) # 树形缩进
for i, (name, _) in enumerate(self.PROC_COLS):
offset = 1 if is_tree else 0
if name == "命令":
h.setSectionResizeMode(i + offset, QHeaderView.Stretch)
else:
h.setSectionResizeMode(i + offset, QHeaderView.ResizeToContents)
self.proc_table.setRowCount(len(data))
for row, item in enumerate(data):
if is_tree:
p, depth, is_last = item["proc"], item["depth"], item["is_last"]
# 树形缩进列
if depth == 0:
indent = ""
else:
indent = ("│ " * (depth - 1)) + ("└─ " if is_last else "├─ ")
tree_item = QTableWidgetItem(indent)
tree_item.setData(Qt.UserRole, p["pid"])
tree_item.setData(Qt.UserRole + 1, p["comm"])
if depth == 0:
tree_item.setForeground(QColor("#42a5f5"))
self.proc_table.setItem(row, 0, tree_item)
col_offset = 1
else:
p = item
col_offset = 0
self._fill_proc_row(row, p, col_offset)
# 更新标题 + 状态行
total = len(self._proc_data)
shown = len(data)
mode_label = "树形" if is_tree else "扁平"
# 如果渲染被截断,标 "(渲染 top N)"
truncated = (not is_tree) and total > self.MAX_RENDER_ROWS and not terms and not min_cpu
if terms or min_cpu > 0 or sort_idx != 0 or is_tree or truncated:
suffix = ""
if truncated and shown == self.MAX_RENDER_ROWS:
suffix = f" (top {self.MAX_RENDER_ROWS}, 共 {total})"
self.proc_group.setTitle(f"进程列表 · {mode_label} · {shown}/{total}{suffix}")
else:
self.proc_group.setTitle(f"进程列表 · {mode_label} · {total} 条")
# TOP 5
self._refresh_top5()
def _build_tree_view(self, data: List[dict]) -> List[dict]:
"""按 PPID 构建树视图(保持 CPU 排序,只调整子进程位置)"""
if not data:
return []
# 把所有可见 PID 做集合
visible_pids = {p["pid"] for p in data}
# PPID 索引
children_of: Dict[int, List[dict]] = {}
for p in data:
ppid = p.get("ppid", 0)
children_of.setdefault(ppid, []).append(p)
# 排序子进程
for ppid in children_of:
children_of[ppid].sort(key=lambda p: p.get("pcpu", 0), reverse=True)
# 从 PID 0 / 1 出发(init / kthreadd),BFS 展开
result: List[dict] = []
roots = children_of.get(0, []) + children_of.get(1, [])
# 也加入那些 PPID 不在可见集合的(孤儿)
orphan_ppids = set(children_of.keys()) - {0, 1}
for ppid in sorted(orphan_ppids):
for p in children_of[ppid]:
if p.get("ppid") not in visible_pids:
roots.append(p)
def walk(node, depth, is_last):
result.append({"proc": node, "depth": depth, "is_last": is_last})
# 找子进程
kids = children_of.get(node["pid"], [])
for i, k in enumerate(kids):
walk(k, depth + 1, i == len(kids) - 1)
for i, r in enumerate(roots):
walk(r, 0, i == len(roots) - 1)
return result
def _fill_proc_row(self, row: int, p: dict, col_offset: int):
"""填充一行单元格。col_offset 决定从第几列开始(树形=1,扁平=0)"""
pid = p.get("pid", 0)
ppid = p.get("ppid", 0)
user = p.get("user", "")
pcpu = p.get("pcpu", 0.0)
pmem = p.get("pmem", 0.0)
rss = p.get("rss", 0)
stat = p.get("stat", "")
pri = p.get("pri", 0)
nice = p.get("nice", 0)
etime = p.get("etime", 0)
ptime = p.get("time", 0)
comm = p.get("comm", "")
# PID
pid_item = QTableWidgetItem(str(pid))
pid_item.setData(Qt.UserRole, pid)
pid_item.setData(Qt.UserRole + 1, comm)
pid_item.setData(Qt.UserRole + 2, ppid) # 给详情面板用
pid_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.proc_table.setItem(row, col_offset, pid_item)
# PPID
ppid_item = QTableWidgetItem(str(ppid) if ppid else "-")
ppid_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.proc_table.setItem(row, col_offset + 1, ppid_item)
# USER(截断到 12 字符)
user_display = user if len(user) <= 12 else user[:11] + "…"
user_item = QTableWidgetItem(user_display)
user_item.setToolTip(user)
self.proc_table.setItem(row, col_offset + 2, user_item)
# CPU%
cpu_item = QTableWidgetItem(f"{pcpu:.1f}")
cpu_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
if pcpu >= 80:
cpu_item.setBackground(QColor(0xef, 0x53, 0x50, 60))
cpu_item.setForeground(QColor("#ef5350"))
font = cpu_item.font()
font.setBold(True)
cpu_item.setFont(font)
elif pcpu >= 50:
cpu_item.setForeground(QColor("#ef5350"))
font = cpu_item.font()
font.setBold(True)
cpu_item.setFont(font)
elif pcpu >= 20:
cpu_item.setForeground(QColor("#ffa726"))
self.proc_table.setItem(row, col_offset + 3, cpu_item)
# MEM%
mem_item = QTableWidgetItem(f"{pmem:.1f}")
mem_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
if pmem >= 15:
mem_item.setBackground(QColor(0xff, 0xa7, 0x26, 60))
mem_item.setForeground(QColor("#ffa726"))
font = mem_item.font()
font.setBold(True)
mem_item.setFont(font)
elif pmem >= 5:
mem_item.setForeground(QColor("#ffa726"))
self.proc_table.setItem(row, col_offset + 4, mem_item)
# RSS
rss_item = QTableWidgetItem(SystemMonitor.format_bytes(rss * 1024))
rss_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.proc_table.setItem(row, col_offset + 5, rss_item)
# STAT(含 Z 用红字 + 整行加黄底)
stat_item = QTableWidgetItem(stat)
if stat.startswith("Z"):
stat_item.setBackground(QColor(0xff, 0xc1, 0x07, 80))
stat_item.setForeground(QColor("#ff6f00"))
font = stat_item.font()
font.setBold(True)
stat_item.setFont(font)
elif stat.startswith("R"):
stat_item.setForeground(QColor("#66bb6a"))
elif "D" in stat:
stat_item.setForeground(QColor("#ab47bc"))
self.proc_table.setItem(row, col_offset + 6, stat_item)
# PRI
pri_item = QTableWidgetItem(str(pri))
pri_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.proc_table.setItem(row, col_offset + 7, pri_item)
# NI
ni_item = QTableWidgetItem(str(nice))
ni_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
if nice < 0:
ni_item.setForeground(QColor("#66bb6a"))
elif nice > 0:
ni_item.setForeground(QColor("#ffa726"))
self.proc_table.setItem(row, col_offset + 8, ni_item)
# 启动时长
self.proc_table.setItem(row, col_offset + 9, QTableWidgetItem(self._fmt_duration(etime)))
# CPU 时间
self.proc_table.setItem(row, col_offset + 10, QTableWidgetItem(self._fmt_duration(ptime)))
# 命令(截断显示 + tooltip 全内容)
comm_display = comm if len(comm) <= 200 else comm[:200] + "…"
comm_item = QTableWidgetItem(comm_display)
comm_item.setToolTip(comm)
self.proc_table.setItem(row, col_offset + 11, comm_item)
def _refresh_top5(self):
"""刷新 TOP 5 CPU / 内存 列表"""
if not hasattr(self, "proc_top_cpu"):
return
top_cpu = sorted(self._proc_data, key=lambda p: p.get("pcpu", 0), reverse=True)[:5]
top_mem = sorted(self._proc_data, key=lambda p: p.get("pmem", 0), reverse=True)[:5]
self.proc_top_cpu.clear()
for i, p in enumerate(top_cpu, 1):
txt = f"{i}. PID {p['pid']:<7} {p.get('pcpu', 0):>5.1f}% {p.get('comm','')[:30]}"
item = QListWidgetItem(txt)
item.setData(Qt.UserRole, p["pid"])
self.proc_top_cpu.addItem(item)
self.proc_top_mem.clear()
for i, p in enumerate(top_mem, 1):
txt = f"{i}. PID {p['pid']:<7} {p.get('pmem', 0):>5.1f}% {SystemMonitor.format_bytes(p.get('rss', 0) * 1024):>7} {p.get('comm','')[:25]}"
item = QListWidgetItem(txt)
item.setData(Qt.UserRole, p["pid"])
self.proc_top_mem.addItem(item)
def _on_proc_row_selected(self):
"""点行 -> 详情面板"""
rows = self.proc_table.selectionModel().selectedRows()
if not rows:
return
row = rows[0].row()
col_offset = 1 if self.proc_view_combo.currentIndex() == 1 else 0
pid_item = self.proc_table.item(row, col_offset)
if not pid_item:
return
pid = pid_item.data(Qt.UserRole)
# 找到进程
p = next((x for x in self._proc_data if x["pid"] == pid), None)
if p:
self._show_proc_detail(p)
def _show_proc_detail(self, p: dict):
"""显示一个进程的完整信息到右侧详情面板"""
comm = p.get("comm", "")
comm_first = comm.split()[0] if comm else ""
lines = [
f"PID : {p.get('pid')} PPID : {p.get('ppid', 0)} USER : {p.get('user','')}",
f"STATE : {p.get('stat','')} PRI : {p.get('pri',0)} NICE: {p.get('nice',0)}",
f"CPU : {p.get('pcpu', 0):.1f}% MEM : {p.get('pmem', 0):.1f}%",
f"RSS : {SystemMonitor.format_bytes(p.get('rss', 0) * 1024)} VSZ : {SystemMonitor.format_bytes(p.get('vsz', 0) * 1024)}",
f"运行 : {self._fmt_duration(p.get('etime', 0))} 累计CPU: {self._fmt_duration(p.get('time', 0))}",
"-" * 50,
f"COMMAND:",
f" {comm}",
]
# 如果有 PPID,找父进程名
ppid = p.get("ppid", 0)
if ppid:
parent = next((x for x in self._proc_data if x["pid"] == ppid), None)
if parent:
pcomm = parent.get("comm", "").split()[0] if parent.get("comm") else "?"
lines.insert(5, f"父进程 : {ppid} ({pcomm})")
else:
lines.insert(5, f"父进程 : {ppid} (未在列表中,可能已退出)")
# 找子进程
kids = [x for x in self._proc_data if x.get("ppid") == p.get("pid")]
if kids:
lines.append("-" * 50)
lines.append(f"子进程 ({len(kids)}):")
for k in kids[:10]:
kcomm = k.get("comm", "").split()[0] if k.get("comm") else "?"
lines.append(f" PID {k.get('pid',0):<6} {kcomm}")
if len(kids) > 10:
lines.append(f" ... 还有 {len(kids) - 10} 个")
self.proc_detail.setPlainText("\n".join(lines))
def _on_top_item_clicked(self, item: QListWidgetItem):
"""点 TOP 5 行 -> 滚动到主表对应行 + 选中"""
pid = item.data(Qt.UserRole)
col_offset = 1 if self.proc_view_combo.currentIndex() == 1 else 0
for row in range(self.proc_table.rowCount()):
pid_item = self.proc_table.item(row, col_offset)
if pid_item and pid_item.data(Qt.UserRole) == pid:
self.proc_table.selectRow(row)
self.proc_table.scrollToItem(pid_item, QAbstractItemView.PositionAtCenter)
return
@staticmethod
def _fmt_duration(seconds: int) -> str:
"""把秒数格式化成 5d3h / 2h15m / 45s"""
s = int(seconds)
if s < 0:
s = 0
days, rem = divmod(s, 86400)
hours, rem = divmod(rem, 3600)
mins, secs = divmod(rem, 60)
if days:
return f"{days}d{hours}h"
if hours:
return f"{hours}h{mins}m"
if mins:
return f"{mins}m{secs}s"
return f"{secs}s"
def _proc_context_menu(self, pos):
idx = self.proc_table.indexAt(pos)
if not idx.isValid():
return
row = idx.row()
col_offset = 1 if self.proc_view_combo.currentIndex() == 1 else 0
pid_item = self.proc_table.item(row, col_offset)
if not pid_item:
return
pid = pid_item.data(Qt.UserRole)
comm = pid_item.data(Qt.UserRole + 1) or ""
# 选中的行数
sel_count = len(self.proc_table.selectionModel().selectedRows())
menu = QMenu(self.proc_table)
a_soft = menu.addAction(f"🔪 kill {pid} (SIGTERM)")
a_hard = menu.addAction(f"☠ kill -9 {pid} (SIGKILL)")
if sel_count > 1:
menu.addSeparator()
a_batch = menu.addAction(f"☠ 批量杀选中 {sel_count} 个 (SIGTERM)")
else:
a_batch = None
menu.addSeparator()
a_copy = menu.addAction("复制 PID")
a_copy_comm = menu.addAction("复制命令行")
a_filter = menu.addAction("按此命令过滤")
a_detail = menu.addAction("查看详情")
chosen = menu.exec_(self.proc_table.viewport().mapToGlobal(pos))
if chosen == a_soft:
self._proc_kill(pid, comm, force=False)
elif chosen == a_hard:
self._proc_kill(pid, comm, force=True)
elif chosen == a_batch:
self._proc_batch_kill()
elif chosen == a_copy:
QApplication.clipboard().setText(str(pid))
elif chosen == a_copy_comm:
QApplication.clipboard().setText(comm)
elif chosen == a_filter:
# 取 comm 的第一段(命令名)
base = comm.split()[0] if comm else ""
if base:
self.proc_search.setText(base)
elif chosen == a_detail:
self._show_proc_detail(next((x for x in self._proc_data if x["pid"] == pid), {"pid": pid}))
def _proc_kill_selected(self):
"""双击行:杀进程(弹确认)"""
rows = self.proc_table.selectionModel().selectedRows()
if not rows:
return
row = rows[0].row()
col_offset = 1 if self.proc_view_combo.currentIndex() == 1 else 0
pid_item = self.proc_table.item(row, col_offset)
if not pid_item:
return
pid = pid_item.data(Qt.UserRole)
comm = pid_item.data(Qt.UserRole + 1) or ""
self._proc_kill(pid, comm, force=False)
def _proc_batch_kill(self):
"""批量杀所有选中行"""
rows = self.proc_table.selectionModel().selectedRows()
if not rows:
QMessageBox.information(self, "提示", "请先在表格里选中要杀的多行(Ctrl/Shift 多选)")
return
col_offset = 1 if self.proc_view_combo.currentIndex() == 1 else 0
targets = [] # [(pid, comm), ...]
for r in rows:
pid_item = self.proc_table.item(r.row(), col_offset)
if pid_item:
pid = pid_item.data(Qt.UserRole)
comm = pid_item.data(Qt.UserRole + 1) or ""
if pid:
targets.append((pid, comm))
if not targets:
return
# 确认
preview = "\n".join(f" PID {p:<7} {c[:60]}" for p, c in targets[:10])
if len(targets) > 10:
preview += f"\n ... 还有 {len(targets) - 10} 个"
reply = QMessageBox.question(
self, f"批量杀 {len(targets)} 个进程",
f"将向以下进程发送 SIGTERM:\n\n{preview}\n\n"
f"建议先 SIGTERM,等几秒不响应再 SIGKILL。\n继续吗?",
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
if not self.current_host_id:
return
conn = self.manager.get_connection(self.current_host_id)
if not conn or not conn.connected:
QMessageBox.warning(self, "未连接", "主机未连接")
return
# 一次 ssh 跑全部 kill(避免循环 ssh 开销)
pids = " ".join(str(p) for p, _ in targets)
code, out, err = conn.exec_command(
f"kill {pids} 2>&1 ; sleep 0.5 ; for p in {pids}; do "
f"if kill -0 $p 2>/dev/null; then echo \"$p alive\"; fi; done",
timeout=15,
)
killed = len(targets) - (out.count("alive") if "alive" in out else 0)
QMessageBox.information(
self, "批量杀完成",
f"共 {len(targets)} 个,已确认退出 {killed} 个\n"
f"残留(未响应 SIGTERM,建议 SIGKILL): {out.count('alive')}\n\n"
f"远程输出:\n{out.strip()[:500]}")
def _proc_kill(self, pid: int, comm: str, force: bool = False):
"""在远程主机上执行 kill [pid]"""
if not self.current_host_id:
return
conn = self.manager.get_connection(self.current_host_id)
if not conn or not conn.connected:
QMessageBox.warning(self, "未连接", "主机未连接")
return
sig = "SIGKILL" if force else "SIGTERM"
flag = "-9" if force else ""
short_comm = (comm[:60] + "…") if len(comm) > 60 else comm
reply = QMessageBox.question(
self, f"杀进程 {pid}",
f"确定要 {sig} 进程 {pid} 吗?\n\n"
f"命令: {short_comm}\n\n"
f"建议: 先 SIGTERM 让进程清理;不响应再 SIGKILL。",
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
code, out, err = conn.exec_command(f"kill {flag} {pid} 2>&1; echo \"exit=$?\"", timeout=10)
if code == 0:
QMessageBox.information(self, "完成",
f"已对 PID {pid} 发送 {sig}\n远程返回:\n{out.strip()}")
else:
QMessageBox.critical(self, "失败",
f"kill {pid} 失败\n退出码: {code}\nstderr: {err.strip()}\nstdout: {out.strip()}")
# ============================================================
# AI Agent 对话面板
# ============================================================
class AIChatPanel(QWidget):
"""AI Agent 对话面板"""
def __init__(self, manager: ConnectionManager, agent, parent=None):
super().__init__(parent)
self.manager = manager
self.agent = agent
self.worker: Optional[AIWorker] = None
self.current_host_id: Optional[str] = None
self._build_ui()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
# 顶部状态
head = QHBoxLayout()
self.status_label = QLabel("AI Agent: 未配置")
self.status_label.setStyleSheet("color: #888;")
head.addWidget(self.status_label)
head.addStretch(1)
self.btn_config = QPushButton("⚙ AI 设置")
self.btn_config.clicked.connect(self._show_config)
head.addWidget(self.btn_config)
self.btn_clear = QPushButton("清空对话")
self.btn_clear.clicked.connect(self._clear)
head.addWidget(self.btn_clear)
layout.addLayout(head)
# 快捷指令
quick = QHBoxLayout()
quick.addWidget(QLabel("快捷:"))
for label, prompt in [
("检查状态", "帮我检查当前主机的运行状态,包括 CPU/内存/磁盘/网络"),
("查日志", "查看最近 100 行系统日志,重点关注 error 和 warning"),
("找大文件", "列出 /var/log 目录下最大的 10 个文件"),
("查端口", "查看当前监听的端口以及对应进程"),
]:
b = QPushButton(label)
b.clicked.connect(lambda _, p=prompt: self.input_edit.setText(p))
quick.addWidget(b)
quick.addStretch(1)
layout.addLayout(quick)
# 对话显示
self.chat_view = QTextEdit()
self.chat_view.setReadOnly(True)
self.chat_view.setStyleSheet("""
QTextEdit {
background: #1e1e1e;
color: #e0e0e0;
font-family: Consolas, 'Courier New', monospace;
font-size: 10pt;
}
""")
layout.addWidget(self.chat_view, 1)
# 输入区
input_layout = QHBoxLayout()
self.input_edit = QLineEdit()
self.input_edit.setPlaceholderText("输入问题,回车发送(Shift+回车换行)...")
self.input_edit.returnPressed.connect(self._send)
self.btn_send = QPushButton("发送")
self.btn_send.clicked.connect(self._send)
input_layout.addWidget(self.input_edit, 1)
input_layout.addWidget(self.btn_send)
layout.addLayout(input_layout)
self._append("系统", "AI Agent 已就绪。请先点击「⚙ AI 设置」配置 API Key。")
def set_host(self, host_id: str):
self.current_host_id = host_id
if host_id:
h = self.manager.get_host(host_id)
if h:
self._append("系统", f"已切换到主机: {h.get('name', h.get('host'))}")
def _append(self, role: str, content: str, color: str = ""):
ts = datetime.now().strftime("%H:%M:%S")
color_map = {
"user": "#4fc3f7", "assistant": "#aed581", "assistant_thinking": "#ffb74d",
"tool_call": "#ba68c8", "tool_result": "#90a4ae", "error": "#ef5350",
"system": "#888",
}
c = color or color_map.get(role, "#e0e0e0")
safe = (content or "").replace("&", "&").replace("<", "<").replace(">", ">")
safe = safe.replace("\n", "
")
role_label_map = {
"user": "我", "assistant": "AI", "assistant_thinking": "AI 思考",
"tool_call": "调用", "tool_result": "结果", "error": "错误", "system": "系统",
}
rl = role_label_map.get(role, role)
self.chat_view.append(
f'[{ts}] '
f'{rl}: '
f'{safe}'
)
# 滚动到底
sb = self.chat_view.verticalScrollBar()
sb.setValue(sb.maximum())
def _send(self):
if self.worker and self.worker.isRunning():
return
text = self.input_edit.text().strip()
if not text:
return
if not self.agent.api_key:
QMessageBox.warning(self, "未配置", "请先在「⚙ AI 设置」中配置 API Key")
return
self._append("user", text)
self.input_edit.clear()
self.btn_send.setEnabled(False)
self.btn_send.setText("思考中...")
conn = self.manager.get_connection(self.current_host_id) if self.current_host_id else None
self.worker = AIWorker(self.agent, text, conn)
self.worker.step.connect(self._append)
self.worker.finished_with.connect(self._on_done)
self.worker.error.connect(self._on_error)
self.worker.start()
def _on_done(self, final: str):
self._append("assistant", final)
self.btn_send.setEnabled(True)
self.btn_send.setText("发送")
def _on_error(self, msg: str):
self._append("error", msg)
self.btn_send.setEnabled(True)
self.btn_send.setText("发送")
def _clear(self):
self.agent.clear_history()
self.chat_view.clear()
self._append("系统", "对话历史已清空")
def _show_config(self):
from .config_dialog import AIConfigDialog
dlg = AIConfigDialog(self.agent, self)
if dlg.exec_():
self.status_label.setText(
f"AI Agent: {self.agent.model} @ {self.agent.base_url}")
self._append("系统", "AI 配置已更新")