feat: add process monitoring to Monitor panel

User asked for process monitoring. Added:

1. core/monitor.py:
- Extended _LINUX_METRICS_SCRIPT with ===PROC=== section
- ps -e -o stat= | awk counts total/running/sleep/disk-sleep/zombie
- ps -eo pid,user,pcpu,pmem,vsz,rss,stat,etimes,times,args
  Uses etimes/times (numeric) instead of start/time (string with
  spaces) to avoid column-shift bugs. awk joins 10th col onwards
  with spaces so comm retains its full command line.
- New empty-dict fields: proc_total, proc_running, proc_sleep,
  proc_disk, proc_zombie, processes
- New processes[] array with full per-process fields

2. ui/widgets.py MonitorPanel:
- New _build_proc_summary() - 4-stat overview card (total/running/
  sleep/zombie, color-coded)
- New _build_proc_table() - 9-column process list with:
    * Search box (multi-keyword AND match across pid/user/stat/comm)
    * Sort dropdown (CPU / MEM / PID / start time / user)
    * Color-coded CPU% (red >=50%, orange >=20%)
    * Color-coded MEM% (red >=10%, orange >=5%)
    * Color-coded STAT (red for zombie)
    * RSS formatted with format_bytes
    * ETIME / TIME formatted as 5d3h / 2h15m / 45s
- _apply_proc_filter() - filter + sort + render in one pass
- _proc_context_menu() - right-click menu:
    * kill (SIGTERM) | kill -9 (SIGKILL) | copy PID | copy cmd |
      filter by this command
- _proc_kill_selected() / _proc_kill() - sends kill over SSH
  with confirmation dialog, shows remote exit code in result
- Splitter: 4 metric cards + proc summary + disk + net on top,
  process list on bottom. User-draggable.

3. New test_process_monitor.py (5 tests, all pass):
- Real SSH connection, collects process data
- Renders MonitorPanel with 200 rows
- Tests search filter / sort change / clear
- Tests killing a real sleep process: starts sleep 60, finds it
  in the table, calls _proc_kill (with QMessageBox monkey-patched
  to auto-yes), verifies remote kill -0 returns 'No such process'
- Also fixed passwords in test_e2e.py + test_terminal.py to match
  the working local sshd credentials

All tests pass: core 6/6 + UI 3/3 + E2E 6/6 + terminal 5/5 + proc 5/5
Build: 57 MB single-file exe.
This commit is contained in:
Hermes
2026-07-28 22:25:33 +08:00
parent 1b7259c2d5
commit 5aa833ebee
5 changed files with 553 additions and 13 deletions
+57
View File
@@ -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) tx=$(cat /sys/class/net/$iface/statistics/tx_bytes 2>/dev/null || echo 0)
echo "NET|$iface|$rx|$tx" echo "NET|$iface|$rx|$tx"
done 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 "===HOST==="
echo "HOSTNAME=$(hostname)" echo "HOSTNAME=$(hostname)"
echo "KERNEL=$(uname -r)" 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, "mem_total": 0, "mem_used": 0, "mem_percent": 0.0,
"swap_total": 0, "swap_used": 0, "swap_total": 0, "swap_used": 0,
"disks": [], "net": [], "disks": [], "net": [],
"proc_total": 0, "proc_running": 0, "proc_sleep": 0,
"proc_disk": 0, "proc_zombie": 0, "processes": [],
"ts": time.time(), "ts": time.time(),
} }
if not conn or not conn.connected: 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: except ValueError:
continue 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 return result
@staticmethod @staticmethod
+2 -2
View File
@@ -23,7 +23,7 @@ def get_local_ssh_auth():
"""探测本机 SSH 登录方式:测试密码""" """探测本机 SSH 登录方式:测试密码"""
# 优先尝试 root 密码(如果测试环境设了) # 优先尝试 root 密码(如果测试环境设了)
candidates = [ 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": ""}, {"host": "127.0.0.1", "port": 22, "username": "root", "password": ""},
] ]
for c in candidates: for c in candidates:
@@ -170,7 +170,7 @@ def test_real_ssh_workflow():
conn3, msg3 = try_connect(auth) conn3, msg3 = try_connect(auth)
if not conn3: 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): def mock_tool(name, args):
if name == "exec_ssh_command": if name == "exec_ssh_command":
if conn3 and conn3.connected: if conn3 and conn3.connected:
+184
View File
@@ -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 >/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()
+1 -1
View File
@@ -22,7 +22,7 @@ from ui.terminal_panel import TerminalPanel
def find_local_ssh(): 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) c = SSHConnection("127.0.0.1", 22, "root", pwd, timeout=5)
ok, _ = c.connect() ok, _ = c.connect()
if ok: if ok:
+309 -10
View File
@@ -10,15 +10,15 @@ from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import Optional, List, Dict from typing import Optional, List, Dict
from PyQt5.QtCore import Qt, pyqtSignal, QSize from PyQt5.QtCore import Qt, pyqtSignal, QSize, QTimer
from PyQt5.QtGui import QFont, QColor, QIcon, QPixmap, QPainter, QBrush from PyQt5.QtGui import QFont, QColor, QIcon, QPixmap, QPainter, QBrush, QKeySequence
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit,
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
QFileDialog, QMessageBox, QProgressBar, QTreeWidget, QTreeWidgetItem, QFileDialog, QMessageBox, QProgressBar, QTreeWidget, QTreeWidgetItem,
QTextEdit, QSplitter, QFrame, QSizePolicy, QGroupBox, QFormLayout, QTextEdit, QSplitter, QFrame, QSizePolicy, QGroupBox, QFormLayout,
QComboBox, QToolButton, QStyle, QApplication, QInputDialog, QComboBox, QToolButton, QStyle, QApplication, QInputDialog,
QListWidget, QListWidgetItem, QTabWidget, QListWidget, QListWidgetItem, QTabWidget, QMenu, QShortcut,
) )
from core.ssh_client import SSHConnection from core.ssh_client import SSHConnection
@@ -394,25 +394,45 @@ class MonitorPanel(QWidget):
self.info_label.setStyleSheet("color: #666;") self.info_label.setStyleSheet("color: #666;")
layout.addWidget(self.info_label) 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 + 内存行 # CPU + 内存行
grid = QHBoxLayout() grid = QHBoxLayout()
grid.addWidget(self._build_card("CPU 使用率", "cpu_card")) grid.addWidget(self._build_card("CPU 使用率", "cpu_card"))
grid.addWidget(self._build_card("内存", "mem_card")) grid.addWidget(self._build_card("内存", "mem_card"))
layout.addLayout(grid) uv.addLayout(grid)
# 负载 + 启动时间 # 负载 + 启动时间
grid2 = QHBoxLayout() grid2 = QHBoxLayout()
grid2.addWidget(self._build_card("系统负载", "load_card")) grid2.addWidget(self._build_card("系统负载", "load_card"))
grid2.addWidget(self._build_card("启动时间", "uptime_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("磁盘", ["挂载点", "已用/总大小", "使用率", "进度"]) 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("网络", ["网卡", "↓ 接收", "↑ 发送", "速率"]) self.net_group = self._build_table_card("网络", ["网卡", "↓ 接收", "↑ 发送", "速率"])
grid3.addWidget(self.disk_group) uv.addWidget(self.net_group)
grid3.addWidget(self.net_group)
layout.addLayout(grid3, 1) 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: def _build_card(self, title: str, name: str) -> QGroupBox:
box = QGroupBox(title) box = QGroupBox(title)
@@ -440,6 +460,99 @@ class MonitorPanel(QWidget):
v.addWidget(table) v.addWidget(table)
return box 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: def _value_label(self, name: str) -> QLabel:
return self.findChild(QLabel, f"{name}_value") 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))}" 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", []) disks = m.get("disks", [])
disk_table: QTableWidget = self.disk_group.findChild(QTableWidget) disk_table: QTableWidget = self.disk_group.findChild(QTableWidget)
@@ -576,6 +699,182 @@ class MonitorPanel(QWidget):
else: else:
net_table.setItem(i, 3, QTableWidgetItem("采样中...")) 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 对话面板 # AI Agent 对话面板