Files
sshclient/test_process_monitor.py
T
Hermes 5e69b1c3e2 feat: overhaul process monitoring UI - more info, tree view, details, batch ops
User feedback: '进程比较关键,以下显示太少了,需要继续优化下监控UI'

Process monitor panel was rebuilt:

1. core/monitor.py: 200 -> 500 processes, added PPID / PRI / NICE
   New ps fields: pid ppid user pcpu pmem vsz rss stat pri nice etimes times args
   Python parser: new fields ppid, pri, nice

2. ui/widgets.py MonitorPanel:
   a) Toolbar: search box + sort dropdown (CPU/MEM/PID/start/user/comm)
      + view toggle (Flat / Tree) + CPU% min filter (SpinBox 0-100%)
      + batch kill button
   b) Table: 12 columns (Flat) / 13 cols (Tree + indent col)
      PID PPID USER CPU% MEM% RSS STAT PRI NI '启动' 'CPU时间' 命令
      - Compact row height 20px (was default 30)
      - Extended selection (Ctrl/Shift multi)
      - Color highlights:
          CPU% >=80 red bg+fg bold, >=50 red fg bold, >=20 orange
          MEM% >=15 orange bg+fg bold, >=5 orange
          STAT: Z orange bg + orange-red fg bold, R green, D purple
          NI: negative green, positive orange
      - Red bg on EXTREME rows for instant visibility
   c) Tree view: builds PPID tree with ├─/└─/│ connectors;
      root processes (PID 1) at depth 0, sorted by CPU within siblings.
      Useful for 'which process is spawning these things?'
   d) Right side panel:
      - 进程详情 (QTextEdit dark theme): full PID/PPID/USER/STAT/PRI/NI/
        CPU/MEM/RSS/VSZ/etime/cputime, command, parent process name,
        child processes list (top 10)
      - 🔥 CPU TOP 5 (QListWidget dark): click to jump+select in main table
      - 💾 内存 TOP 5 (QListWidget dark): same
   e) Right-click menu enhanced:
      kill (SIGTERM) | kill -9 (SIGKILL) | batch kill (if multi-select)
      | copy PID | copy command | filter by command | view details
   f) Batch kill: collects all selected PIDs, runs 'kill p1 p2 p3 ...'
      in a single SSH call, then loops 'kill -0' to report survivors.
      One confirmation dialog lists top 10 + '还有 N 个'.
   g) Status row at bottom: 'Ctrl+A 全选, Shift+点击多选, 右键批量操作'

3. test_process_monitor.py: 5/5 tests pass with 500 rows, tree view,
   TOP 5, CPU min filter, etc.

All tests pass: core 6/6 + UI 3/3 + E2E 6/6 + terminal 5/5 + proc 5/5
Build: 57 MB single-file exe.
2026-07-28 22:39:26 +08:00

230 lines
9.2 KiB
Python

"""
进程监控端到端测试:
- 真实 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}"
# 验证 PPID 列存在
ppid_text = panel.proc_table.item(0, 1).text()
assert ppid_text.lstrip("-").isdigit() or ppid_text == "-", f"PPID 错: {ppid_text!r}"
# 验证 PRI/NI 列
pri_text = panel.proc_table.item(0, 7).text()
assert pri_text.lstrip("-").isdigit(), f"PRI 错: {pri_text!r}"
print(f" ✓ 进程表行数: {panel.proc_table.rowCount()}")
print(f" ✓ 第 1 行 PID={pid_text} PPID={ppid_text} CPU={panel.proc_table.item(0, 3).text()}% PRI={pri_text}")
# TOP 5
assert panel.proc_top_cpu.count() == 5
assert panel.proc_top_mem.count() == 5
top_cpu_first = panel.proc_top_cpu.item(0).text()
top_mem_first = panel.proc_top_mem.item(0).text()
assert "%" in top_cpu_first and "%" in top_mem_first
print(f" ✓ TOP CPU: {top_cpu_first[:60]}")
print(f" ✓ TOP MEM: {top_mem_first[:60]}")
# 树形切换
panel.proc_view_combo.setCurrentIndex(1)
app.processEvents()
# 第一行应该有缩进符号(树根没有符号,但深度=0)
first_cell = panel.proc_table.item(0, 0).text()
assert "├─" in first_cell or first_cell == "", f"树形第 1 行异常: {first_cell!r}"
# 列数应该 13(树形多 1 列)
assert panel.proc_table.columnCount() == 13
print(f" ✓ 树形视图生效 (第 1 行: {first_cell!r}, 13 列)")
# 切回扁平
panel.proc_view_combo.setCurrentIndex(0)
app.processEvents()
assert panel.proc_table.columnCount() == 12
print(f" ✓ 切回扁平 (12 列)")
# CPU 过滤
panel.proc_min_cpu.setValue(10)
app.processEvents()
after_min = panel.proc_table.rowCount()
if after_min > 0:
# 验证所有行的 CPU 列都 >= 10
for r in range(min(after_min, 10)):
cpu_val = float(panel.proc_table.item(r, 3).text())
assert cpu_val >= 10, f"CPU 过滤失败: 行 {r} CPU={cpu_val}"
print(f" ✓ CPU>=10% 过滤后剩 {after_min} 行,全部 CPU≥10%")
panel.proc_min_cpu.setValue(0)
app.processEvents()
assert panel.proc_table.rowCount() == len(m["processes"])
print(f" ✓ CPU 过滤清空恢复 {panel.proc_table.rowCount()}")
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 >/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()