Compare commits

..

5 Commits

Author SHA1 Message Date
Your Name 2b29a5cf48 feat: command snippets + UI test coverage for new features
- core/snippets.py: SnippetManager with 20 prebuilt sysadmin commands,
  CRUD + reorder, persisted to ~/.sshclient/snippets.json
- ui/snippet_dialog.py: SnippetDialog (add/edit) + SnippetManagerDialog
  (table view with add/edit/delete/move up/down)
- ui/terminal_panel.py: snippet combo box in toolbar, send_command()
  method to inject commands into interactive shell, snippet manager button
- README.md: updated feature table, usage section, project structure
- test_ui.py: added [2/4] checks for sparkline/snippets/theme,
  added [4/4] theme toggle verification
2026-07-28 23:03:37 +08:00
Your Name 57092cad03 feat: dark theme toggle + CPU/memory sparkline trend charts
- ui/theme.py: light/dark QSS stylesheets, persisted to ~/.sshclient/settings.json
- main_window.py: View menu with dark theme checkbox, applies on startup
- ui/widgets.py: SparklineChart widget for mini trend visualization
- MonitorPanel: CPU and memory sparkline charts showing last 60 samples
- Charts cleared on stop/switch host
2026-07-28 23:00:50 +08:00
Your Name 5d83348db3 fix: monitor worker non-blocking + test fixes
- core/monitor.py: add fast metrics script (0.3s vs 1.4s sleep),
  collect() now defaults to fast=True for responsive UI
- ui/workers.py: MonitorWorker changed from loop mode to single-shot
  (QTimer re-arms next cycle on finished signal, avoids paramiko
  blocking in infinite loop)
- ui/widgets.py: remove debug print from _kick_one_sample
- test_monitor_nonblock.py: fix sample counting by wrapping _on_sample
  instead of disconnecting signals (old approach missed new workers)
- test_process_monitor.py: assertions account for MAX_RENDER_ROWS=200,
  fix MEM% column index (4 not 3) in sort test
2026-07-28 22:57:15 +08:00
Hermes 5e69b1c3e2 feat: overhaul process monitoring UI - more info, tree view, details, batch ops
User feedback: '进程比较关键,以下显示太少了,需要继续优化下监控UI'

Process monitor panel was rebuilt:

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

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

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

