From 5e69b1c3e2a213dc516fd106b44cc4b2a6e91053 Mon Sep 17 00:00:00 2001 From: Hermes Date: Tue, 28 Jul 2026 22:39:26 +0800 Subject: [PATCH] feat: overhaul process monitoring UI - more info, tree view, details, batch ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/monitor.py | 36 +-- test_process_monitor.py | 47 +++- ui/widgets.py | 549 +++++++++++++++++++++++++++++++++------- 3 files changed, 524 insertions(+), 108 deletions(-) diff --git a/core/monitor.py b/core/monitor.py index a64f4fe..0f19adf 100644 --- a/core/monitor.py +++ b/core/monitor.py @@ -54,13 +54,14 @@ 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 \ +# 字段:pid ppid user pcpu pmem vsz rss stat pri nice etimes times args +ps -eo pid,ppid,user,pcpu,pmem,vsz,rss,stat,pri,nice,etimes,times,args --sort=-pcpu --no-headers 2>/dev/null \ + | head -500 \ | awk '{ out="PROC\t"; - for(i=1;i<=9;i++) out=out $i "\t"; + for(i=1;i<=12;i++) out=out $i "\t"; rest=""; - for(i=10;i<=NF;i++) rest=(i==10?$i:rest " " $i); + for(i=13;i<=NF;i++) rest=(i==13?$i:rest " " $i); print out rest }' echo "===HOST===" @@ -167,29 +168,32 @@ echo "OS=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)" result["proc_disk"] = 0 result["proc_zombie"] = 0 - # 进程列表(远程用 \t 分隔前 9 列,第 10 列开始是 args/comm,可能含空格) + # 进程列表(远程用 \t 分隔前 12 列,第 13 列开始是 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: + fields = payload.split("\t", 12) # 只切前 12 次 + if len(fields) < 13: 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], + "ppid": int(fields[1]) if fields[1].isdigit() else 0, + "user": fields[2], + "pcpu": float(fields[3]), + "pmem": float(fields[4]), + "vsz": int(fields[5]), + "rss": int(fields[6]), + "stat": fields[7], + "pri": int(fields[8]) if fields[8].lstrip("-").isdigit() else 0, + "nice": int(fields[9]) if fields[9].lstrip("-").isdigit() else 0, # etimes: 自启动以来的秒数(整数) - "etime": int(fields[7]) if fields[7].isdigit() else 0, + "etime": int(fields[10]) if fields[10].isdigit() else 0, # times: 累计 CPU 时间秒数 - "time": int(fields[8]) if fields[8].isdigit() else 0, - "comm": fields[9].strip(), + "time": int(fields[11]) if fields[11].isdigit() else 0, + "comm": fields[12].strip(), }) except (ValueError, IndexError): continue diff --git a/test_process_monitor.py b/test_process_monitor.py index 4d56667..6a0e56c 100644 --- a/test_process_monitor.py +++ b/test_process_monitor.py @@ -65,8 +65,53 @@ def main(): # 验证第一列 (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} CPU={panel.proc_table.item(0, 2).text()}%") + 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] 搜索过滤") # 找一个常见的进程名 diff --git a/ui/widgets.py b/ui/widgets.py index cc37c4b..97ba454 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -18,7 +18,7 @@ from PyQt5.QtWidgets import ( QFileDialog, QMessageBox, QProgressBar, QTreeWidget, QTreeWidgetItem, QTextEdit, QSplitter, QFrame, QSizePolicy, QGroupBox, QFormLayout, QComboBox, QToolButton, QStyle, QApplication, QInputDialog, - QListWidget, QListWidgetItem, QTabWidget, QMenu, QShortcut, + QListWidget, QListWidgetItem, QTabWidget, QMenu, QShortcut, QSpinBox, ) from core.ssh_client import SSHConnection @@ -429,9 +429,14 @@ class MonitorPanel(QWidget): splitter.addWidget(upper) - # ---- 下半:进程表 ---- + # ---- 下半:进程区(左侧表格 + 右侧详情/TOP) ---- self.proc_group = self._build_proc_table() - splitter.addWidget(self.proc_group) + 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: @@ -499,60 +504,121 @@ class MonitorPanel(QWidget): return w def _build_proc_table(self) -> QGroupBox: - """进程列表(搜索 + 排序 + 右键杀进程)""" - box = QGroupBox("进程列表(前 200,按 CPU 降序,双击/右键杀进程)") + """进程列表:搜索 + 排序 + 树形切换 + 紧凑行 + 高亮 + 多选""" + 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/用户/命令行,含空格时拆分多关键字)...") + self.proc_search.setPlaceholderText("🔍 过滤 (PID/USER/命令) 空格分隔多个关键字 AND 匹配") self.proc_search.textChanged.connect(self._apply_proc_filter) - toolbar.addWidget(self.proc_search, 1) + self.proc_search.setClearButtonEnabled(True) + toolbar.addWidget(self.proc_search, 2) self.proc_sort_combo = QComboBox() self.proc_sort_combo.addItems([ - "按 CPU 降序", "按内存降序", "按 PID 升序", "按启动时间降序", "按用户", + "按 CPU 降序", "按内存降序", "按 PID 升序", + "按启动时间(新→旧)", "按用户", "按命令名", ]) self.proc_sort_combo.currentIndexChanged.connect(self._apply_proc_filter) - toolbar.addWidget(QLabel("排序:")) 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) # 表格 - headers = ["PID", "用户", "CPU%", "MEM%", "RSS", "STAT", "启动", "CPU时间", "命令"] - self.proc_table = QTableWidget(0, len(headers)) - self.proc_table.setHorizontalHeaderLabels(headers) + 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) - # 列宽策略 - 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.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) - # 提示 - hint = QLabel("提示: 右键/双击进程可执行 kill / kill -9。危险操作,请确认 PID。") - hint.setStyleSheet("color: #888; font-size: 9pt;") - v.addWidget(hint) + # 底部状态行 + self.proc_status_label = QLabel("提示: Ctrl+A 全选, Shift+点击多选, 右键批量操作") + self.proc_status_label.setStyleSheet("color: #888; font-size: 9pt;") + v.addWidget(self.proc_status_label) - # 当前缓存的进程数据(worker 回调里更新) + # 当前缓存的进程数据 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") @@ -700,90 +766,328 @@ class MonitorPanel(QWidget): 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"), + ] + def _apply_proc_filter(self): - """根据搜索框 + 排序下拉,过滤并刷新进程表""" + """根据搜索框 + 排序 + 视图模式 + CPU 过滤,刷新进程表和 TOP 5""" 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 + 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: + if terms or min_cpu > 0: 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) + 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 降序 + if sort_idx == 0: # CPU data.sort(key=lambda p: p.get("pcpu", 0), reverse=True) - elif sort_idx == 1: # MEM 降序 + elif sort_idx == 1: # MEM data.sort(key=lambda p: p.get("pmem", 0), reverse=True) - elif sort_idx == 2: # PID 升序 + elif sort_idx == 2: # PID data.sort(key=lambda p: p.get("pid", 0)) - elif sort_idx == 3: # 启动时间降序(etime 大 = 新) + elif sort_idx == 3: # 启动时间(新→旧) data.sort(key=lambda p: p.get("etime", 0), reverse=True) - elif sort_idx == 4: # 按用户,再 CPU + 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))) + + # 树形视图:按 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, 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) - # 更新标题栏显示当前过滤后条数 + 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) - title = "进程列表" - if terms or sort_idx != 0: - title += f" (显示 {shown}/{total})" + mode_label = "树形" if is_tree else "扁平" + if terms or min_cpu > 0 or sort_idx != 0 or is_tree: + self.proc_group.setTitle(f"进程列表 · {mode_label} · 显示 {shown}/{total}") else: - title += f" (前 {total})" - self.proc_group.setTitle(title) + 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: @@ -807,23 +1111,34 @@ class MonitorPanel(QWidget): if not idx.isValid(): return row = idx.row() - pid_item = self.proc_table.item(row, 0) + 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: @@ -833,6 +1148,8 @@ class MonitorPanel(QWidget): 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): """双击行:杀进程(弹确认)""" @@ -840,13 +1157,63 @@ class MonitorPanel(QWidget): if not rows: return row = rows[0].row() - pid_item = self.proc_table.item(row, 0) + 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: