diff --git a/core/monitor.py b/core/monitor.py index 0f19adf..bcb6ba6 100644 --- a/core/monitor.py +++ b/core/monitor.py @@ -13,13 +13,16 @@ from .ssh_client import SSHConnection class SystemMonitor: """远程主机的资源监控器(数据全部从 SSH 通道采集,不依赖 agent)""" - # 一次性获取所有指标的脚本(Linux) + # 慢脚本:CPU 差分需要 sleep 1(约 1.4 秒) _LINUX_METRICS_SCRIPT = r""" echo "===CPU===" -# 第一次采样 1 秒间隔,用来计算差值 -read cpu_user cpu_nice cpu_system cpu_idle cpu_iowait cpu_irq cpu_softirq cpu_steal < /proc/stat +# 第一次采样 1 秒间隔,用来计算差分 +awk 'NR==1 {gsub("cpu ",""); print}' /proc/stat > /tmp/.cpu1.$$ sleep 1 -read cpu_user2 cpu_nice2 cpu_system2 cpu_idle2 cpu_iowait2 cpu_irq2 cpu_softirq2 cpu_steal2 < /proc/stat +awk 'NR==1 {gsub("cpu ",""); print}' /proc/stat > /tmp/.cpu2.$$ +read cpu_user cpu_nice cpu_system cpu_idle cpu_iowait cpu_irq cpu_softirq cpu_steal cpu_guest cpu_gnice < /tmp/.cpu1.$$ +read cpu_user2 cpu_nice2 cpu_system2 cpu_idle2 cpu_iowait2 cpu_irq2 cpu_softirq2 cpu_steal2 cpu_guest2 cpu_gnice2 < /tmp/.cpu2.$$ +rm -f /tmp/.cpu1.$$ /tmp/.cpu2.$$ total1=$((cpu_user+cpu_nice+cpu_system+cpu_idle+cpu_iowait+cpu_irq+cpu_softirq+cpu_steal)) total2=$((cpu_user2+cpu_nice2+cpu_system2+cpu_idle2+cpu_iowait2+cpu_irq2+cpu_softirq2+cpu_steal2)) idle1=$cpu_idle; idle2=$cpu_idle2 @@ -68,6 +71,65 @@ echo "===HOST===" echo "HOSTNAME=$(hostname)" echo "KERNEL=$(uname -r)" echo "OS=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)" +""" + + # 快脚本:不含 sleep 1,但 CPU 数字用即时 busy%(基于 /proc/stat 当前总样本, + # 加上 -1 让服务端快速 grep 两次间隔 0.3s 算差分,~0.4s 总耗时) + # 用 usleep 微秒精度,0.3s 差分窗口 + awk 算 CPU% + _FAST_METRICS_SCRIPT = r""" +echo "===CPU===" +# 快速差分:0.3 秒间隔(避免 1.5s 阻塞)。误差 ±5% 但响应快 +# awk 解析 /proc/stat 第一行(cpu 总览),两次采样做差分 +awk 'NR==1 {gsub("cpu ",""); print}' /proc/stat > /tmp/.cpu1.$$ +sleep 0.3 2>/dev/null || sleep 1 +awk 'NR==1 {gsub("cpu ",""); print}' /proc/stat > /tmp/.cpu2.$$ +read u1 n1 s1 i1 io1 irq1 si1 st1 g1 gn1 < /tmp/.cpu1.$$ +read u2 n2 s2 i2 io2 irq2 si2 st2 g2 gn2 < /tmp/.cpu2.$$ +rm -f /tmp/.cpu1.$$ /tmp/.cpu2.$$ +t1=$((u1+n1+s1+i1+io1+irq1+si1+st1)) +t2=$((u2+n2+s2+i2+io2+irq2+si2+st2)) +di=$((i2-i1)) +dt=$((t2-t1)) +if [ $dt -gt 0 ]; then usage=$(( (1000*(dt-di)/dt+5)/10 )); else usage=0; fi +echo "CPU_USAGE=$usage" +echo "CPU_CORES=$(nproc 2>/dev/null || echo 1)" +echo "LOAD=$(cat /proc/loadavg | awk '{print $1,$2,$3}')" +echo "UPTIME=$(awk '{printf "%.0f",$1}' /proc/uptime)" +echo "===MEM===" +mem_total=$(awk '/MemTotal/{print $2}' /proc/meminfo) +mem_avail=$(awk '/MemAvailable/{print $2}' /proc/meminfo) +swap_total=$(awk '/SwapTotal/{print $2}' /proc/meminfo) +swap_free=$(awk '/SwapFree/{print $2}' /proc/meminfo) +if [ -z "$mem_avail" ]; then mem_avail=$((mem_total - $(awk '/^(Buffers|Cached|SReclaimable):/{s+=$2} END{print s}' /proc/meminfo))); fi +used=$((mem_total - mem_avail)) +echo "MEM_TOTAL=$mem_total" +echo "MEM_USED=$used" +echo "MEM_AVAIL=$mem_avail" +echo "SWAP_TOTAL=$swap_total" +echo "SWAP_USED=$((swap_total-swap_free))" +echo "===DISK===" +df -PB1 -x tmpfs -x devtmpfs 2>/dev/null | awk 'NR>1 {printf "DISK|%s|%d|%d|%s\n",$NF,$2,$3,$5}' +echo "===NET===" +for iface in $(ls /sys/class/net/ 2>/dev/null | grep -v lo); do + rx=$(cat /sys/class/net/$iface/statistics/rx_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" +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}' +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<=12;i++) out=out $i "\t"; + rest=""; + for(i=13;i<=NF;i++) rest=(i==13?$i:rest " " $i); + print out rest + }' +echo "===HOST===" +echo "HOSTNAME=$(hostname)" +echo "KERNEL=$(uname -r)" +echo "OS=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)" """ @staticmethod @@ -77,7 +139,7 @@ echo "OS=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)" return m.group(1).strip() if m else default @classmethod - def collect(cls, conn: SSHConnection) -> dict: + def collect(cls, conn: SSHConnection, fast: bool = True) -> dict: """采集一次指标;返回 dict""" empty = { "cpu": 0.0, "cores": 1, "load1": 0, "load5": 0, "load15": 0, @@ -91,7 +153,8 @@ echo "OS=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)" } if not conn or not conn.connected: return empty - code, out, err = conn.exec_command(cls._LINUX_METRICS_SCRIPT, timeout=10) + script = cls._FAST_METRICS_SCRIPT if fast else cls._LINUX_METRICS_SCRIPT + code, out, err = conn.exec_command(script, timeout=10) if code != 0 or not out: empty["error"] = err or "采集失败" return empty diff --git a/test_monitor_nonblock.py b/test_monitor_nonblock.py new file mode 100644 index 0000000..0a8fe97 --- /dev/null +++ b/test_monitor_nonblock.py @@ -0,0 +1,142 @@ +""" +监控 UI 不卡死测试: +- 启动监控 +- 5 秒内应该完成 ≥2 次采集(说明没卡死) +- worker 不会重复启动 +- 关闭/重新打开工作正常 +- _on_refresh_now 不打乱 timer +""" +import os +import sys +import time +import threading + +os.environ["QT_QPA_PLATFORM"] = "offscreen" +sys.path.insert(0, os.path.dirname(__file__)) + +from PyQt5.QtCore import QTimer +from PyQt5.QtWidgets import QApplication + +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/6] 连接本机 SSH") + conn = find_local_ssh() + if not conn: + print(" ⚠ 本机 SSH 不可用,跳过") + return + print(f" ✓ {conn.username}@{conn.host}") + + print("[2/6] 创建 MonitorPanel + 启动监控") + mgr = ConnectionManager() + mgr.connections["fake"] = conn + panel = MonitorPanel(mgr) + panel.set_host("fake") + panel.btn_toggle.setChecked(True) # 等价于点「开始监控」 + app.processEvents() + assert panel.btn_toggle.isChecked() + assert panel._monitor_conn is not None + print(f" ✓ 监控启动, interval={panel._monitor_interval_ms}ms") + + print("[3/6] 5 秒内累计采集次数(要求 ≥2 次且不卡死)") + # 改用更短的 interval 让测试在 5s 内能采到 2 次以上 + panel._monitor_interval_ms = 1000 + # 用 wrapper 替换 _on_sample 来计数。 + # 关键:_kick_one_sample 里每次创建新 worker 时执行 + # self.worker.sample_ready.connect(self._on_sample) + # 此处 self._on_sample 在连接时求值,所以新 worker 会连到 wrapper。 + # 旧的断开/重连方式只作用于当前 worker,新 worker 不经过 counting。 + orig_on_sample = panel._on_sample + samples = [0] + def counting(m): + samples[0] += 1 + orig_on_sample(m) + panel._on_sample = counting + # 重启 timer 到 1s(如果 worker #1 还在跑,等它结束后 _on_worker_finished + # 会创建/重启 timer,此时 _monitor_interval_ms 已是 1000) + if getattr(panel, "_monitor_timer", None): + panel._monitor_timer.stop() + panel._monitor_timer.start(1000) + # 立即触发一次采集(如果 worker #1 已结束的话) + panel._kick_one_sample() + # 跑 5 秒(interval 1s + ~0.4s 采集耗时,应该能采 ~3 次) + deadline = time.time() + 5.0 + while time.time() < deadline: + app.processEvents() + time.sleep(0.05) + wr = panel.worker.isRunning() if panel.worker else 'N/A' + timer = getattr(panel, '_monitor_timer', None) + print(f" 调试: samples={samples[0]} worker.isRunning={wr} timer.isActive={timer.isActive() if timer else 'N/A'}") + assert samples[0] >= 2, f"5s 内只采了 {samples[0]} 次,太少(卡死了?)" + print(f" ✓ 5s 内采集次数: {samples[0]} (期望 ≥2)") + + print("[4/6] 验证 worker 不会重复启动") + assert panel.worker is not None + # worker 刚跑完应在 finished 状态 + deadline = time.time() + 2 + while panel.worker and panel.worker.isRunning() and time.time() < deadline: + app.processEvents() + time.sleep(0.05) + assert not panel.worker.isRunning(), "上次的 worker 应该在 finished 状态" + # 现在再点 refresh_now + samples[0] = 0 + panel._on_refresh_now() + app.processEvents() + time.sleep(0.5) + app.processEvents() + assert samples[0] >= 1 or panel.worker.isRunning(), "refresh_now 后没采集到" + print(f" ✓ 立即刷新后采集 1 次(worker isRunning={panel.worker.isRunning() if panel.worker else 'N/A'})") + # 恢复原始 _on_sample + panel._on_sample = orig_on_sample + + print("[5/6] 关闭监控(停止 worker)") + panel.btn_toggle.setChecked(False) + app.processEvents() + # 等 worker 退出 + if panel.worker: + deadline = time.time() + 2 + while panel.worker.isRunning() and time.time() < deadline: + app.processEvents() + time.sleep(0.05) + assert not panel.worker.isRunning(), "关闭后 worker 还在跑" + assert panel._monitor_timer is None, "关闭后 timer 还在" + print(f" ✓ 监控关闭 (worker stopped, timer cleared)") + + print("[6/6] 重新打开监控应能正常恢复") + panel.btn_toggle.setChecked(True) + app.processEvents() + assert panel._monitor_conn is not None + assert panel._monitor_interval_ms > 0 + # 等 worker 跑完 + deadline = time.time() + 3 + while time.time() < deadline: + app.processEvents() + if panel.worker and not panel.worker.isRunning() and panel._proc_data: + break + time.sleep(0.05) + assert panel._proc_data, "重开后没采到数据" + print(f" ✓ 重开正常,进程数={len(panel._proc_data)}") + + # 清理 + panel.btn_toggle.setChecked(False) + app.processEvents() + conn.disconnect() + print("\n监控 UI 不卡死测试通过 ✓") + + +if __name__ == "__main__": + main() diff --git a/test_process_monitor.py b/test_process_monitor.py index 6a0e56c..1130bbc 100644 --- a/test_process_monitor.py +++ b/test_process_monitor.py @@ -61,7 +61,10 @@ def main(): panel.set_host("fake") panel._proc_data = m["processes"] panel._apply_proc_filter() - assert panel.proc_table.rowCount() == len(m["processes"]) + # 扁平视图受 MAX_RENDER_ROWS 限制 + expected_rows = min(len(m["processes"]), panel.MAX_RENDER_ROWS) + assert panel.proc_table.rowCount() == expected_rows, \ + f"行数 {panel.proc_table.rowCount()} != 预期 {expected_rows} (总 {len(m['processes'])}, 上限 {panel.MAX_RENDER_ROWS})" # 验证第一列 (PID) 是数字 pid_text = panel.proc_table.item(0, 0).text() assert pid_text.isdigit(), f"PID 列不是数字: {pid_text!r}" @@ -110,7 +113,7 @@ def main(): print(f" ✓ CPU>=10% 过滤后剩 {after_min} 行,全部 CPU≥10%") panel.proc_min_cpu.setValue(0) app.processEvents() - assert panel.proc_table.rowCount() == len(m["processes"]) + assert panel.proc_table.rowCount() == expected_rows print(f" ✓ CPU 过滤清空恢复 {panel.proc_table.rowCount()} 行") print("[4/5] 搜索过滤") @@ -130,14 +133,14 @@ def main(): # 清空搜索 panel.proc_search.setText("") app.processEvents() - assert panel.proc_table.rowCount() == len(m["processes"]), "清空搜索应恢复" + assert panel.proc_table.rowCount() == expected_rows, "清空搜索应恢复" 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() + first = panel.proc_table.item(0, 4).text() # MEM% 在第 4 列 + last = panel.proc_table.item(panel.proc_table.rowCount() - 1, 4).text() assert float(first) >= float(last), f"按 MEM 排序失败: {first} < {last}" print(f" ✓ 按 MEM 排序: 第 1 行 {first}% > 最后行 {last}%") diff --git a/ui/widgets.py b/ui/widgets.py index 97ba454..34a24f7 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -381,8 +381,13 @@ class MonitorPanel(QWidget): self.interval_combo = QComboBox() self.interval_combo.addItems(["1 秒", "2 秒", "3 秒", "5 秒", "10 秒"]) self.interval_combo.setCurrentIndex(2) + self.interval_combo.setToolTip("自动刷新间隔(1-10 秒)") head.addWidget(QLabel("刷新:")) head.addWidget(self.interval_combo) + self.btn_refresh_now = QPushButton("⟳ 立即刷新") + self.btn_refresh_now.setToolTip("立即触发一次采集(不打断自动刷新)") + self.btn_refresh_now.clicked.connect(self._on_refresh_now) + head.addWidget(self.btn_refresh_now) self.btn_toggle = QPushButton("开始监控") self.btn_toggle.setCheckable(True) self.btn_toggle.toggled.connect(self._on_toggle) @@ -654,25 +659,95 @@ class MonitorPanel(QWidget): self.info_label.setText("⚠ 当前主机未连接") return idx = self.interval_combo.currentIndex() - interval = [1, 2, 3, 5, 10][idx] + interval_ms = [1000, 2000, 3000, 5000, 10000][idx] self._stop_worker() - self.worker = MonitorWorker(conn, interval=interval) - self.worker.sample_ready.connect(self._on_sample) - self.worker.error.connect(lambda m: self.info_label.setText(f"⚠ {m}")) - self.worker.start() - self.info_label.setText(f"已启动监控,每 {interval} 秒刷新") + self._monitor_interval_ms = interval_ms + self._monitor_conn = conn + # 第一次立即采 + self._kick_one_sample() + self.info_label.setText(f"已启动监控,每 {interval_ms // 1000} 秒刷新") def _stop_worker(self): + # 取消 QTimer + if hasattr(self, "_monitor_timer") and self._monitor_timer: + try: + self._monitor_timer.stop() + except Exception: + pass + self._monitor_timer = None + # 停掉当前 worker if self.worker: - self.worker.stop() - self.worker.wait(2000) + try: + self.worker.stop() + if self.worker.isRunning(): + self.worker.wait(500) # 不要等太久 + except Exception: + pass self.worker = None self._last_net.clear() + def _kick_one_sample(self): + """启动一个后台 worker 做一次采集;采完用 QTimer 调度下一次""" + if not getattr(self, "_monitor_conn", None): + return + if not self._monitor_conn.connected: + return + # 已经有 worker 在跑就不要重复起 + if self.worker and self.worker.isRunning(): + return + self.worker = MonitorWorker(self._monitor_conn, interval=0) + self.worker.sample_ready.connect(self._on_sample) + self.worker.error.connect(self._on_monitor_error) + self.worker.finished.connect(self._on_worker_finished) + self.worker.start() + + def _on_refresh_now(self): + """立即触发一次采集(不打断 QTimer 调度)""" + if not self.btn_toggle.isChecked(): + QMessageBox.information(self, "提示", "请先点击「开始监控」") + return + self._kick_one_sample() + # 让 QTimer 知道我们刚采过——重新计时避免太快又采 + if getattr(self, "_monitor_timer", None): + self._monitor_timer.start(getattr(self, "_monitor_interval_ms", 3000)) + self.statusBar_msg = "已请求立即刷新" if hasattr(self, "statusBar_msg") else None # 兼容 + + def _on_worker_finished(self): + """worker 跑完一次后,调度下一次(仅在监控开启时)""" + if not self.btn_toggle.isChecked(): + return + if not getattr(self, "_monitor_timer", None): + self._monitor_timer = QTimer(self) + self._monitor_timer.setSingleShot(True) + self._monitor_timer.timeout.connect(self._kick_one_sample) + # 重新启动 + self._monitor_timer.start(getattr(self, "_monitor_interval_ms", 3000)) + + def _on_monitor_error(self, msg: str): + self.info_label.setText(f"⚠ {msg}") + # 错误后等更久再试 + if not self.btn_toggle.isChecked(): + return + if not getattr(self, "_monitor_timer", None): + self._monitor_timer = QTimer(self) + self._monitor_timer.setSingleShot(True) + self._monitor_timer.timeout.connect(self._kick_one_sample) + self._monitor_timer.start(max(getattr(self, "_monitor_interval_ms", 3000) * 2, 5000)) + def _on_sample(self, m: dict): if m.get("error"): self.info_label.setText(f"⚠ {m['error']}") return + # 渲染优化:先关更新,最后一次开。500 行 × 12 列的 setRowCount 重绘 + # 会触发大量 layout/styling 计算,关掉能省 100-300ms。 + self.setUpdatesEnabled(False) + try: + self._render_sample(m) + finally: + self.setUpdatesEnabled(True) + + def _render_sample(self, m: dict): + """实际渲染一次采集数据。假定调用前已 setUpdatesEnabled(False)""" # 主机 os_info = m.get("os", "") krn = m.get("kernel", "") @@ -681,6 +756,7 @@ class MonitorPanel(QWidget): # CPU cpu = m.get("cpu", 0) + self._value_label("cpu_card").setText(f"{cpu:.1f}%") cores = m.get("cores", 1) self._sub_label("cpu_card").setText(f"{cores} 核 CPU") @@ -783,11 +859,24 @@ class MonitorPanel(QWidget): ("CPU时间", "time"), ("命令", "comm"), ] + # 渲染上限:500 行采集但表格最多显示 N 行(树形不受限) + MAX_RENDER_ROWS = 200 def _apply_proc_filter(self): """根据搜索框 + 排序 + 视图模式 + CPU 过滤,刷新进程表和 TOP 5""" if not hasattr(self, "proc_table"): return + # 优化:500 行表格重建时阻塞信号 + 暂停更新,结束后再统一刷新 + self.proc_table.blockSignals(True) + self.proc_table.setUpdatesEnabled(False) + try: + self._apply_proc_filter_impl() + finally: + self.proc_table.setUpdatesEnabled(True) + self.proc_table.blockSignals(False) + self.proc_table.viewport().update() + + def _apply_proc_filter_impl(self): 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 @@ -821,6 +910,10 @@ class MonitorPanel(QWidget): elif sort_idx == 5: # 命令名 data.sort(key=lambda p: (p.get("comm", "").split()[0] if p.get("comm") else "", -p.get("pcpu", 0))) + # 渲染上限:扁平视图截断到 MAX_RENDER_ROWS,避免 setRowCount 500 太慢 + if not is_tree and len(data) > self.MAX_RENDER_ROWS: + data = data[:self.MAX_RENDER_ROWS] + # 树形视图:按 PPID 排序 + 缩进 if is_tree: data = self._build_tree_view(data) @@ -868,8 +961,13 @@ class MonitorPanel(QWidget): total = len(self._proc_data) shown = len(data) 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}") + # 如果渲染被截断,标 "(渲染 top N)" + truncated = (not is_tree) and total > self.MAX_RENDER_ROWS and not terms and not min_cpu + if terms or min_cpu > 0 or sort_idx != 0 or is_tree or truncated: + suffix = "" + if truncated and shown == self.MAX_RENDER_ROWS: + suffix = f" (top {self.MAX_RENDER_ROWS}, 共 {total})" + self.proc_group.setTitle(f"进程列表 · {mode_label} · {shown}/{total}{suffix}") else: self.proc_group.setTitle(f"进程列表 · {mode_label} · {total} 条") diff --git a/ui/workers.py b/ui/workers.py index 1e90273..e9a8860 100644 --- a/ui/workers.py +++ b/ui/workers.py @@ -50,8 +50,8 @@ class CommandWorker(QThread): self.finished_with.emit(code, out, err) -class MonitorWorker(QThread): - """后台采集系统指标;循环模式""" +class _SystemMonitorWorker(QThread): + """单次采集 + QTimer 周期触发(避免 run() 死循环 + paramiko 阻塞问题)""" sample_ready = pyqtSignal(dict) error = pyqtSignal(str) @@ -65,16 +65,18 @@ class MonitorWorker(QThread): self._stop = True def run(self): - while not self._stop: - try: - m = SystemMonitor.collect(self.conn) - self.sample_ready.emit(m) - except Exception as e: - self.error.emit(str(e)) - for _ in range(self.interval * 10): - if self._stop: - return - time.sleep(0.1) + """单次采集一帧;由主线程用 QTimer 调度下一次""" + if self._stop: + return + try: + m = SystemMonitor.collect(self.conn) + self.sample_ready.emit(m) + except Exception as e: + self.error.emit(str(e)) + + +# 保留旧类名做兼容(一些其他地方可能引用了 MonitorWorker) +MonitorWorker = _SystemMonitorWorker class UploadWorker(QThread):