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:
@@ -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
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
+309
-10
@@ -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 对话面板
|
||||
|
||||
Reference in New Issue
Block a user