All tests pass: core 6/6 + UI 3/3 + E2E 6/6 + terminal 5/5 + proc 5/5
Build: 57 MB single-file exe.
2026-07-28 22:39:26 +08:00
Hermes 5aa833ebee 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.
2026-07-28 22:25:33 +08:00
14 changed files with 1884 additions and 50 deletions
+17 -7
View File
@@ -9,10 +9,14 @@
| 模块 | 说明 | | 模块 | 说明 |
| --- | --- | | --- | --- |
| 多主机管理 | 增删改查、密码/私钥双认证、配置导入导出、配置持久化到 `~/.sshclient/` | | 多主机管理 | 增删改查、密码/私钥双认证、配置导入导出、配置持久化到 `~/.sshclient/` |
| 远程终端 | 命令执行 + 输出捕获 + 退出码 + 超时控制 + 常用命令快捷 | | 远程终端 | 真实交互式 shellXshell 风格)、ANSI 颜色、本地历史、Ctrl+A/E/U/K/W 快捷 |
| 命令片段 | 20+ 预置常用命令(ss/df/ps/docker/systemctl…),一键发送到终端,自定义增删改 |
| SFTP 文件浏览 | 目录树浏览、上传/下载(带进度条)、新建目录、删除、重命名、双击进入 | | SFTP 文件浏览 | 目录树浏览、上传/下载(带进度条)、新建目录、删除、重命名、双击进入 |
| 实时监控 | CPU/内存/负载/启动时间/磁盘/网络速率,1-10 秒可调刷新间隔 | | 实时监控 | CPU/内存/负载/启动时间/磁盘/网络速率/进程列表1-10 秒可调刷新间隔 |
| AI Agent | OpenAI 兼容 APIOpenAI / DeepSeek / Moonshot / 通义千问 / Ollama 等),5 个工具自动调用:执行命令、查指标、列文件、读文件、上传 | | 趋势迷你图 | CPU 和内存使用率实时折线图,最近 60 个采样点,一目了然 |
| 进程管理 | 进程表(500 条)、搜索过滤、CPU/内存排序、树形视图、右键杀进程、批量操作 |
| AI Agent | OpenAI 兼容 APIOpenAI / DeepSeek / Moonshot / 通义千问 / Ollama 等),5 个工具自动调用 |
| 暗色主题 | 视图菜单一键切换暗/亮主题,偏好自动持久化 |
| 跨平台 | 代码兼容 Windows / macOS / LinuxPyQt5 + paramiko | | 跨平台 | 代码兼容 Windows / macOS / LinuxPyQt5 + paramiko |
## 📦 在 Windows 上构建 exe ## 📦 在 Windows 上构建 exe
@@ -58,13 +62,15 @@ python test_e2e.py # 端到端测试(需本机或可访问的 sshd)
选中主机 → **🔌 连接**。状态指示器变绿后即可使用。 选中主机 → **🔌 连接**。状态指示器变绿后即可使用。
### 3. 终端 ### 3. 终端
**⌨ 终端** Tab 直接输入命令回车执行。常用命令(pwd / df / free / top / netstat)有快捷按钮 **⌨ 终端** Tab 直接输入命令回车执行。支持 ANSI 颜色、↑↓ 历史回放、Ctrl+A/E/U/K/W 行编辑快捷键
**命令片段**:工具栏下拉框选择预置命令(ss/df/ps/docker/systemctl 等),一键发送到终端。点击 ⚙ 管理自定义片段。
### 4. 文件浏览 ### 4. 文件浏览
**📁 文件** Tab 双击目录进入,双击文件直接下载。可拖入 / 上传任意文件。 **📁 文件** Tab 双击目录进入,双击文件直接下载。可拖入 / 上传任意文件。
### 5. 监控 ### 5. 监控
**📊 监控** Tab 点击 **开始监控**,指标会按设定间隔刷新。CPU/内存用大字突出,磁盘用进度条,网络显示当前速率。 **📊 监控** Tab 点击 **开始监控**,指标会按设定间隔刷新。CPU/内存用大字突出,下方有实时趋势折线图,磁盘用进度条,网络显示当前速率。下半区是进程管理表,支持搜索过滤、CPU/内存排序、树形视图、右键杀进程。
### 6. AI Agent ### 6. AI Agent
1. 先点 **⚙ AI 设置**,选择预设(OpenAI/DeepSeek/Kimi/通义千问/Ollama)或自定义填 API Key 1. 先点 **⚙ AI 设置**,选择预设(OpenAI/DeepSeek/Kimi/通义千问/Ollama)或自定义填 API Key
@@ -101,12 +107,16 @@ sshclient/
│ ├── ssh_client.py # SSH 连接 + SFTP │ ├── ssh_client.py # SSH 连接 + SFTP
│ ├── monitor.py # 远程系统监控 │ ├── monitor.py # 远程系统监控
│ ├── ai_agent.py # AI Agent (OpenAI 兼容) │ ├── ai_agent.py # AI Agent (OpenAI 兼容)
── manager.py # 多主机管理 + 配置持久化 ── manager.py # 多主机管理 + 配置持久化
│ └── snippets.py # 命令片段管理 + 持久化
├── ui/ # PyQt5 界面 ├── ui/ # PyQt5 界面
│ ├── main_window.py # 主窗口 │ ├── main_window.py # 主窗口
│ ├── widgets.py # FileBrowser / MonitorPanel / AIChatPanel │ ├── widgets.py # FileBrowser / MonitorPanel / SparklineChart / AIChatPanel
│ ├── terminal_panel.py # 交互式终端面板
│ ├── host_dialog.py # 主机编辑对话框 │ ├── host_dialog.py # 主机编辑对话框
│ ├── config_dialog.py # AI 设置对话框 │ ├── config_dialog.py # AI 设置对话框
│ ├── snippet_dialog.py # 命令片段管理对话框
│ ├── theme.py # 亮色/暗色主题 QSS
│ └── workers.py # 后台线程 │ └── workers.py # 后台线程
└── test_*.py # 测试脚本 └── test_*.py # 测试脚本
``` ```
+130 -6
View File
@@ -13,13 +13,16 @@ from .ssh_client import SSHConnection
class SystemMonitor: class SystemMonitor:
"""远程主机的资源监控器(数据全部从 SSH 通道采集,不依赖 agent)""" """远程主机的资源监控器(数据全部从 SSH 通道采集,不依赖 agent)"""
# 一次性获取所有指标的脚本(Linux # 慢脚本:CPU 差分需要 sleep 1(约 1.4 秒
_LINUX_METRICS_SCRIPT = r""" _LINUX_METRICS_SCRIPT = r"""
echo "===CPU===" echo "===CPU==="
# 第一次采样 1 秒间隔,用来计算差 # 第一次采样 1 秒间隔,用来计算差
read cpu_user cpu_nice cpu_system cpu_idle cpu_iowait cpu_irq cpu_softirq cpu_steal < /proc/stat awk 'NR==1 {gsub("cpu ",""); print}' /proc/stat > /tmp/.cpu1.$$
sleep 1 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)) 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)) total2=$((cpu_user2+cpu_nice2+cpu_system2+cpu_idle2+cpu_iowait2+cpu_irq2+cpu_softirq2+cpu_steal2))
idle1=$cpu_idle; idle2=$cpu_idle2 idle1=$cpu_idle; idle2=$cpu_idle2
@@ -49,6 +52,80 @@ 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 用换行做记录结束
# 字段: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<=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)"
"""
# 快脚本:不含 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 "===HOST==="
echo "HOSTNAME=$(hostname)" echo "HOSTNAME=$(hostname)"
echo "KERNEL=$(uname -r)" echo "KERNEL=$(uname -r)"
@@ -62,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 return m.group(1).strip() if m else default
@classmethod @classmethod
def collect(cls, conn: SSHConnection) -> dict: def collect(cls, conn: SSHConnection, fast: bool = True) -> dict:
"""采集一次指标;返回 dict""" """采集一次指标;返回 dict"""
empty = { empty = {
"cpu": 0.0, "cores": 1, "load1": 0, "load5": 0, "load15": 0, "cpu": 0.0, "cores": 1, "load1": 0, "load5": 0, "load15": 0,
@@ -70,11 +147,14 @@ 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:
return empty 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: if code != 0 or not out:
empty["error"] = err or "采集失败" empty["error"] = err or "采集失败"
return empty return empty
@@ -137,6 +217,50 @@ 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 分隔前 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", 12) # 只切前 12 次
if len(fields) < 13:
continue
try:
result["processes"].append({
"pid": int(fields[0]),
"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[10]) if fields[10].isdigit() else 0,
# times: 累计 CPU 时间秒数
"time": int(fields[11]) if fields[11].isdigit() else 0,
"comm": fields[12].strip(),
})
except (ValueError, IndexError):
continue
return result return result
@staticmethod @staticmethod
+86
View File
@@ -0,0 +1,86 @@
"""
命令片段管理:保存常用命令,快速发送到终端。
持久化到 ~/.sshclient/snippets.json
"""
import json
from pathlib import Path
from typing import List, Optional
CONFIG_DIR = Path.home() / ".sshclient"
SNIPPETS_FILE = CONFIG_DIR / "snippets.json"
# 预置片段(首次运行时自动添加)
DEFAULT_SNIPPETS = [
{"name": "系统信息", "cmd": "uname -a && cat /etc/os-release"},
{"name": "磁盘使用", "cmd": "df -h"},
{"name": "内存使用", "cmd": "free -h"},
{"name": "CPU 信息", "cmd": "lscpu | head -20"},
{"name": "监听端口", "cmd": "ss -tlnp"},
{"name": "所有连接", "cmd": "ss -tnp"},
{"name": "进程 TOP10", "cmd": "ps aux --sort=-%cpu | head -11"},
{"name": "内存 TOP10", "cmd": "ps aux --sort=-%mem | head -11"},
{"name": "最近登录", "cmd": "last -10"},
{"name": "系统日志", "cmd": "journalctl -n 50 --no-pager"},
{"name": "DNS 解析", "cmd": "dig +short"},
{"name": "网络路由", "cmd": "ip route show"},
{"name": "网卡信息", "cmd": "ip addr show"},
{"name": "防火墙状态", "cmd": "iptables -L -n --line-numbers"},
{"name": "Docker 容器", "cmd": "docker ps -a"},
{"name": "Docker 日志", "cmd": "docker logs --tail 50"},
{"name": "systemctl 状态", "cmd": "systemctl status"},
{"name": "重启服务", "cmd": "systemctl restart"},
{"name": "定时任务", "cmd": "crontab -l"},
{"name": "大文件 TOP10", "cmd": "find / -type f -size +100M 2>/dev/null | head -10"},
]
class SnippetManager:
"""命令片段的增删改查 + 持久化"""
def __init__(self):
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
self._snippets: List[dict] = []
self._load()
def _load(self):
if SNIPPETS_FILE.exists():
try:
with open(SNIPPETS_FILE, "r", encoding="utf-8") as f:
self._snippets = json.load(f)
except Exception:
self._snippets = []
if not self._snippets:
self._snippets = [dict(s) for s in DEFAULT_SNIPPETS]
self._save()
def _save(self):
with open(SNIPPETS_FILE, "w", encoding="utf-8") as f:
json.dump(self._snippets, f, ensure_ascii=False, indent=2)
def list_all(self) -> List[dict]:
return list(self._snippets)
def add(self, name: str, cmd: str) -> int:
"""添加片段,返回新索引"""
snippet = {"name": name.strip(), "cmd": cmd}
self._snippets.append(snippet)
self._save()
return len(self._snippets) - 1
def update(self, index: int, name: str, cmd: str):
if 0 <= index < len(self._snippets):
self._snippets[index] = {"name": name.strip(), "cmd": cmd}
self._save()
def remove(self, index: int):
if 0 <= index < len(self._snippets):
del self._snippets[index]
self._save()
def move(self, index: int, direction: int):
"""direction: -1=上移, +1=下移"""
new_idx = index + direction
if 0 <= new_idx < len(self._snippets):
self._snippets[index], self._snippets[new_idx] = \
self._snippets[new_idx], self._snippets[index]
self._save()
+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:
+142
View File
@@ -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()
+232
View File
@@ -0,0 +1,232 @@
"""
进程监控端到端测试:
- 真实 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()
# 扁平视图受 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}"
# 验证 PPID 列存在
ppid_text = panel.proc_table.item(0, 1).text()
assert ppid_text.lstrip("-").isdigit() or ppid_text == "-", f"PPID 错: {ppid_text!r}"
# 验证 PRI/NI 列
pri_text = panel.proc_table.item(0, 7).text()
assert pri_text.lstrip("-").isdigit(), f"PRI 错: {pri_text!r}"
print(f" ✓ 进程表行数: {panel.proc_table.rowCount()}")
print(f" ✓ 第 1 行 PID={pid_text} PPID={ppid_text} CPU={panel.proc_table.item(0, 3).text()}% PRI={pri_text}")
# TOP 5
assert panel.proc_top_cpu.count() == 5
assert panel.proc_top_mem.count() == 5
top_cpu_first = panel.proc_top_cpu.item(0).text()
top_mem_first = panel.proc_top_mem.item(0).text()
assert "%" in top_cpu_first and "%" in top_mem_first
print(f" ✓ TOP CPU: {top_cpu_first[:60]}")
print(f" ✓ TOP MEM: {top_mem_first[:60]}")
# 树形切换
panel.proc_view_combo.setCurrentIndex(1)
app.processEvents()
# 第一行应该有缩进符号(树根没有符号,但深度=0)
first_cell = panel.proc_table.item(0, 0).text()
assert "├─" in first_cell or first_cell == "", f"树形第 1 行异常: {first_cell!r}"
# 列数应该 13(树形多 1 列)
assert panel.proc_table.columnCount() == 13
print(f" ✓ 树形视图生效 (第 1 行: {first_cell!r}, 13 列)")
# 切回扁平
panel.proc_view_combo.setCurrentIndex(0)
app.processEvents()
assert panel.proc_table.columnCount() == 12
print(f" ✓ 切回扁平 (12 列)")
# CPU 过滤
panel.proc_min_cpu.setValue(10)
app.processEvents()
after_min = panel.proc_table.rowCount()
if after_min > 0:
# 验证所有行的 CPU 列都 >= 10
for r in range(min(after_min, 10)):
cpu_val = float(panel.proc_table.item(r, 3).text())
assert cpu_val >= 10, f"CPU 过滤失败: 行 {r} CPU={cpu_val}"
print(f" ✓ CPU>=10% 过滤后剩 {after_min} 行,全部 CPU≥10%")
panel.proc_min_cpu.setValue(0)
app.processEvents()
assert panel.proc_table.rowCount() == expected_rows
print(f" ✓ CPU 过滤清空恢复 {panel.proc_table.rowCount()}")
print("[4/5] 搜索过滤")
# 找一个常见的进程名
sample = m["processes"][0]
comm_first = sample["comm"].split()[0] if sample["comm"] else ""
if not comm_first:
# 退而求其次用 sshd
comm_first = "sshd"
panel.proc_search.setText(comm_first)
app.processEvents()
shown = panel.proc_table.rowCount()
assert shown > 0, f"'{comm_first}' 过滤后应至少 1 行"
assert shown < len(m["processes"]), f"过滤后应少于总数: {shown}/{len(m['processes'])}"
print(f" ✓ 搜索 '{comm_first}' 过滤后剩 {shown}/{len(m['processes'])}")
# 清空搜索
panel.proc_search.setText("")
app.processEvents()
assert panel.proc_table.rowCount() == expected_rows, "清空搜索应恢复"
print(f" ✓ 清空搜索恢复全部 {panel.proc_table.rowCount()}")
# 测试按内存排序
panel.proc_sort_combo.setCurrentIndex(1) # 按 MEM 降序
app.processEvents()
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}%")
# 改回按 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:
+25 -4
View File
@@ -33,13 +33,21 @@ def main():
assert w.tabs.count() == 4, f"应有 4 个 Tab,实际 {w.tabs.count()}" assert w.tabs.count() == 4, f"应有 4 个 Tab,实际 {w.tabs.count()}"
print(f" ✓ 主窗口创建,Tab 数量 = {w.tabs.count()}") print(f" ✓ 主窗口创建,Tab 数量 = {w.tabs.count()}")
print("[2/3] 验证子组件") print("[2/4] 验证子组件")
assert isinstance(w.file_browser, FileBrowser) assert isinstance(w.file_browser, FileBrowser)
assert isinstance(w.monitor, MonitorPanel) assert isinstance(w.monitor, MonitorPanel)
assert isinstance(w.ai_panel, AIChatPanel) assert isinstance(w.ai_panel, AIChatPanel)
# 验证新增功能
assert hasattr(w.monitor, "_spark_cpu"), "MonitorPanel 应有 CPU 迷你图"
assert hasattr(w.monitor, "_spark_mem"), "MonitorPanel 应有内存迷你图"
assert hasattr(w.terminal_panel, "snippet_combo"), "TerminalPanel 应有片段下拉框"
assert hasattr(w, "act_dark"), "MainWindow 应有暗色主题菜单项"
menus = [a.text() for a in w.menuBar().actions()]
assert "视图(&V)" in menus, f"应有视图菜单: {menus}"
print(f" ✓ FileBrowser / MonitorPanel / AIChatPanel 全部就位") print(f" ✓ FileBrowser / MonitorPanel / AIChatPanel 全部就位")
print(f" ✓ 暗色主题切换 / CPU·内存迷你图 / 命令片段下拉框")
print("[3/3] 验证对话框") print("[3/4] 验证对话框")
# 主机对话框 # 主机对话框
hd = HostDialog(current={"name": "test", "host": "1.2.3.4", "port": 22, hd = HostDialog(current={"name": "test", "host": "1.2.3.4", "port": 22,
"username": "u", "password": "p", "key_path": ""}) "username": "u", "password": "p", "key_path": ""})
@@ -56,10 +64,23 @@ def main():
cd.close() cd.close()
print(f" ✓ AIConfigDialog + {len(PRESETS)} 个预设") print(f" ✓ AIConfigDialog + {len(PRESETS)} 个预设")
# 不真正 show,只确保不崩 print("[4/4] 验证主题切换")
from ui.theme import get_theme, set_theme, get_qss
# 切换到暗色
w.act_dark.setChecked(True)
w._toggle_theme()
assert get_theme() == "dark"
qss = app.styleSheet()
assert len(qss) > 100, "QSS 应该有内容"
# 切回亮色
w.act_dark.setChecked(False)
w._toggle_theme()
assert get_theme() == "light"
print(f" ✓ 暗/亮主题切换正常 (dark QSS={len(get_qss('dark'))} chars)")
# 清理
w.close() w.close()
print("\n所有 UI 组件加载正常 ✓") print("\n所有 UI 组件加载正常 ✓")
# 不进入事件循环,强制退出
QTimer.singleShot(0, app.quit) QTimer.singleShot(0, app.quit)
app.exec_() app.exec_()
+20
View File
@@ -22,6 +22,7 @@ from .workers import ConnectWorker
from .widgets import FileBrowser, MonitorPanel, AIChatPanel from .widgets import FileBrowser, MonitorPanel, AIChatPanel
from .config_dialog import AIConfigDialog from .config_dialog import AIConfigDialog
from .terminal_panel import TerminalPanel from .terminal_panel import TerminalPanel
from .theme import get_qss, get_theme, set_theme
APP_NAME = "SSHClient" APP_NAME = "SSHClient"
@@ -43,6 +44,7 @@ class MainWindow(QMainWindow):
self._build_ui() self._build_ui()
self._build_menu() self._build_menu()
self._build_statusbar() self._build_statusbar()
self._apply_theme()
self._load_hosts_to_list() self._load_hosts_to_list()
# ============================================================ # ============================================================
@@ -144,6 +146,12 @@ class MainWindow(QMainWindow):
act_clear = QAction("清空 AI 对话", self) act_clear = QAction("清空 AI 对话", self)
act_clear.triggered.connect(lambda: self.ai_panel._clear()) act_clear.triggered.connect(lambda: self.ai_panel._clear())
m_ai.addAction(act_clear) m_ai.addAction(act_clear)
# 视图
m_view = menubar.addMenu("视图(&V)")
self.act_dark = QAction("🌙 暗色主题", self, checkable=True)
self.act_dark.setChecked(get_theme() == "dark")
self.act_dark.triggered.connect(self._toggle_theme)
m_view.addAction(self.act_dark)
# 帮助 # 帮助
m_help = menubar.addMenu("帮助(&H)") m_help = menubar.addMenu("帮助(&H)")
act_about = QAction("关于", self) act_about = QAction("关于", self)
@@ -301,6 +309,18 @@ class MainWindow(QMainWindow):
# ============================================================ # ============================================================
# AI / 关于 # AI / 关于
# ============================================================ # ============================================================
def _apply_theme(self):
theme = get_theme()
qss = get_qss(theme)
QApplication.instance().setStyleSheet(qss)
def _toggle_theme(self):
new_theme = "dark" if self.act_dark.isChecked() else "light"
set_theme(new_theme)
self._apply_theme()
label = "暗色" if new_theme == "dark" else "亮色"
self.statusBar().showMessage(f"已切换到{label}主题", 3000)
def _show_ai_config(self): def _show_ai_config(self):
dlg = AIConfigDialog(self.agent, self) dlg = AIConfigDialog(self.agent, self)
if dlg.exec_(): if dlg.exec_():
+151
View File
@@ -0,0 +1,151 @@
"""
命令片段管理对话框
"""
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
QPushButton, QLabel, QLineEdit, QTextEdit, QMessageBox, QHeaderView,
QAbstractItemView, QSplitter, QWidget, QFormLayout,
)
from core.snippets import SnippetManager
class SnippetDialog(QDialog):
"""单个片段的编辑/新增对话框"""
def __init__(self, parent=None, name: str = "", cmd: str = ""):
super().__init__(parent)
self.setWindowTitle("编辑片段" if name else "新增片段")
self.setMinimumWidth(460)
v = QVBoxLayout(self)
form = QFormLayout()
self.name_edit = QLineEdit(name)
self.name_edit.setPlaceholderText("如:查看端口占用")
form.addRow("名称:", self.name_edit)
self.cmd_edit = QTextEdit(cmd)
self.cmd_edit.setPlaceholderText("如:ss -tlnp | grep :80")
self.cmd_edit.setMaximumHeight(80)
form.addRow("命令:", self.cmd_edit)
v.addLayout(form)
btns = QHBoxLayout()
btns.addStretch(1)
ok = QPushButton("保存")
ok.clicked.connect(self._on_ok)
cancel = QPushButton("取消")
cancel.clicked.connect(self.reject)
btns.addWidget(ok)
btns.addWidget(cancel)
v.addLayout(btns)
def _on_ok(self):
if not self.name_edit.text().strip():
QMessageBox.warning(self, "提示", "请输入名称")
return
if not self.cmd_edit.toPlainText().strip():
QMessageBox.warning(self, "提示", "请输入命令")
return
self.accept()
def get_value(self) -> dict:
return {
"name": self.name_edit.text().strip(),
"cmd": self.cmd_edit.toPlainText().strip(),
}
class SnippetManagerDialog(QDialog):
"""片段管理主对话框:表格 + 增删改 + 上下移"""
def __init__(self, mgr: SnippetManager, parent=None):
super().__init__(parent)
self.mgr = mgr
self.setWindowTitle("命令片段管理")
self.setMinimumSize(520, 420)
self._build_ui()
self._refresh_table()
def _build_ui(self):
v = QVBoxLayout(self)
# 工具栏
toolbar = QHBoxLayout()
toolbar.addWidget(QLabel("📋 命令片段列表"))
toolbar.addStretch(1)
self.btn_add = QPushButton(" 新增")
self.btn_add.clicked.connect(self._add)
self.btn_edit = QPushButton("✏ 编辑")
self.btn_edit.clicked.connect(self._edit)
self.btn_del = QPushButton("🗑 删除")
self.btn_del.clicked.connect(self._delete)
self.btn_up = QPushButton("⬆ 上移")
self.btn_up.clicked.connect(lambda: self._move(-1))
self.btn_down = QPushButton("⬇ 下移")
self.btn_down.clicked.connect(lambda: self._move(1))
for b in (self.btn_add, self.btn_edit, self.btn_del, self.btn_up, self.btn_down):
toolbar.addWidget(b)
v.addLayout(toolbar)
# 表格
self.table = QTableWidget(0, 2)
self.table.setHorizontalHeaderLabels(["名称", "命令"])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.doubleClicked.connect(self._edit)
v.addWidget(self.table)
# 关闭按钮
btns = QHBoxLayout()
btns.addStretch(1)
close = QPushButton("关闭")
close.clicked.connect(self.accept)
btns.addWidget(close)
v.addLayout(btns)
def _refresh_table(self):
snippets = self.mgr.list_all()
self.table.setRowCount(len(snippets))
for i, s in enumerate(snippets):
self.table.setItem(i, 0, QTableWidgetItem(s["name"]))
cmd_display = s["cmd"][:60] + ("..." if len(s["cmd"]) > 60 else "")
self.table.setItem(i, 1, QTableWidgetItem(cmd_display))
self.table.item(i, 0).setToolTip(s["cmd"])
def _add(self):
dlg = SnippetDialog(self)
if dlg.exec_() == dlg.Accepted:
v = dlg.get_value()
self.mgr.add(v["name"], v["cmd"])
self._refresh_table()
def _edit(self):
row = self.table.currentRow()
if row < 0:
return
s = self.mgr.list_all()[row]
dlg = SnippetDialog(self, s["name"], s["cmd"])
if dlg.exec_() == dlg.Accepted:
v = dlg.get_value()
self.mgr.update(row, v["name"], v["cmd"])
self._refresh_table()
def _delete(self):
row = self.table.currentRow()
if row < 0:
return
s = self.mgr.list_all()[row]
if QMessageBox.question(
self, "确认删除", f"删除片段「{s['name']}」?",
QMessageBox.Yes | QMessageBox.No
) == QMessageBox.Yes:
self.mgr.remove(row)
self._refresh_table()
def _move(self, direction):
row = self.table.currentRow()
if row < 0:
return
self.mgr.move(row, direction)
new_row = row + direction
self._refresh_table()
if 0 <= new_row < self.table.rowCount():
self.table.selectRow(new_row)
+62
View File
@@ -21,6 +21,7 @@ from PyQt5.QtWidgets import (
) )
from core.ssh_client import SSHConnection from core.ssh_client import SSHConnection
from core.snippets import SnippetManager
# ANSI 颜色映射(16 色) # ANSI 颜色映射(16 色)
@@ -133,6 +134,8 @@ class TerminalPanel(QWidget):
self._ansi_invert = False self._ansi_invert = False
# OSC 0/2 设置的窗口标题 # OSC 0/2 设置的窗口标题
self._window_title = "" self._window_title = ""
# 命令片段
self.snippet_mgr = SnippetManager()
self._build_ui() self._build_ui()
self._apply_style() self._apply_style()
@@ -152,6 +155,20 @@ class TerminalPanel(QWidget):
self.status_label.setStyleSheet("color: #888;") self.status_label.setStyleSheet("color: #888;")
toolbar.addWidget(self.status_label) toolbar.addWidget(self.status_label)
toolbar.addStretch(1) toolbar.addStretch(1)
# 命令片段
toolbar.addWidget(QLabel("📋"))
self.snippet_combo = QComboBox()
self.snippet_combo.setMinimumWidth(180)
self.snippet_combo.setToolTip("选择命令片段快速发送到终端")
self.snippet_combo.currentIndexChanged.connect(self._on_snippet_selected)
toolbar.addWidget(self.snippet_combo)
self._refresh_snippets()
self.btn_snippet_mgr = QPushButton("")
self.btn_snippet_mgr.setFixedWidth(28)
self.btn_snippet_mgr.setToolTip("管理命令片段")
self.btn_snippet_mgr.clicked.connect(self._open_snippet_manager)
toolbar.addWidget(self.btn_snippet_mgr)
toolbar.addSpacing(8)
self.btn_clear = QPushButton("清屏") self.btn_clear = QPushButton("清屏")
self.btn_clear.clicked.connect(self._clear_screen) self.btn_clear.clicked.connect(self._clear_screen)
self.btn_reset = QPushButton("重连 shell") self.btn_reset = QPushButton("重连 shell")
@@ -190,6 +207,51 @@ class TerminalPanel(QWidget):
} }
""") """)
# ============================================================
# 命令片段
# ============================================================
def _refresh_snippets(self):
"""刷新下拉框"""
self.snippet_combo.blockSignals(True)
self.snippet_combo.clear()
self.snippet_combo.addItem("-- 选择命令片段 --", "")
for s in self.snippet_mgr.list_all():
self.snippet_combo.addItem(f"{s['name']} ({s['cmd'][:30]})", s["cmd"])
self.snippet_combo.blockSignals(False)
def _on_snippet_selected(self, index: int):
"""选择片段后发送命令到终端"""
if index <= 0:
return
cmd = self.snippet_combo.itemData(index)
if not cmd:
return
# 重置下拉框选中项(让用户能重复选同一个)
self.snippet_combo.blockSignals(True)
self.snippet_combo.setCurrentIndex(0)
self.snippet_combo.blockSignals(False)
# 发送命令
self.send_command(cmd)
def send_command(self, cmd: str):
"""发送一条命令到远程 shell(自动加换行)"""
if not self.chan or not self._connected:
self._set_status("未连接,无法发送命令", "#c62828")
return
data = cmd.encode("utf-8", errors="replace")
if not cmd.endswith("\n"):
data += b"\n"
try:
self.chan.send(data)
except Exception as e:
self._set_status(f"发送失败: {e}", "#c62828")
def _open_snippet_manager(self):
from .snippet_dialog import SnippetManagerDialog
dlg = SnippetManagerDialog(self.snippet_mgr, self)
dlg.exec_()
self._refresh_snippets()
# ============================================================ # ============================================================
# 生命周期 # 生命周期
# ============================================================ # ============================================================
+118
View File
@@ -0,0 +1,118 @@
"""
主题管理:亮色 / 暗色 QSS 样式表
持久化到 ~/.sshclient/settings.json
"""
import json
from pathlib import Path
CONFIG_DIR = Path.home() / ".sshclient"
SETTINGS_FILE = CONFIG_DIR / "settings.json"
DARK_QSS = """
QMainWindow, QWidget { background-color: #1e1e2e; color: #cdd6f4; }
QMenuBar { background-color: #181825; color: #cdd6f4; border-bottom: 1px solid #313244; }
QMenuBar::item:selected { background-color: #313244; }
QMenu { background-color: #1e1e2e; color: #cdd6f4; border: 1px solid #313244; }
QMenu::item:selected { background-color: #45475a; }
QTabWidget::pane { border: 1px solid #313244; background: #1e1e2e; }
QTabBar::tab { background: #181825; color: #a6adc8; padding: 6px 14px; border: 1px solid #313244; border-bottom: none; }
QTabBar::tab:selected { background: #1e1e2e; color: #89b4fa; border-bottom: 2px solid #89b4fa; }
QTabBar::tab:hover:!selected { background: #313244; }
QListWidget { background-color: #181825; color: #cdd6f4; border: 1px solid #313244; alternate-background-color: #1e1e2e; }
QListWidget::item:selected { background-color: #45475a; color: #89b4fa; }
QPushButton { background-color: #313244; color: #cdd6f4; border: 1px solid #45475a; padding: 5px 12px; border-radius: 3px; }
QPushButton:hover { background-color: #45475a; border-color: #89b4fa; }
QPushButton:pressed { background-color: #585b70; }
QPushButton:disabled { color: #585b70; background-color: #1e1e2e; }
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QSpinBox, QDoubleSpinBox {
background-color: #181825; color: #cdd6f4; border: 1px solid #313244; padding: 3px 6px; border-radius: 2px;
}
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus { border-color: #89b4fa; }
QComboBox::drop-down { border: none; }
QComboBox QAbstractItemView { background-color: #1e1e2e; color: #cdd6f4; selection-background-color: #45475a; }
QTableWidget { background-color: #181825; color: #cdd6f4; gridline-color: #313244; alternate-background-color: #1e1e2e; }
QTableWidget::item:selected { background-color: #45475a; }
QHeaderView::section { background-color: #313244; color: #cdd6f4; border: none; padding: 4px; border-right: 1px solid #45475a; }
QTreeWidget { background-color: #181825; color: #cdd6f4; border: 1px solid #313244; }
QTreeWidget::item:selected { background-color: #45475a; color: #89b4fa; }
QProgressBar { background-color: #181825; border: 1px solid #313244; border-radius: 3px; text-align: center; color: #cdd6f4; }
QProgressBar::chunk { background-color: #89b4fa; border-radius: 2px; }
QGroupBox { border: 1px solid #313244; border-radius: 4px; margin-top: 8px; padding-top: 8px; color: #89b4fa; font-weight: bold; }
QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; }
QLabel { color: #cdd6f4; }
QStatusBar { background-color: #181825; color: #a6adc8; }
QSplitter::handle { background-color: #313244; }
QSplitter::handle:horizontal { width: 2px; }
QSplitter::handle:vertical { height: 2px; }
QScrollBar:vertical { background: #181825; width: 10px; border: none; }
QScrollBar::handle:vertical { background: #45475a; min-height: 20px; border-radius: 4px; }
QScrollBar::handle:vertical:hover { background: #585b70; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar:horizontal { background: #181825; height: 10px; border: none; }
QScrollBar::handle:horizontal { background: #45475a; min-width: 20px; border-radius: 4px; }
QScrollBar::handle:horizontal:hover { background: #585b70; }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
QToolTip { background-color: #313244; color: #cdd6f4; border: 1px solid #45475a; padding: 4px; }
QCheckBox { color: #cdd6f4; }
QCheckBox::indicator { width: 14px; height: 14px; }
QCheckBox::indicator:unchecked { background: #181825; border: 1px solid #45475a; border-radius: 2px; }
QCheckBox::indicator:checked { background: #89b4fa; border: 1px solid #89b4fa; border-radius: 2px; }
QDialog { background-color: #1e1e2e; }
QFileDialog { background-color: #1e1e2e; }
QMessageBox { background-color: #1e1e2e; }
QMessageBox QLabel { color: #cdd6f4; }
QToolButton { background-color: #313244; color: #cdd6f4; border: 1px solid #45475a; padding: 3px 6px; border-radius: 3px; }
QToolButton:hover { background-color: #45475a; }
"""
LIGHT_QSS = """
QMainWindow, QWidget { background-color: #f5f5f5; color: #1a1a2e; }
QTabWidget::pane { border: 1px solid #ddd; background: #fff; }
QTabBar::tab { background: #e8e8e8; color: #555; padding: 6px 14px; border: 1px solid #ddd; border-bottom: none; }
QTabBar::tab:selected { background: #fff; color: #1976d2; border-bottom: 2px solid #1976d2; }
QListWidget { background-color: #fff; border: 1px solid #ddd; alternate-background-color: #f9f9f9; }
QListWidget::item:selected { background-color: #e3f2fd; color: #1976d2; }
QPushButton { background-color: #fff; color: #333; border: 1px solid #ccc; padding: 5px 12px; border-radius: 3px; }
QPushButton:hover { background-color: #f0f0f0; border-color: #1976d2; }
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QSpinBox { background-color: #fff; color: #333; border: 1px solid #ccc; padding: 3px 6px; border-radius: 2px; }
QLineEdit:focus, QTextEdit:focus { border-color: #1976d2; }
QTableWidget { background-color: #fff; gridline-color: #e0e0e0; alternate-background-color: #f9f9f9; }
QTableWidget::item:selected { background-color: #e3f2fd; }
QHeaderView::section { background-color: #f0f0f0; color: #555; border: none; padding: 4px; border-right: 1px solid #ddd; }
QGroupBox { border: 1px solid #ddd; border-radius: 4px; margin-top: 8px; padding-top: 8px; color: #1976d2; font-weight: bold; }
QProgressBar { background-color: #f0f0f0; border: 1px solid #ddd; border-radius: 3px; text-align: center; }
QProgressBar::chunk { background-color: #1976d2; border-radius: 2px; }
QStatusBar { background-color: #f0f0f0; color: #666; }
"""
def load_settings() -> dict:
if SETTINGS_FILE.exists():
try:
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {"theme": "light"}
def save_settings(settings: dict):
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(settings, f, ensure_ascii=False, indent=2)
def get_theme() -> str:
return load_settings().get("theme", "light")
def set_theme(theme: str):
s = load_settings()
s["theme"] = theme
save_settings(s)
def get_qss(theme: "str | None" = None) -> str:
if theme is None:
theme = get_theme()
return DARK_QSS if theme == "dark" else LIGHT_QSS
+883 -17
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, QSpinBox,
) )
from core.ssh_client import SSHConnection from core.ssh_client import SSHConnection
@@ -356,6 +356,93 @@ class FileBrowser(QWidget):
# ============================================================ # ============================================================
# 监控面板 # 监控面板
# ============================================================ # ============================================================
class SparklineChart(QWidget):
"""迷你趋势图:绘制 0-100 的折线图,显示最近 N 个采样点"""
def __init__(self, title: str = "", color: str = "#4caf50", max_points: int = 60, parent=None):
super().__init__(parent)
self._title = title
self._color = QColor(color)
self._max_points = max_points
self._data: list = []
self.setMinimumHeight(64)
self.setMinimumWidth(120)
def add_value(self, val: float):
self._data.append(max(0, min(100, val)))
if len(self._data) > self._max_points:
self._data = self._data[-self._max_points:]
self.update()
def clear(self):
self._data.clear()
self.update()
def paintEvent(self, _ev):
from PyQt5.QtGui import QPainter, QPen, QBrush, QPainterPath, QLinearGradient
from PyQt5.QtCore import QRectF, QPointF
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
# 背景
p.fillRect(0, 0, w, h, QColor("#ffffff08"))
# 标题
if self._title:
p.setPen(QPen(QColor("#888")))
p.setFont(QFont("sans-serif", 8))
p.drawText(4, 12, self._title)
if len(self._data) < 2:
p.setPen(QPen(QColor("#666")))
p.setFont(QFont("sans-serif", 8))
p.drawText(w // 2 - 20, h // 2, "等待数据...")
return
# 绘图区域(留出标题空间)
top = 16
bottom = h - 4
left = 4
right = w - 4
plot_w = right - left
plot_h = bottom - top
# 点坐标
n = len(self._data)
step = plot_w / max(n - 1, 1)
points = []
for i, v in enumerate(self._data):
x = left + i * step
y = bottom - (v / 100.0) * plot_h
points.append(QPointF(x, y))
# 填充区域
fill_path = QPainterPath()
fill_path.moveTo(points[0].x(), bottom)
for pt in points:
fill_path.lineTo(pt)
fill_path.lineTo(points[-1].x(), bottom)
fill_path.closeSubpath()
grad = QLinearGradient(0, top, 0, bottom)
c = self._color
grad.setColorAt(0, QColor(c.red(), c.green(), c.blue(), 80))
grad.setColorAt(1, QColor(c.red(), c.green(), c.blue(), 10))
p.fillPath(fill_path, QBrush(grad))
# 折线
p.setPen(QPen(self._color, 1.5))
line_path = QPainterPath()
line_path.moveTo(points[0])
for pt in points[1:]:
line_path.lineTo(pt)
p.drawPath(line_path)
# 当前值标签
cur = self._data[-1]
p.setPen(QPen(self._color))
p.setFont(QFont("sans-serif", 9, QFont.Bold))
label = f"{cur:.1f}%"
p.drawText(right - 40, 12, label)
# 当前点圆点
p.setBrush(QBrush(self._color))
p.setPen(QPen(self._color, 0))
p.drawEllipse(points[-1], 3, 3)
class MonitorPanel(QWidget): class MonitorPanel(QWidget):
"""实时监控:CPU、内存、磁盘、网络""" """实时监控:CPU、内存、磁盘、网络"""
@@ -381,8 +468,13 @@ class MonitorPanel(QWidget):
self.interval_combo = QComboBox() self.interval_combo = QComboBox()
self.interval_combo.addItems(["1 秒", "2 秒", "3 秒", "5 秒", "10 秒"]) self.interval_combo.addItems(["1 秒", "2 秒", "3 秒", "5 秒", "10 秒"])
self.interval_combo.setCurrentIndex(2) self.interval_combo.setCurrentIndex(2)
self.interval_combo.setToolTip("自动刷新间隔(1-10 秒)")
head.addWidget(QLabel("刷新:")) head.addWidget(QLabel("刷新:"))
head.addWidget(self.interval_combo) 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 = QPushButton("开始监控")
self.btn_toggle.setCheckable(True) self.btn_toggle.setCheckable(True)
self.btn_toggle.toggled.connect(self._on_toggle) self.btn_toggle.toggled.connect(self._on_toggle)
@@ -394,25 +486,58 @@ 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)
# CPU / 内存趋势迷你图
spark_row = QHBoxLayout()
self._spark_cpu = SparklineChart("CPU 趋势", "#4caf50")
self._spark_mem = SparklineChart("内存趋势", "#7986cb")
spark_row.addWidget(self._spark_cpu)
spark_row.addWidget(self._spark_mem)
uv.addLayout(spark_row)
# 负载 + 启动时间 # 负载 + 启动时间
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)
# ---- 下半:进程区(左侧表格 + 右侧详情/TOP) ----
self.proc_group = self._build_proc_table()
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: def _build_card(self, title: str, name: str) -> QGroupBox:
box = QGroupBox(title) box = QGroupBox(title)
@@ -440,6 +565,160 @@ 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("进程列表(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/USER/命令) 空格分隔多个关键字 AND 匹配")
self.proc_search.textChanged.connect(self._apply_proc_filter)
self.proc_search.setClearButtonEnabled(True)
toolbar.addWidget(self.proc_search, 2)
self.proc_sort_combo = QComboBox()
self.proc_sort_combo.addItems([
"按 CPU 降序", "按内存降序", "按 PID 升序",
"按启动时间(新→旧)", "按用户", "按命令名",
])
self.proc_sort_combo.currentIndexChanged.connect(self._apply_proc_filter)
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)
# 表格
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)
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)
# 底部状态行
self.proc_status_label = QLabel("提示: Ctrl+A 全选, Shift+点击多选, 右键批量操作")
self.proc_status_label.setStyleSheet("color: #888; font-size: 9pt;")
v.addWidget(self.proc_status_label)
# 当前缓存的进程数据
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: def _value_label(self, name: str) -> QLabel:
return self.findChild(QLabel, f"{name}_value") return self.findChild(QLabel, f"{name}_value")
@@ -475,25 +754,100 @@ class MonitorPanel(QWidget):
self.info_label.setText("⚠ 当前主机未连接") self.info_label.setText("⚠ 当前主机未连接")
return return
idx = self.interval_combo.currentIndex() idx = self.interval_combo.currentIndex()
interval = [1, 2, 3, 5, 10][idx] interval_ms = [1000, 2000, 3000, 5000, 10000][idx]
self._stop_worker() self._stop_worker()
self.worker = MonitorWorker(conn, interval=interval) self._monitor_interval_ms = interval_ms
self.worker.sample_ready.connect(self._on_sample) self._monitor_conn = conn
self.worker.error.connect(lambda m: self.info_label.setText(f"{m}")) # 第一次立即采
self.worker.start() self._kick_one_sample()
self.info_label.setText(f"已启动监控,每 {interval} 秒刷新") self.info_label.setText(f"已启动监控,每 {interval_ms // 1000} 秒刷新")
def _stop_worker(self): 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: if self.worker:
try:
self.worker.stop() self.worker.stop()
self.worker.wait(2000) if self.worker.isRunning():
self.worker.wait(500) # 不要等太久
except Exception:
pass
self.worker = None self.worker = None
self._last_net.clear() self._last_net.clear()
# 清空趋势图
if hasattr(self, "_spark_cpu"):
self._spark_cpu.clear()
if hasattr(self, "_spark_mem"):
self._spark_mem.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): def _on_sample(self, m: dict):
if m.get("error"): if m.get("error"):
self.info_label.setText(f"{m['error']}") self.info_label.setText(f"{m['error']}")
return 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", "") os_info = m.get("os", "")
krn = m.get("kernel", "") krn = m.get("kernel", "")
@@ -502,12 +856,15 @@ class MonitorPanel(QWidget):
# CPU # CPU
cpu = m.get("cpu", 0) cpu = m.get("cpu", 0)
self._spark_cpu.add_value(cpu)
self._value_label("cpu_card").setText(f"{cpu:.1f}%") self._value_label("cpu_card").setText(f"{cpu:.1f}%")
cores = m.get("cores", 1) cores = m.get("cores", 1)
self._sub_label("cpu_card").setText(f"{cores} 核 CPU") self._sub_label("cpu_card").setText(f"{cores} 核 CPU")
# 内存 # 内存
mp = m.get("mem_percent", 0) mp = m.get("mem_percent", 0)
self._spark_mem.add_value(mp)
mu = SystemMonitor.format_bytes(m.get("mem_used", 0)) mu = SystemMonitor.format_bytes(m.get("mem_used", 0))
mt = SystemMonitor.format_bytes(m.get("mem_total", 0)) mt = SystemMonitor.format_bytes(m.get("mem_total", 0))
self._value_label("mem_card").setText(f"{mp:.1f}%") self._value_label("mem_card").setText(f"{mp:.1f}%")
@@ -528,6 +885,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 +943,505 @@ class MonitorPanel(QWidget):
else: else:
net_table.setItem(i, 3, QTableWidgetItem("采样中...")) 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"),
]
# 渲染上限: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
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 or min_cpu > 0:
def match(p):
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
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: # 启动时间(新→旧)
data.sort(key=lambda p: p.get("etime", 0), reverse=True)
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)))
# 渲染上限:扁平视图截断到 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)
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, 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)
mode_label = "树形" if is_tree else "扁平"
# 如果渲染被截断,标 "(渲染 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}")
# 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:
"""把秒数格式化成 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()
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:
QApplication.clipboard().setText(comm)
elif chosen == a_filter:
# 取 comm 的第一段(命令名)
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):
"""双击行:杀进程(弹确认)"""
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)
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:
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 对话面板
+9 -7
View File
@@ -50,8 +50,8 @@ class CommandWorker(QThread):
self.finished_with.emit(code, out, err) self.finished_with.emit(code, out, err)
class MonitorWorker(QThread): class _SystemMonitorWorker(QThread):
"""后台采集系统指标;循环模式""" """单次采集 + QTimer 周期触发(避免 run() 死循环 + paramiko 阻塞问题)"""
sample_ready = pyqtSignal(dict) sample_ready = pyqtSignal(dict)
error = pyqtSignal(str) error = pyqtSignal(str)
@@ -65,16 +65,18 @@ class MonitorWorker(QThread):
self._stop = True self._stop = True
def run(self): def run(self):
while not self._stop: """单次采集一帧;由主线程用 QTimer 调度下一次"""
if self._stop:
return
try: try:
m = SystemMonitor.collect(self.conn) m = SystemMonitor.collect(self.conn)
self.sample_ready.emit(m) self.sample_ready.emit(m)
except Exception as e: except Exception as e:
self.error.emit(str(e)) self.error.emit(str(e))
for _ in range(self.interval * 10):
if self._stop:
return # 保留旧类名做兼容(一些其他地方可能引用了 MonitorWorker
time.sleep(0.1) MonitorWorker = _SystemMonitorWorker
class UploadWorker(QThread): class UploadWorker(QThread):