diff --git a/README.md b/README.md index 69b031f..fb2432e 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,14 @@ | 模块 | 说明 | | --- | --- | | 多主机管理 | 增删改查、密码/私钥双认证、配置导入导出、配置持久化到 `~/.sshclient/` | -| 远程终端 | 命令执行 + 输出捕获 + 退出码 + 超时控制 + 常用命令快捷栏 | +| 远程终端 | 真实交互式 shell(Xshell 风格)、ANSI 颜色、本地历史、Ctrl+A/E/U/K/W 快捷键 | +| 命令片段 | 20+ 预置常用命令(ss/df/ps/docker/systemctl…),一键发送到终端,自定义增删改 | | SFTP 文件浏览 | 目录树浏览、上传/下载(带进度条)、新建目录、删除、重命名、双击进入 | -| 实时监控 | CPU/内存/负载/启动时间/磁盘/网络速率,1-10 秒可调刷新间隔 | -| AI Agent | OpenAI 兼容 API(OpenAI / DeepSeek / Moonshot / 通义千问 / Ollama 等),5 个工具自动调用:执行命令、查指标、列文件、读文件、上传 | +| 实时监控 | CPU/内存/负载/启动时间/磁盘/网络速率/进程列表,1-10 秒可调刷新间隔 | +| 趋势迷你图 | CPU 和内存使用率实时折线图,最近 60 个采样点,一目了然 | +| 进程管理 | 进程表(500 条)、搜索过滤、CPU/内存排序、树形视图、右键杀进程、批量操作 | +| AI Agent | OpenAI 兼容 API(OpenAI / DeepSeek / Moonshot / 通义千问 / Ollama 等),5 个工具自动调用 | +| 暗色主题 | 视图菜单一键切换暗/亮主题,偏好自动持久化 | | 跨平台 | 代码兼容 Windows / macOS / Linux(PyQt5 + paramiko) | ## 📦 在 Windows 上构建 exe @@ -58,13 +62,15 @@ python test_e2e.py # 端到端测试(需本机或可访问的 sshd) 选中主机 → **🔌 连接**。状态指示器变绿后即可使用。 ### 3. 终端 -**⌨ 终端** Tab 直接输入命令回车执行。常用命令(pwd / df / free / top / netstat)有快捷按钮。 +**⌨ 终端** Tab 直接输入命令回车执行。支持 ANSI 颜色、↑↓ 历史回放、Ctrl+A/E/U/K/W 行编辑快捷键。 + +**命令片段**:工具栏下拉框选择预置命令(ss/df/ps/docker/systemctl 等),一键发送到终端。点击 ⚙ 管理自定义片段。 ### 4. 文件浏览 **📁 文件** Tab 双击目录进入,双击文件直接下载。可拖入 / 上传任意文件。 ### 5. 监控 -**📊 监控** Tab 点击 **开始监控**,指标会按设定间隔刷新。CPU/内存用大字突出,磁盘用进度条,网络显示当前速率。 +**📊 监控** Tab 点击 **开始监控**,指标会按设定间隔刷新。CPU/内存用大字突出,下方有实时趋势折线图,磁盘用进度条,网络显示当前速率。下半区是进程管理表,支持搜索过滤、CPU/内存排序、树形视图、右键杀进程。 ### 6. AI Agent 1. 先点 **⚙ AI 设置**,选择预设(OpenAI/DeepSeek/Kimi/通义千问/Ollama)或自定义填 API Key @@ -101,12 +107,16 @@ sshclient/ │ ├── ssh_client.py # SSH 连接 + SFTP │ ├── monitor.py # 远程系统监控 │ ├── ai_agent.py # AI Agent (OpenAI 兼容) -│ └── manager.py # 多主机管理 + 配置持久化 +│ ├── manager.py # 多主机管理 + 配置持久化 +│ └── snippets.py # 命令片段管理 + 持久化 ├── ui/ # PyQt5 界面 │ ├── main_window.py # 主窗口 -│ ├── widgets.py # FileBrowser / MonitorPanel / AIChatPanel +│ ├── widgets.py # FileBrowser / MonitorPanel / SparklineChart / AIChatPanel +│ ├── terminal_panel.py # 交互式终端面板 │ ├── host_dialog.py # 主机编辑对话框 │ ├── config_dialog.py # AI 设置对话框 +│ ├── snippet_dialog.py # 命令片段管理对话框 +│ ├── theme.py # 亮色/暗色主题 QSS │ └── workers.py # 后台线程 └── test_*.py # 测试脚本 ``` diff --git a/core/snippets.py b/core/snippets.py new file mode 100644 index 0000000..4912278 --- /dev/null +++ b/core/snippets.py @@ -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() diff --git a/test_ui.py b/test_ui.py index 0c9b3e9..b79c5ae 100644 --- a/test_ui.py +++ b/test_ui.py @@ -33,13 +33,21 @@ def main(): assert w.tabs.count() == 4, f"应有 4 个 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.monitor, MonitorPanel) 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" ✓ 暗色主题切换 / CPU·内存迷你图 / 命令片段下拉框") - print("[3/3] 验证对话框") + print("[3/4] 验证对话框") # 主机对话框 hd = HostDialog(current={"name": "test", "host": "1.2.3.4", "port": 22, "username": "u", "password": "p", "key_path": ""}) @@ -56,10 +64,23 @@ def main(): cd.close() 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() print("\n所有 UI 组件加载正常 ✓") - # 不进入事件循环,强制退出 QTimer.singleShot(0, app.quit) app.exec_() diff --git a/ui/snippet_dialog.py b/ui/snippet_dialog.py new file mode 100644 index 0000000..254b1e0 --- /dev/null +++ b/ui/snippet_dialog.py @@ -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) diff --git a/ui/terminal_panel.py b/ui/terminal_panel.py index 5dd0d42..e660410 100644 --- a/ui/terminal_panel.py +++ b/ui/terminal_panel.py @@ -21,6 +21,7 @@ from PyQt5.QtWidgets import ( ) from core.ssh_client import SSHConnection +from core.snippets import SnippetManager # ANSI 颜色映射(16 色) @@ -133,6 +134,8 @@ class TerminalPanel(QWidget): self._ansi_invert = False # OSC 0/2 设置的窗口标题 self._window_title = "" + # 命令片段 + self.snippet_mgr = SnippetManager() self._build_ui() self._apply_style() @@ -152,6 +155,20 @@ class TerminalPanel(QWidget): self.status_label.setStyleSheet("color: #888;") toolbar.addWidget(self.status_label) 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.clicked.connect(self._clear_screen) 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() + # ============================================================ # 生命周期 # ============================================================