diff --git a/core/monitor.py b/core/monitor.py index bebe3fb..a64f4fe 100644 --- a/core/monitor.py +++ b/core/monitor.py @@ -49,6 +49,20 @@ for iface in $(ls /sys/class/net/ 2>/dev/null | grep -v lo); do tx=$(cat /sys/class/net/$iface/statistics/tx_bytes 2>/dev/null || echo 0) echo "NET|$iface|$rx|$tx" done +echo "===PROC===" +# 进程统计 +ps -e -o stat= 2>/dev/null | awk '{r+=($1~/^R/); s+=($1~/^S/); d+=($1~/^D/); z+=($1~/^Z/); t++} END{printf "PROC_TOTAL=%d\nPROC_RUNNING=%d\nPROC_SLEEP=%d\nPROC_DISK=%d\nPROC_ZOMBIE=%d\n", t, r, s, d, z}' +# 进程列表:etimes/times 都是纯数字,避免 start 字段含 "Jul 21" 多列错位 +# comm 字段可能含空格,前 9 列用 \t 拼,comm 用换行做记录结束 +ps -eo pid,user,pcpu,pmem,vsz,rss,stat,etimes,times,args --sort=-pcpu --no-headers 2>/dev/null \ + | head -200 \ + | awk '{ + out="PROC\t"; + for(i=1;i<=9;i++) out=out $i "\t"; + rest=""; + for(i=10;i<=NF;i++) rest=(i==10?$i:rest " " $i); + print out rest + }' echo "===HOST===" echo "HOSTNAME=$(hostname)" echo "KERNEL=$(uname -r)" @@ -70,6 +84,8 @@ echo "OS=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)" "mem_total": 0, "mem_used": 0, "mem_percent": 0.0, "swap_total": 0, "swap_used": 0, "disks": [], "net": [], + "proc_total": 0, "proc_running": 0, "proc_sleep": 0, + "proc_disk": 0, "proc_zombie": 0, "processes": [], "ts": time.time(), } if not conn or not conn.connected: @@ -137,6 +153,47 @@ echo "OS=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)" except ValueError: continue + # 进程统计 + try: + result["proc_total"] = int(cls._parse_kv(out, "PROC_TOTAL")) + result["proc_running"] = int(cls._parse_kv(out, "PROC_RUNNING")) + result["proc_sleep"] = int(cls._parse_kv(out, "PROC_SLEEP")) + result["proc_disk"] = int(cls._parse_kv(out, "PROC_DISK")) + result["proc_zombie"] = int(cls._parse_kv(out, "PROC_ZOMBIE")) + except ValueError: + result["proc_total"] = 0 + result["proc_running"] = 0 + result["proc_sleep"] = 0 + result["proc_disk"] = 0 + result["proc_zombie"] = 0 + + # 进程列表(远程用 \t 分隔前 9 列,第 10 列开始是 args/comm,可能含空格) + result["processes"] = [] + for line in out.splitlines(): + if not line.startswith("PROC\t"): + continue + payload = line[5:] # 去掉 "PROC\t" 前缀 + fields = payload.split("\t", 9) # 只切前 9 次 + if len(fields) < 10: + continue + try: + result["processes"].append({ + "pid": int(fields[0]), + "user": fields[1], + "pcpu": float(fields[2]), + "pmem": float(fields[3]), + "vsz": int(fields[4]), + "rss": int(fields[5]), + "stat": fields[6], + # etimes: 自启动以来的秒数(整数) + "etime": int(fields[7]) if fields[7].isdigit() else 0, + # times: 累计 CPU 时间秒数 + "time": int(fields[8]) if fields[8].isdigit() else 0, + "comm": fields[9].strip(), + }) + except (ValueError, IndexError): + continue + return result @staticmethod diff --git a/test_e2e.py b/test_e2e.py index cd456ef..162bba0 100644 --- a/test_e2e.py +++ b/test_e2e.py @@ -23,7 +23,7 @@ def get_local_ssh_auth(): """探测本机 SSH 登录方式:测试密码""" # 优先尝试 root 密码(如果测试环境设了) candidates = [ - {"host": "127.0.0.1", "port": 22, "username": "root", "password": "testpass"}, + {"host": "127.0.0.1", "port": 22, "username": "root", "password": "sshclient_test_pwd_2026"}, {"host": "127.0.0.1", "port": 22, "username": "root", "password": ""}, ] for c in candidates: @@ -170,7 +170,7 @@ def test_real_ssh_workflow(): conn3, msg3 = try_connect(auth) if not conn3: - conn3, msg3 = try_connect({"host": "127.0.0.1", "port": 22, "username": "root", "password": "testpass"}) + conn3, msg3 = try_connect({"host": "127.0.0.1", "port": 22, "username": "root", "password": "sshclient_test_pwd_2026"}) def mock_tool(name, args): if name == "exec_ssh_command": if conn3 and conn3.connected: diff --git a/test_process_monitor.py b/test_process_monitor.py new file mode 100644 index 0000000..4d56667 --- /dev/null +++ b/test_process_monitor.py @@ -0,0 +1,184 @@ +""" +进程监控端到端测试: +- 真实 SSH 连接 +- SystemMonitor.collect() 解析进程统计 + 进程列表 +- MonitorPanel 在真实数据下渲染进程表 +- 搜索 / 排序 / 杀进程(用 echo $$ 自己的 PID 测试) +""" +import os +import sys +import time + +os.environ["QT_QPA_PLATFORM"] = "offscreen" +sys.path.insert(0, os.path.dirname(__file__)) + +from PyQt5.QtWidgets import QApplication +from PyQt5.QtCore import Qt, QTimer + +from core.ssh_client import SSHConnection +from core.monitor import SystemMonitor +from core.manager import ConnectionManager +from ui.widgets import MonitorPanel + + +def find_local_ssh(): + for pwd in ("sshclient_test_pwd_2026", "testpass", ""): + c = SSHConnection("127.0.0.1", 22, "root", pwd, timeout=5) + ok, _ = c.connect() + if ok: + return c + return None + + +def main(): + app = QApplication(sys.argv) + print("[1/5] 连接本机 SSH") + conn = find_local_ssh() + if not conn: + print(" ⚠ 本机 SSH 不可用,跳过") + return + print(f" ✓ {conn.username}@{conn.host}") + + print("[2/5] SystemMonitor 采集进程数据") + m = SystemMonitor.collect(conn) + assert m["proc_total"] > 50, f"应有大量进程,实际 {m['proc_total']}" + assert m["proc_running"] >= 1, "至少应有 1 个运行中进程" + assert m["proc_sleep"] > 0, "应有睡眠进程" + assert len(m["processes"]) > 0 + # 验证至少一条进程包含完整字段 + p = m["processes"][0] + for k in ("pid", "user", "pcpu", "pmem", "vsz", "rss", "stat", "etime", "time", "comm"): + assert k in p, f"缺字段 {k}" + assert p["pid"] > 0 + assert p["comm"], "comm 字段不应为空" + print(f" ✓ 进程 {m['proc_total']} 个 (运行 {m['proc_running']} / 睡眠 {m['proc_sleep']} / 僵尸 {m['proc_zombie']})") + print(f" ✓ TOP1: PID={p['pid']} USER={p['user']} CPU={p['pcpu']}% MEM={p['pmem']}% STAT={p['stat']} COMM={p['comm'][:50]}") + + print("[3/5] MonitorPanel 渲染进程表") + mgr = ConnectionManager() + mgr.connections["fake"] = conn + panel = MonitorPanel(mgr) + panel.set_host("fake") + panel._proc_data = m["processes"] + panel._apply_proc_filter() + assert panel.proc_table.rowCount() == len(m["processes"]) + # 验证第一列 (PID) 是数字 + pid_text = panel.proc_table.item(0, 0).text() + assert pid_text.isdigit(), f"PID 列不是数字: {pid_text!r}" + print(f" ✓ 进程表行数: {panel.proc_table.rowCount()}") + print(f" ✓ 第 1 行 PID={pid_text} CPU={panel.proc_table.item(0, 2).text()}%") + + print("[4/5] 搜索过滤") + # 找一个常见的进程名 + sample = m["processes"][0] + comm_first = sample["comm"].split()[0] if sample["comm"] else "" + if not comm_first: + # 退而求其次用 sshd + comm_first = "sshd" + panel.proc_search.setText(comm_first) + app.processEvents() + shown = panel.proc_table.rowCount() + assert shown > 0, f"按 '{comm_first}' 过滤后应至少 1 行" + assert shown < len(m["processes"]), f"过滤后应少于总数: {shown}/{len(m['processes'])}" + print(f" ✓ 搜索 '{comm_first}' 过滤后剩 {shown}/{len(m['processes'])} 行") + + # 清空搜索 + panel.proc_search.setText("") + app.processEvents() + assert panel.proc_table.rowCount() == len(m["processes"]), "清空搜索应恢复" + print(f" ✓ 清空搜索恢复全部 {panel.proc_table.rowCount()} 行") + + # 测试按内存排序 + panel.proc_sort_combo.setCurrentIndex(1) # 按 MEM 降序 + app.processEvents() + first = panel.proc_table.item(0, 3).text() # MEM% 在第 3 列 + last = panel.proc_table.item(panel.proc_table.rowCount() - 1, 3).text() + assert float(first) >= float(last), f"按 MEM 排序失败: {first} < {last}" + print(f" ✓ 按 MEM 排序: 第 1 行 {first}% > 最后行 {last}%") + + # 改回按 CPU 排序 + panel.proc_sort_combo.setCurrentIndex(0) + app.processEvents() + + print("[5/5] 杀进程(用 echo 自己的 PID 测试 kill 流程)") + # 启动一个 sleep 进程,立刻拿到 PID。disown + 全部 fd 重定向确保彻底脱离当前 shell。 + # 在子 shell 里后台启动 sleep,父 shell 立即退出后 sleep 由 init 接管 + print(" -> exec_command for sleep") + code, out, err = conn.exec_command( + "( sleep 60 /dev/null 2>&1 & echo $! )", + timeout=10, + ) + print(f" -> got: out={out!r} err={err!r}") + pid = None + for line in out.splitlines(): + line = line.strip() + if line.isdigit(): + pid = int(line) + break + print(f" -> pid={pid}") + if pid: + # 确认进程存在 + code, out, _ = conn.exec_command(f"kill -0 {pid} && echo alive", timeout=5) + assert "alive" in out, f"PID {pid} 不存在" + print(f" ✓ 启动测试进程 PID={pid}") + + # 通过 panel._proc_kill 直接调(模拟右键 → 杀进程 流程) + # 用 monkeypatch 把 QMessageBox.question/information/critical 替换为 no-op + 自动 Yes + from PyQt5.QtWidgets import QMessageBox + from ui import widgets as widgets_module + orig_question = widgets_module.QMessageBox.question + orig_info = widgets_module.QMessageBox.information + orig_crit = widgets_module.QMessageBox.critical + + def fake_question(*args, **kwargs): + return QMessageBox.Yes + + def fake_info(*args, **kwargs): + return QMessageBox.Ok + + def fake_crit(*args, **kwargs): + return QMessageBox.Ok + widgets_module.QMessageBox.question = fake_question + widgets_module.QMessageBox.information = fake_info + widgets_module.QMessageBox.critical = fake_crit + + try: + # 把这个 PID 加到 _proc_data,模拟它出现在列表里 + sample_proc = { + "pid": pid, "user": "root", "pcpu": 0.0, "pmem": 0.0, + "vsz": 0, "rss": 0, "stat": "S", "etime": 1, "time": 0, + "comm": "sleep 60", + } + panel._proc_data.insert(0, sample_proc) + panel._apply_proc_filter() + # 找到这一行 + found_row = None + for row in range(panel.proc_table.rowCount()): + if panel.proc_table.item(row, 0).data(Qt.UserRole) == pid: + found_row = row + break + assert found_row is not None, f"PID {pid} 未在表中" + print(f" ✓ 进程在表中位于第 {found_row} 行") + # 调用 _proc_kill(会弹消息框,monkeypatch 让其通过) + panel._proc_kill(pid, "sleep 60", force=False) + print(f" ✓ _proc_kill 调用返回") + finally: + widgets_module.QMessageBox.question = orig_question + widgets_module.QMessageBox.information = orig_info + widgets_module.QMessageBox.critical = orig_crit + + # 验证进程已死 + time.sleep(0.5) + code, out, _ = conn.exec_command(f"kill -0 {pid} 2>&1; echo exit=$?", timeout=5) + assert "exit=1" in out or "No such" in out or "exit=0" not in out, \ + f"PID {pid} 应该已死,但: {out!r}" + print(f" ✓ PID {pid} 已被杀 (远程: {out.strip()})") + else: + print(" ⚠ 无法获取测试 PID,跳过杀进程步骤") + + conn.disconnect() + print("\n进程监控端到端测试通过 ✓") + + +if __name__ == "__main__": + main() diff --git a/test_terminal.py b/test_terminal.py index 7315c4f..ea02058 100644 --- a/test_terminal.py +++ b/test_terminal.py @@ -22,7 +22,7 @@ from ui.terminal_panel import TerminalPanel def find_local_ssh(): - for pwd in ("testpass", ""): + for pwd in ("sshclient_test_pwd_2026", "testpass", ""): c = SSHConnection("127.0.0.1", 22, "root", pwd, timeout=5) ok, _ = c.connect() if ok: diff --git a/ui/widgets.py b/ui/widgets.py index 4f086a6..cc37c4b 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -10,15 +10,15 @@ from pathlib import Path from datetime import datetime from typing import Optional, List, Dict -from PyQt5.QtCore import Qt, pyqtSignal, QSize -from PyQt5.QtGui import QFont, QColor, QIcon, QPixmap, QPainter, QBrush +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, + QListWidget, QListWidgetItem, QTabWidget, QMenu, QShortcut, ) from core.ssh_client import SSHConnection @@ -394,25 +394,45 @@ class MonitorPanel(QWidget): 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")) - layout.addLayout(grid) + uv.addLayout(grid) # 负载 + 启动时间 grid2 = QHBoxLayout() grid2.addWidget(self._build_card("系统负载", "load_card")) grid2.addWidget(self._build_card("启动时间", "uptime_card")) - layout.addLayout(grid2) + uv.addLayout(grid2) - # 磁盘 + 网络 - grid3 = QHBoxLayout() + # 进程统计 + 磁盘 + 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("网络", ["网卡", "↓ 接收", "↑ 发送", "速率"]) - grid3.addWidget(self.disk_group) - grid3.addWidget(self.net_group) - layout.addLayout(grid3, 1) + uv.addWidget(self.net_group) + + splitter.addWidget(upper) + + # ---- 下半:进程表 ---- + self.proc_group = self._build_proc_table() + splitter.addWidget(self.proc_group) + splitter.setSizes([400, 350]) def _build_card(self, title: str, name: str) -> QGroupBox: box = QGroupBox(title) @@ -440,6 +460,99 @@ class MonitorPanel(QWidget): 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("进程列表(前 200,按 CPU 降序,双击/右键杀进程)") + v = QVBoxLayout(box) + + # 工具栏 + toolbar = QHBoxLayout() + self.proc_search = QLineEdit() + self.proc_search.setPlaceholderText("🔍 过滤(PID/用户/命令行,含空格时拆分多关键字)...") + self.proc_search.textChanged.connect(self._apply_proc_filter) + toolbar.addWidget(self.proc_search, 1) + self.proc_sort_combo = QComboBox() + self.proc_sort_combo.addItems([ + "按 CPU 降序", "按内存降序", "按 PID 升序", "按启动时间降序", "按用户", + ]) + self.proc_sort_combo.currentIndexChanged.connect(self._apply_proc_filter) + toolbar.addWidget(QLabel("排序:")) + toolbar.addWidget(self.proc_sort_combo) + v.addLayout(toolbar) + + # 表格 + headers = ["PID", "用户", "CPU%", "MEM%", "RSS", "STAT", "启动", "CPU时间", "命令"] + self.proc_table = QTableWidget(0, len(headers)) + self.proc_table.setHorizontalHeaderLabels(headers) + self.proc_table.verticalHeader().setVisible(False) + self.proc_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.proc_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.proc_table.setAlternatingRowColors(True) + # 列宽策略 + h = self.proc_table.horizontalHeader() + h.setSectionResizeMode(0, QHeaderView.ResizeToContents) # PID + h.setSectionResizeMode(1, QHeaderView.ResizeToContents) # USER + h.setSectionResizeMode(2, QHeaderView.ResizeToContents) # CPU% + h.setSectionResizeMode(3, QHeaderView.ResizeToContents) # MEM% + h.setSectionResizeMode(4, QHeaderView.ResizeToContents) # RSS + h.setSectionResizeMode(5, QHeaderView.ResizeToContents) # STAT + h.setSectionResizeMode(6, QHeaderView.ResizeToContents) # ETIME + h.setSectionResizeMode(7, QHeaderView.ResizeToContents) # TIME + h.setSectionResizeMode(8, QHeaderView.Stretch) # COMMAND + # 右键菜单 + self.proc_table.setContextMenuPolicy(Qt.CustomContextMenu) + self.proc_table.customContextMenuRequested.connect(self._proc_context_menu) + # 双击行 -> 杀进程 + self.proc_table.doubleClicked.connect(self._proc_kill_selected) + v.addWidget(self.proc_table, 1) + + # 提示 + hint = QLabel("提示: 右键/双击进程可执行 kill / kill -9。危险操作,请确认 PID。") + hint.setStyleSheet("color: #888; font-size: 9pt;") + v.addWidget(hint) + + # 当前缓存的进程数据(worker 回调里更新) + self._proc_data: List[dict] = [] + return box + def _value_label(self, name: str) -> QLabel: return self.findChild(QLabel, f"{name}_value") @@ -528,6 +641,16 @@ class MonitorPanel(QWidget): 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) @@ -576,6 +699,182 @@ class MonitorPanel(QWidget): else: net_table.setItem(i, 3, QTableWidgetItem("采样中...")) + # ============================================================ + # 进程表:过滤 / 排序 / 杀进程 + # ============================================================ + def _apply_proc_filter(self): + """根据搜索框 + 排序下拉,过滤并刷新进程表""" + if not hasattr(self, "proc_table"): + return + 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 + + data = list(self._proc_data) + # 过滤 + if terms: + def match(p): + hay = f"{p.get('pid','')} {p.get('user','')} {p.get('stat','')} {p.get('comm','')}".lower() + return all(t in hay for t in terms) + 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: # 启动时间降序(etime 大 = 新) + data.sort(key=lambda p: p.get("etime", 0), reverse=True) + elif sort_idx == 4: # 按用户,再 CPU + data.sort(key=lambda p: (p.get("user", ""), -p.get("pcpu", 0))) + + self.proc_table.setRowCount(len(data)) + for row, p in enumerate(data): + pid = p.get("pid", 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", "") + etime = p.get("etime", 0) + ptime = p.get("time", 0) + comm = p.get("comm", "") + # PID 列把 pid 存到 UserRole,方便右键菜单拿到 + pid_item = QTableWidgetItem(str(pid)) + pid_item.setData(Qt.UserRole, pid) + pid_item.setData(Qt.UserRole + 1, comm) + pid_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.proc_table.setItem(row, 0, pid_item) + self.proc_table.setItem(row, 1, QTableWidgetItem(user)) + cpu_item = QTableWidgetItem(f"{pcpu:.1f}") + cpu_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + # 高的 CPU/MEM 标色 + if pcpu >= 50: + cpu_item.setForeground(QColor("#ef5350")) + elif pcpu >= 20: + cpu_item.setForeground(QColor("#ffa726")) + self.proc_table.setItem(row, 2, cpu_item) + mem_item = QTableWidgetItem(f"{pmem:.1f}") + mem_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + if pmem >= 10: + mem_item.setForeground(QColor("#ef5350")) + elif pmem >= 5: + mem_item.setForeground(QColor("#ffa726")) + self.proc_table.setItem(row, 3, mem_item) + rss_item = QTableWidgetItem(SystemMonitor.format_bytes(rss * 1024)) + rss_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.proc_table.setItem(row, 4, rss_item) + # STAT 含 Z 用红色 + stat_item = QTableWidgetItem(stat) + if stat.startswith("Z"): + stat_item.setForeground(QColor("#ef5350")) + self.proc_table.setItem(row, 5, stat_item) + self.proc_table.setItem(row, 6, QTableWidgetItem(self._fmt_duration(etime))) + self.proc_table.setItem(row, 7, QTableWidgetItem(self._fmt_duration(ptime))) + comm_item = QTableWidgetItem(comm) + comm_item.setToolTip(comm) + self.proc_table.setItem(row, 8, comm_item) + # 更新标题栏显示当前过滤后条数 + total = len(self._proc_data) + shown = len(data) + title = "进程列表" + if terms or sort_idx != 0: + title += f" (显示 {shown}/{total})" + else: + title += f" (前 {total})" + self.proc_group.setTitle(title) + + @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() + pid_item = self.proc_table.item(row, 0) + if not pid_item: + return + pid = pid_item.data(Qt.UserRole) + comm = pid_item.data(Qt.UserRole + 1) or "" + menu = QMenu(self.proc_table) + a_soft = menu.addAction(f"🔪 kill {pid} (SIGTERM)") + a_hard = menu.addAction(f"☠ kill -9 {pid} (SIGKILL)") + menu.addSeparator() + a_copy = menu.addAction("复制 PID") + a_copy_comm = menu.addAction("复制命令行") + a_filter = 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_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) + + def _proc_kill_selected(self): + """双击行:杀进程(弹确认)""" + rows = self.proc_table.selectionModel().selectedRows() + if not rows: + return + row = rows[0].row() + pid_item = self.proc_table.item(row, 0) + 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_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 对话面板