Compare commits

...

3 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
12 changed files with 922 additions and 44 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 # 测试脚本
``` ```
+69 -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
@@ -68,6 +71,65 @@ echo "===HOST==="
echo "HOSTNAME=$(hostname)" echo "HOSTNAME=$(hostname)"
echo "KERNEL=$(uname -r)" echo "KERNEL=$(uname -r)"
echo "OS=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)" 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 @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 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,
@@ -91,7 +153,8 @@ echo "OS=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)"
} }
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
+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()
+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()
+8 -5
View File
@@ -61,7 +61,10 @@ def main():
panel.set_host("fake") panel.set_host("fake")
panel._proc_data = m["processes"] panel._proc_data = m["processes"]
panel._apply_proc_filter() 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) 是数字
pid_text = panel.proc_table.item(0, 0).text() pid_text = panel.proc_table.item(0, 0).text()
assert pid_text.isdigit(), f"PID 列不是数字: {pid_text!r}" assert pid_text.isdigit(), f"PID 列不是数字: {pid_text!r}"
@@ -110,7 +113,7 @@ def main():
print(f" ✓ CPU>=10% 过滤后剩 {after_min} 行,全部 CPU≥10%") print(f" ✓ CPU>=10% 过滤后剩 {after_min} 行,全部 CPU≥10%")
panel.proc_min_cpu.setValue(0) panel.proc_min_cpu.setValue(0)
app.processEvents() 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(f" ✓ CPU 过滤清空恢复 {panel.proc_table.rowCount()}")
print("[4/5] 搜索过滤") print("[4/5] 搜索过滤")
@@ -130,14 +133,14 @@ def main():
# 清空搜索 # 清空搜索
panel.proc_search.setText("") panel.proc_search.setText("")
app.processEvents() app.processEvents()
assert panel.proc_table.rowCount() == len(m["processes"]), "清空搜索应恢复" assert panel.proc_table.rowCount() == expected_rows, "清空搜索应恢复"
print(f" ✓ 清空搜索恢复全部 {panel.proc_table.rowCount()}") print(f" ✓ 清空搜索恢复全部 {panel.proc_table.rowCount()}")
# 测试按内存排序 # 测试按内存排序
panel.proc_sort_combo.setCurrentIndex(1) # 按 MEM 降序 panel.proc_sort_combo.setCurrentIndex(1) # 按 MEM 降序
app.processEvents() app.processEvents()
first = panel.proc_table.item(0, 3).text() # MEM% 在第 3 first = panel.proc_table.item(0, 4).text() # MEM% 在第 4
last = panel.proc_table.item(panel.proc_table.rowCount() - 1, 3).text() last = panel.proc_table.item(panel.proc_table.rowCount() - 1, 4).text()
assert float(first) >= float(last), f"按 MEM 排序失败: {first} < {last}" assert float(first) >= float(last), f"按 MEM 排序失败: {first} < {last}"
print(f" ✓ 按 MEM 排序: 第 1 行 {first}% > 最后行 {last}%") print(f" ✓ 按 MEM 排序: 第 1 行 {first}% > 最后行 {last}%")
+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
+210 -10
View File
@@ -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)
@@ -410,6 +502,14 @@ class MonitorPanel(QWidget):
grid.addWidget(self._build_card("内存", "mem_card")) grid.addWidget(self._build_card("内存", "mem_card"))
uv.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"))
@@ -654,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:
self.worker.stop() try:
self.worker.wait(2000) self.worker.stop()
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", "")
@@ -681,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}%")
@@ -783,11 +961,24 @@ class MonitorPanel(QWidget):
("CPU时间", "time"), ("CPU时间", "time"),
("命令", "comm"), ("命令", "comm"),
] ]
# 渲染上限:500 行采集但表格最多显示 N 行(树形不受限)
MAX_RENDER_ROWS = 200
def _apply_proc_filter(self): def _apply_proc_filter(self):
"""根据搜索框 + 排序 + 视图模式 + CPU 过滤,刷新进程表和 TOP 5""" """根据搜索框 + 排序 + 视图模式 + CPU 过滤,刷新进程表和 TOP 5"""
if not hasattr(self, "proc_table"): if not hasattr(self, "proc_table"):
return 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 "" query = self.proc_search.text().strip().lower() if hasattr(self, "proc_search") else ""
terms = [t for t in query.split() if t] terms = [t for t in query.split() if t]
sort_idx = self.proc_sort_combo.currentIndex() if hasattr(self, "proc_sort_combo") else 0 sort_idx = self.proc_sort_combo.currentIndex() if hasattr(self, "proc_sort_combo") else 0
@@ -821,6 +1012,10 @@ class MonitorPanel(QWidget):
elif sort_idx == 5: # 命令名 elif sort_idx == 5: # 命令名
data.sort(key=lambda p: (p.get("comm", "").split()[0] if p.get("comm") else "", -p.get("pcpu", 0))) 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 排序 + 缩进 # 树形视图:按 PPID 排序 + 缩进
if is_tree: if is_tree:
data = self._build_tree_view(data) data = self._build_tree_view(data)
@@ -868,8 +1063,13 @@ class MonitorPanel(QWidget):
total = len(self._proc_data) total = len(self._proc_data)
shown = len(data) shown = len(data)
mode_label = "树形" if is_tree else "扁平" mode_label = "树形" if is_tree else "扁平"
if terms or min_cpu > 0 or sort_idx != 0 or is_tree: # 如果渲染被截断,标 "(渲染 top N)"
self.proc_group.setTitle(f"进程列表 · {mode_label} · 显示 {shown}/{total}") 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: else:
self.proc_group.setTitle(f"进程列表 · {mode_label} · {total}") self.proc_group.setTitle(f"进程列表 · {mode_label} · {total}")
+14 -12
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 调度下一次"""
try: if self._stop:
m = SystemMonitor.collect(self.conn) return
self.sample_ready.emit(m) try:
except Exception as e: m = SystemMonitor.collect(self.conn)
self.error.emit(str(e)) self.sample_ready.emit(m)
for _ in range(self.interval * 10): except Exception as e:
if self._stop: self.error.emit(str(e))
return
time.sleep(0.1)
# 保留旧类名做兼容(一些其他地方可能引用了 MonitorWorker
MonitorWorker = _SystemMonitorWorker
class UploadWorker(QThread): class UploadWorker(QThread):