From 9b45ad77e5db28255d6036a677bb32945cc96d6c Mon Sep 17 00:00:00 2001 From: yinjianm Date: Wed, 15 Apr 2026 22:21:26 +0800 Subject: [PATCH] feat(status-monitor): add websocket process manager and runtime metadata extend server status payload with timezone, uptime, and process summary so the monitor sidebar can show richer at-a-glance host context. introduce process:list and process:signal websocket flows on active SSH sessions, enabling on-demand process querying and terminate/kill actions without adding new HTTP endpoints. add a dedicated process manager modal in the frontend with search, refresh, auto-refresh, and per-process actions, and wire localized labels for both english and chinese. enhance global connection fuzzy search scoring to include tag names as secondary-weight fields while preserving primary host/name relevance. --- .helloagents/CHANGELOG.md | 8 + .../.status.json | 1 + .../proposal.md | 117 ++++ .../tasks.md | 49 ++ .../proposal.md | 167 +++++ .../tasks.md | 63 ++ .helloagents/archive/_index.md | 2 + .helloagents/modules/backend.md | 8 +- .helloagents/modules/frontend.md | 6 +- .../src/services/status-monitor.service.ts | 105 ++++ packages/backend/src/websocket/connection.ts | 12 +- .../src/websocket/handlers/process.handler.ts | 261 ++++++++ packages/backend/src/websocket/types.ts | 17 +- .../GlobalConnectionQuickSearch.vue | 4 +- .../src/components/ProcessManagerModal.vue | 578 ++++++++++++++++++ .../frontend/src/components/StatusMonitor.vue | 197 +++++- packages/frontend/src/locales/en-US.json | 36 +- packages/frontend/src/locales/zh-CN.json | 36 +- packages/frontend/src/types/server.types.ts | 17 + .../frontend/src/types/websocket.types.ts | 15 + .../frontend/src/utils/connectionSearch.ts | 18 +- 21 files changed, 1701 insertions(+), 16 deletions(-) create mode 100644 .helloagents/archive/2026-04/202604152139_workspace-global-search-tag-fuzzy-search/.status.json create mode 100644 .helloagents/archive/2026-04/202604152139_workspace-global-search-tag-fuzzy-search/proposal.md create mode 100644 .helloagents/archive/2026-04/202604152139_workspace-global-search-tag-fuzzy-search/tasks.md create mode 100644 .helloagents/archive/2026-04/202604152147_status-monitor-process-manager-modal/proposal.md create mode 100644 .helloagents/archive/2026-04/202604152147_status-monitor-process-manager-modal/tasks.md create mode 100644 packages/backend/src/websocket/handlers/process.handler.ts create mode 100644 packages/frontend/src/components/ProcessManagerModal.vue diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index d29cd1d..3937173 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +- **[frontend]**: 将右侧状态监控继续收紧为更贴近服务器小屏的默认概览,并新增时区、运行时间、进程概览与“查看全部”独立进程管理弹窗 — by yinjianm + - 方案: [202604152147_status-monitor-process-manager-modal](archive/2026-04/202604152147_status-monitor-process-manager-modal/) +- **[backend]**: 扩展状态监控采集时区、运行时间和轻量进程摘要,并为当前 SSH 会话新增 `process:list` / `process:signal` WebSocket 进程管理消息 — by yinjianm + - 方案: [202604152147_status-monitor-process-manager-modal](archive/2026-04/202604152147_status-monitor-process-manager-modal/) + +- **[frontend]**: 让全局服务器检索将标签名纳入本地模糊搜索评分,并保持标签匹配权重低于名称和主机、高于类型 - by yinjianm + - 方案: [202604152139_workspace-global-search-tag-fuzzy-search](archive/2026-04/202604152139_workspace-global-search-tag-fuzzy-search/) + - **[frontend]**: 将工作区状态监控重构为更接近服务器监控小屏的深色响应式面板,统一头部信息条、资源监控条、内存/网络/磁盘卡片及 CPU/网络趋势图风格 — by yinjianm - 方案: [202604152109_status-monitor-responsive-remodel](archive/2026-04/202604152109_status-monitor-responsive-remodel/) diff --git a/.helloagents/archive/2026-04/202604152139_workspace-global-search-tag-fuzzy-search/.status.json b/.helloagents/archive/2026-04/202604152139_workspace-global-search-tag-fuzzy-search/.status.json new file mode 100644 index 0000000..7bb4abf --- /dev/null +++ b/.helloagents/archive/2026-04/202604152139_workspace-global-search-tag-fuzzy-search/.status.json @@ -0,0 +1 @@ +{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"Completed - tag names now participate in global connection fuzzy search","updated_at":"2026-04-15 21:44:00"} diff --git a/.helloagents/archive/2026-04/202604152139_workspace-global-search-tag-fuzzy-search/proposal.md b/.helloagents/archive/2026-04/202604152139_workspace-global-search-tag-fuzzy-search/proposal.md new file mode 100644 index 0000000..715f5de --- /dev/null +++ b/.helloagents/archive/2026-04/202604152139_workspace-global-search-tag-fuzzy-search/proposal.md @@ -0,0 +1,117 @@ +# 变更提案: workspace-global-search-tag-fuzzy-search + +## 元信息 +```yaml +类型: 优化 +方案类型: implementation +优先级: P2 +状态: 草稿 +创建: 2026-04-15 +``` + +--- + +## 1. 需求 + +### 背景 +“全局服务器检索”已经支持在结果卡片中显示服务器标签,但当前模糊搜索只对连接名称、主机、用户名和类型做评分,用户通过标签组织环境或用途时,仍然无法直接按标签词搜索到目标服务器。 + +### 目标 +- 让服务器标签名也参与全局模糊搜索。 +- 保持当前排序主次关系,其中标签匹配权重低于服务器名称和主机,高于类型。 +- 尽量局部改动,只影响全局服务器检索及其搜索工具函数。 + +### 约束条件 +```yaml +时间约束: 本轮内完成搜索权重调整与基础验证 +性能约束: 保持当前本地评分模型,不引入额外远程请求 +兼容性约束: 不改变空搜索时的默认排序,不影响现有结果卡片渲染 +业务约束: 标签只作为附加搜索字段,不替代名称和主机的主排序地位 +``` + +### 验收标准 +- [ ] 在全局服务器检索中输入标签名时,能命中绑定该标签的服务器。 +- [ ] 当名称/主机与标签同时参与匹配时,名称和主机仍优先于标签字段排序。 +- [ ] 空搜索场景仍按最近连接和显示名排序,不受新增标签字段影响。 +- [ ] 至少完成一次前端可执行校验,确认本次改动未引入新的打包错误。 + +--- + +## 2. 方案 + +### 技术方案 +扩展 `packages/frontend/src/utils/connectionSearch.ts`,为搜索函数增加可选的附加搜索字段提供器,由调用方传入标签名数组。`GlobalConnectionQuickSearch.vue` 继续复用当前标签映射逻辑,把每条连接的标签名作为附加字段传给 `searchConnections()`。评分权重设置为低于 `host`、高于 `type` 的中低档位,只影响有查询词时的匹配分数。 + +### 影响范围 +```yaml +涉及模块: + - frontend: 全局服务器检索的本地模糊搜索评分 + - knowledge-base: 新增方案包并记录本次搜索能力优化 +预计变更文件: 5 +``` + +### 风险评估 +| 风险 | 等级 | 应对 | +|------|------|------| +| 标签权重过高,导致标签命中结果压过名称或主机匹配 | 中 | 将标签字段权重明确控制在 `host` 之下,并保留现有主字段排序 | +| 搜索工具函数签名调整影响其他调用点 | 低 | 当前仓库仅 `GlobalConnectionQuickSearch.vue` 使用该函数,保持参数向后兼容 | +| 标签缺失或标签缓存未加载时搜索结果不稳定 | 低 | 附加字段回调默认返回空数组,缺失时静默降级为现有搜索行为 | + +--- + +## 3. 技术设计(可选) + +> 本次不涉及架构、API 或数据模型变更,N/A。 + +--- + +## 4. 核心场景 + +### 场景: 按标签检索服务器 +**模块**: frontend +**条件**: 用户打开全局服务器检索,并输入某个服务器标签名 +**行为**: 搜索工具函数把标签名作为附加字段参与模糊匹配与排序 +**结果**: 绑定该标签的服务器能出现在搜索结果中 + +### 场景: 主字段保持更高优先级 +**模块**: frontend +**条件**: 查询词同时可能命中名称、主机和标签 +**行为**: 名称和主机匹配继续获得更高分值,标签只作为次级加权字段 +**结果**: 结果排序仍符合“先服务器标识、后标签补充”的预期 + +--- + +## 5. 技术决策 + +### workspace-global-search-tag-fuzzy-search#D001: 用可选附加字段扩展搜索函数,而不是把标签逻辑硬编码进工具函数 +**日期**: 2026-04-15 +**状态**: ✅采纳 +**背景**: 需要让标签参与全局搜索,但又不想让工具函数只为一个组件写死标签依赖。 +**选项分析**: +| 选项 | 优点 | 缺点 | +|------|------|------| +| A: 在 `connectionSearch.ts` 内直接读取标签 store | 调用方最简单 | 工具函数耦合 UI store,不利于复用和测试 | +| B: 为 `searchConnections()` 增加可选附加字段回调 | 依赖边界清晰,调用方按需提供标签名 | 函数签名略有扩展 | +**决策**: 选择方案 B +**理由**: 既能满足当前需求,又能把工具函数保持为纯评分逻辑,不直接依赖 Vue/Pinia。 +**影响**: frontend + +--- + +## 6. 成果设计 + +### 设计方向 +- **美学基调**: 不改视觉布局,搜索能力增强应保持“无感升级”。 +- **记忆点**: 用户可以直接输入环境标签词并立即定位服务器。 +- **参考**: 继续沿用现有 `GlobalConnectionQuickSearch.vue` 结果卡片展示。 + +### 视觉要素 +- **配色**: N/A +- **字体**: N/A +- **布局**: 不新增额外布局变化,保持现有结果卡片结构。 +- **动效**: N/A +- **氛围**: N/A + +### 技术约束 +- **可访问性**: 搜索行为增强不应改变现有键盘上下选择与回车连接流程。 +- **响应式**: N/A diff --git a/.helloagents/archive/2026-04/202604152139_workspace-global-search-tag-fuzzy-search/tasks.md b/.helloagents/archive/2026-04/202604152139_workspace-global-search-tag-fuzzy-search/tasks.md new file mode 100644 index 0000000..1ddfc1d --- /dev/null +++ b/.helloagents/archive/2026-04/202604152139_workspace-global-search-tag-fuzzy-search/tasks.md @@ -0,0 +1,49 @@ +# 任务清单: workspace-global-search-tag-fuzzy-search + +> **@status:** completed | 2026-04-15 21:51 + +```yaml +@feature: workspace-global-search-tag-fuzzy-search +@created: 2026-04-15 +@status: completed +@mode: R2 +``` + +## 进度概览 + +| 完成 | 失败 | 跳过 | 总数 | +|------|------|------|------| +| 4 | 0 | 0 | 4 | + +--- + +## 任务列表 + +### 1. 方案设计 + +- [√] 1.1 确认标签搜索权重策略为“低于名称和主机,高于类型”,并收敛到全局搜索工具与调用组件 | depends_on: [] + +### 2. 前端实现 + +- [√] 2.1 在 `packages/frontend/src/utils/connectionSearch.ts` 中为搜索函数增加附加搜索字段能力,并设置标签字段权重 | depends_on: [1.1] +- [√] 2.2 在 `packages/frontend/src/components/GlobalConnectionQuickSearch.vue` 中将连接标签名传入搜索函数,接通按标签模糊搜索 | depends_on: [2.1] + +### 3. 验证与知识同步 + +- [√] 3.1 执行前端构建或等价校验,确认标签加入模糊搜索后未引入新的打包错误 | depends_on: [2.2] + +--- + +## 执行日志 + +| 时间 | 任务 | 状态 | 备注 | +|------|------|------|------| +| 2026-04-15 21:39 | DESIGN | completed | 已确认本次为现有全局检索的搜索增强,标签匹配权重低于名称与主机、高于类型 | +| 2026-04-15 21:42 | 2.1-2.2 | completed | 已扩展 `searchConnections()` 的附加搜索字段能力,并在 `GlobalConnectionQuickSearch.vue` 中接通标签名搜索 | +| 2026-04-15 21:44 | 3.1 | completed | `npm --workspace @nexus-terminal/frontend exec vite build` 通过,确认标签搜索接入未引入新的打包错误 | + +--- + +## 执行备注 + +> 本次只增强“标签参与搜索”,不调整空搜索排序,不扩展到其他连接列表或筛选器。 diff --git a/.helloagents/archive/2026-04/202604152147_status-monitor-process-manager-modal/proposal.md b/.helloagents/archive/2026-04/202604152147_status-monitor-process-manager-modal/proposal.md new file mode 100644 index 0000000..9ae113f --- /dev/null +++ b/.helloagents/archive/2026-04/202604152147_status-monitor-process-manager-modal/proposal.md @@ -0,0 +1,167 @@ +# 变更提案: status-monitor-process-manager-modal + +## 元信息 +```yaml +类型: 新功能/重构/优化 +方案类型: implementation +优先级: P1 +状态: 已确认 +创建: 2026-04-15 +``` + +--- + +## 1. 需求 + +### 背景 +上一轮状态监控已经完成基础视觉重构,但用户明确反馈“不够好看,要更贴近参考图”,同时新增了明确需求:默认状态视图中需要补上时区、运行时间和进程管理概览;点击“查看全部”后,应弹出一个更完整的独立进程管理页面,风格接近用户提供的深色表格化管理截图。 + +### 目标 +- 将默认状态视图继续收紧为更接近“服务器监控小屏”的布局节奏与视觉密度 +- 在默认状态视图中新增时区、运行时间和进程管理概览 +- 提供“查看全部”入口,打开独立 modal 形式的进程管理页面,支持搜索、刷新、自动刷新和结束/强制结束进程 + +### 约束条件 +```yaml +时间约束: 当前回合内完成设计、实现、验证和知识库同步 +性能约束: 进程列表查询与自动刷新不能影响现有 SSH 会话稳定性;默认视图仅展示摘要,避免持续渲染超长列表 +兼容性约束: 复用现有 SSH 会话 WebSocket 链路,不破坏状态轮询、终端、Docker 和现有 modal 行为 +业务约束: 默认视图是轻量概览;完整进程管理通过“查看全部” modal 打开,不直接把整张管理表挤进侧栏 +``` + +### 验收标准 +- [ ] `StatusMonitor.vue` 默认视图补齐时区、运行时间和进程概览,并整体更贴近用户给出的服务器小屏参考图 +- [ ] 新增独立进程管理 modal,支持搜索、刷新、自动刷新、结束进程、强制结束进程 +- [ ] 后端通过现有 WebSocket 会话返回时区、运行时间、进程列表和进程操作结果,前端构建通过 + +--- + +## 2. 方案 + +### 技术方案 +本次改动分为三层: + +1. 扩展状态监控后端采集: +- 在 `status-monitor.service.ts` 中新增服务器时区和运行时间采集 +- 保持 `status_update` 主链路用于基础状态字段 + +2. 新增进程管理 WebSocket 能力: +- 在现有 SSH 会话 WebSocket 连接上扩展 `process:list` 与 `process:signal` 消息类型 +- 后端通过当前活动 SSH 会话执行 `ps` 和 `kill` 指令,返回结构化进程列表与操作结果 + +3. 前端双层展示: +- `StatusMonitor.vue` 默认视图继续向监控小屏靠拢,新增时区、运行时间和进程概览卡片 +- “查看全部” 打开新的 `ProcessManagerModal.vue`,其中嵌入完整进程管理表格视图 +- 表格风格尽量贴近用户截图,采用紧凑列、搜索栏、自动刷新开关和右侧操作列 + +### 影响范围 +```yaml +涉及模块: + - packages/backend/src/services/status-monitor.service.ts: 扩展时区和运行时间采集 + - packages/backend/src/websocket/connection.ts: 新增进程查询与信号操作消息分发 + - packages/backend/src/websocket/types.ts: 扩展进程管理消息类型 + - packages/frontend/src/types/server.types.ts: 增加时区/运行时间字段 + - packages/frontend/src/components/StatusMonitor.vue: 默认状态视图继续贴近参考图并增加进程概览 + - packages/frontend/src/components/ProcessManagerModal.vue: 独立进程管理 modal + - packages/frontend/src/locales/*.json: 新增进程管理与状态监控文案 + - .helloagents/modules/frontend.md / modules/backend.md / CHANGELOG.md: 同步知识库 +预计变更文件: 8-10 +``` + +### 风险评估 +| 风险 | 等级 | 应对 | +|------|------|------| +| `ps` 输出格式在不同 Linux 发行版上存在差异 | 中 | 使用明确字段顺序与受控 awk/tab 分隔格式,后端集中解析并做回退 | +| 进程操作误伤系统进程 | 中 | 默认仅提供单行显式操作按钮,不做批量;强制结束作为二级危险动作高亮展示 | +| modal 自动刷新与会话切换叠加导致订阅混乱 | 中 | 前端对 active session / isVisible 建立显式注册与清理逻辑,关闭 modal 时停止自动刷新 | + +--- + +## 3. 技术设计(可选) + +> 本次以现有 SSH 会话 WebSocket 为主,不新增独立 HTTP 管理接口。 + +### 架构设计 +```mermaid +flowchart TD + A[StatusMonitorService] --> B[status_update] + C[WebSocket connection.ts] --> D[process:list / process:signal] + B --> E[StatusMonitor.vue 默认概览] + D --> E + E --> F[ProcessManagerModal.vue] +``` + +### API设计 +#### WebSocket `process:list` +- **请求**: `{ type: 'process:list', sessionId, payload: { limit?: number } }` +- **响应**: `{ type: 'process:list:response', sessionId, payload: { processes, total, running, sleeping } }` + +#### WebSocket `process:signal` +- **请求**: `{ type: 'process:signal', sessionId, payload: { pid, signal: 'TERM' | 'KILL' } }` +- **响应**: `{ type: 'process:signal:response', sessionId, payload: { pid, signal, success, error? } }` + +### 数据模型 +| 字段 | 类型 | 说明 | +|------|------|------| +| `timezone` | `string` | 服务器当前时区显示文本 | +| `uptimeSeconds` | `number` | 服务器已运行秒数 | +| `ProcessListItem.pid` | `number` | 进程 ID | +| `ProcessListItem.user` | `string` | 所属用户 | +| `ProcessListItem.state` | `string` | 进程状态 | +| `ProcessListItem.cpu` | `number` | CPU 占比 | +| `ProcessListItem.memMb` | `number` | 内存占用,MB | +| `ProcessListItem.startedAt` | `string` | 启动时间文本 | +| `ProcessListItem.command` | `string` | 完整命令 | + +--- + +## 4. 核心场景 + +### 场景: 默认状态视图查看进程概览 +**模块**: `packages/frontend/src/components/StatusMonitor.vue` +**条件**: 用户已有活动 SSH 会话,状态监控面板正常显示 +**行为**: 面板以更接近服务器监控小屏的密度展示系统、时区、运行时间和资源状态,并在底部显示进程概览和“查看全部”入口 +**结果**: 用户无需离开侧栏即可快速判断机器运行情况,并知道是否需要打开完整进程管理 + +### 场景: 查看全部进程管理 +**模块**: `packages/frontend/src/components/ProcessManagerModal.vue` +**条件**: 用户点击状态监控中的“查看全部” +**行为**: 打开独立 modal,显示可搜索的进程表格,并支持刷新、自动刷新、结束进程和强制结束 +**结果**: 用户获得接近参考图的独立进程管理页面,同时仍保留在当前工作区上下文中 + +--- + +## 5. 技术决策 + +### status-monitor-process-manager-modal#D001: 进程管理沿当前 SSH 会话 WebSocket 链路实现 +**日期**: 2026-04-15 +**状态**: ✅采纳 +**背景**: 进程列表和进程操作都依赖当前活动 SSH 会话上下文,若改走独立 HTTP 接口,需要额外解决会话绑定和 SSH 复用问题。 +**选项分析**: +| 选项 | 优点 | 缺点 | +|------|------|------| +| A: 新增 HTTP 接口直连后台查询进程 | 前端调用方式统一 | 很难绑定当前 SSH 会话,需额外管理远端执行上下文 | +| B: 复用现有 SSH 会话 WebSocket 消息 | 与当前终端/状态监控链路一致,复用现有 sessionId 和 wsManager | 需要扩展消息类型和前端订阅 | +**决策**: 选择方案 B +**理由**: 与现有架构最一致,能自然绑定活动 SSH 会话,也更适合 modal 内的即时刷新和进程操作回执。 +**影响**: 影响 `packages/backend/src/websocket/*`、`StatusMonitor.vue` 以及新的进程管理 modal 组件 + +--- + +## 6. 成果设计 + +### 设计方向 +- **美学基调**: 更贴近运维监控终端的小屏控制台风格,默认视图强调紧凑、层级分明、状态色克制,弹窗则偏运维后台表格台面 +- **记忆点**: 默认视图像“服务器小屏仪表”,而点击“查看全部”后立刻切到一张近似真实进程控制台的深色表格 +- **参考**: 用户给出的服务器状态小屏截图 + 进程管理独立弹窗截图 + +### 视觉要素 +- **配色**: 延续炭黑监控底色,默认视图用绿色/蓝色/琥珀色状态高亮;进程表格中的危险操作使用明显红色 +- **字体**: 继续使用项目现有字体体系,强调等宽数字和紧凑列头,避免引入额外字体依赖 +- **布局**: 默认视图尽量纵向、像小屏;进程管理 modal 采用顶栏搜索 + 表格主体 + 右侧操作列的后台结构 +- **动效**: 仅保留轻量 hover、刷新和 modal 开合过渡,不增加大幅动画 +- **氛围**: 深色渐变、薄边框、表格网格线、低饱和荧光状态色和危险动作红色高亮 + +### 技术约束 +- **可访问性**: 表头、搜索框、按钮和危险操作需要明确可见状态;危险动作文字不能只靠颜色表达 +- **响应式**: 默认视图继续兼容窄侧栏;modal 在桌面优先按参考图宽表格布局,小屏允许横向滚动 diff --git a/.helloagents/archive/2026-04/202604152147_status-monitor-process-manager-modal/tasks.md b/.helloagents/archive/2026-04/202604152147_status-monitor-process-manager-modal/tasks.md new file mode 100644 index 0000000..5bb8be1 --- /dev/null +++ b/.helloagents/archive/2026-04/202604152147_status-monitor-process-manager-modal/tasks.md @@ -0,0 +1,63 @@ +# 任务清单: status-monitor-process-manager-modal + +> **@status:** completed | 2026-04-15 22:12 + +```yaml +@feature: status-monitor-process-manager-modal +@created: 2026-04-15 +@status: completed +@mode: R2 +``` + +## 进度概览 + +| 完成 | 失败 | 跳过 | 总数 | +|------|------|------|------| +| 7 | 0 | 0 | 7 | + +--- + +## 任务列表 + +### 1. 需求与链路确认 + +- [√] 1.1 确认时区、运行时间和进程管理应分别挂载到状态轮询链路与 SSH 会话 WebSocket 链路 | depends_on: [] + +### 2. 后端能力补充 + +- [√] 2.1 在 `packages/backend/src/services/status-monitor.service.ts` 中新增时区与运行时间采集并扩展状态返回结构 | depends_on: [1.1] +- [√] 2.2 在 `packages/backend/src/websocket/types.ts` 与 `packages/backend/src/websocket/connection.ts` 中新增进程列表和进程信号操作消息 | depends_on: [1.1] + +### 3. 前端类型与默认视图 + +- [√] 3.1 在 `packages/frontend/src/types/server.types.ts` 中补充时区/运行时间/进程相关类型 | depends_on: [2.1, 2.2] +- [√] 3.2 在 `packages/frontend/src/components/StatusMonitor.vue` 中继续收紧默认视图,并新增时区、运行时间和进程概览区 | depends_on: [3.1] + +### 4. 独立进程管理页面 + +- [√] 4.1 新增进程管理 modal 组件,支持搜索、刷新、自动刷新和结束/强制结束操作 | depends_on: [2.2, 3.1] + +### 5. 文案、验证与同步 + +- [√] 5.1 更新中英文状态监控和进程管理文案 | depends_on: [3.2, 4.1] +- [√] 5.2 执行前后端相关构建验证并同步知识库、CHANGELOG、方案包 | depends_on: [2.1, 2.2, 3.2, 4.1, 5.1] + +--- + +## 执行日志 + +| 时间 | 任务 | 状态 | 备注 | +|------|------|------|------| +| 2026-04-15 21:47 | 1.1 | completed | 已确认默认视图和完整进程管理页分层交付,后端需补状态字段与进程 WebSocket 消息 | +| 2026-04-15 21:56 | 2.1 / 2.2 | completed | 后端状态监控已补时区、运行时间、进程摘要,并新增进程列表 / 信号操作 WebSocket handler | +| 2026-04-15 22:02 | 3.1 / 3.2 | completed | 前端状态类型已扩展,默认状态视图已新增时区、运行时间和进程概览入口 | +| 2026-04-15 22:06 | 4.1 | completed | 已新增独立 `ProcessManagerModal.vue`,支持搜索、自动刷新、刷新和结束/强制结束进程 | +| 2026-04-15 22:08 | 5.1 / 5.2 | completed | `packages/backend` 与 `packages/frontend` 构建通过;文案和知识库已同步 | + +--- + +## 执行备注 + +- 默认视图继续贴近监控小屏,不直接承载完整进程表格 +- 完整进程管理通过 modal 打开,避免破坏右侧状态监控的侧栏职责 +- 前端构建仍保留仓库既有的 Vite 动态导入与大 chunk 警告,但本次改动未引入新的阻断性错误 diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md index c9ddefa..6f97230 100644 --- a/.helloagents/archive/_index.md +++ b/.helloagents/archive/_index.md @@ -7,6 +7,8 @@ | 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 | |--------|------|------|---------|------|------| +| 202604152147 | status-monitor-process-manager-modal | - | - | - | ✅完成 | +| 202604152139 | workspace-global-search-tag-fuzzy-search | - | - | - | ✅完成 | | 202604152110 | workspace-global-search-show-connection-tags | - | - | - | ✅完成 | | 202604152109 | status-monitor-responsive-remodel | - | - | - | ✅完成 | | 202604122248 | connections-tag-batch-management | implementation | frontend, backend | connections-tag-batch-management#D001 | ✅完成 | diff --git a/.helloagents/modules/backend.md b/.helloagents/modules/backend.md index 397eae8..d3c291e 100644 --- a/.helloagents/modules/backend.md +++ b/.helloagents/modules/backend.md @@ -66,8 +66,8 @@ ### 状态监控 **条件**: 前端工作区通过 WebSocket 订阅服务器状态。 -**行为**: `StatusMonitorService` 通过 SSH 读取 `free`、`df`、`/proc/stat` 与 `/proc/net/dev`,同时计算瞬时网速与默认网卡自开机以来的累计上下行字节数。 -**结果**: 前端状态监控既能展示实时速率,也能展示“开机累计流量”,后续扩展监控字段时应优先复用现有 SSH 采集链路。 +**行为**: `StatusMonitorService` 通过 SSH 读取 `free`、`df`、`/proc/stat`、`/proc/net/dev`、`date` 与 `/proc/uptime`,同时计算瞬时网速与默认网卡自开机以来的累计上下行字节数,并在常规 `status_update` 中附带服务器时区、运行秒数和轻量级进程摘要(总数、运行中、休眠中、Top 进程预览)。 +**结果**: 前端状态监控既能展示实时资源状态,也能直接展示服务器时区、运行时间和默认进程概览,而无需再为这些基础信息单独请求后端。 ## 依赖关系 @@ -78,5 +78,5 @@ ### 状态监控字段扩展 **条件**: `StatusMonitorService` 为前端工作区持续轮询服务器状态。 -**行为**: 当前状态采集链路除 `free`、`df`、`/proc/stat` 与 `/proc/net/dev` 外,还会补充解析 `memFree`、`memCached`、`diskAvailable`、`diskMountPoint`、`diskFsType`、`diskDevice`,并基于 `/proc/diskstats` 计算根设备的磁盘读写速率;CPU 规格信息则会先读取 CPU 型号,再通过 `nproc`、`getconf _NPROCESSORS_ONLN`、`grep -c '^processor' /proc/cpuinfo` 与 `lscpu` 多级回退获取 `cpuCores`;无法获取的字段均按 `undefined` 降级。 -**结果**: 前端状态监控可以直接展示参考图风格的内存/磁盘卡片,并额外展示 CPU 核心数,而不需要再自行推导缓存、空闲、磁盘元信息或服务器 CPU 规格。 +**行为**: 当前状态采集链路除 `free`、`df`、`/proc/stat` 与 `/proc/net/dev` 外,还会补充解析 `memFree`、`memCached`、`diskAvailable`、`diskMountPoint`、`diskFsType`、`diskDevice`,并基于 `/proc/diskstats` 计算根设备的磁盘读写速率;CPU 规格信息则会先读取 CPU 型号,再通过 `nproc`、`getconf _NPROCESSORS_ONLN`、`grep -c '^processor' /proc/cpuinfo` 与 `lscpu` 多级回退获取 `cpuCores`;本轮还新增服务器时区、运行时间和默认进程摘要采集。与此同时,`websocket/connection.ts` 新增 `process:list` 与 `process:signal` 消息分发,后端会在当前活动 SSH 会话上下文中执行 `ps` 与 `kill` 指令,返回完整进程列表及结束/强制结束结果。 +**结果**: 前端默认状态监控可以展示更完整的小屏监控信息,而“查看全部”进程管理 modal 也能沿同一 SSH 会话上下文安全复用进程查询与操作能力。 diff --git a/.helloagents/modules/frontend.md b/.helloagents/modules/frontend.md index 2e7cf43..17f8b26 100644 --- a/.helloagents/modules/frontend.md +++ b/.helloagents/modules/frontend.md @@ -36,7 +36,7 @@ ### 工作区交互 **条件**: 用户进入 `/workspace` 或相关管理页面。 -**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 继续整合快捷指令、命令历史、文件管理和编辑器四个面板,导航入口保持为纯图标按钮,但已调整为位于 `Workbench` 标题区上方的横向 icon rail,四个入口自左向右排列、默认仅显示图标并通过 tooltip 暴露名称,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。应用根组件 `App.vue` 现在还新增了全局服务器快捷检索:已登录页面按下 `Ctrl+Shift+F` 会打开 `GlobalConnectionQuickSearch.vue`,通过 `utils/connectionSearch.ts` 对连接名称、主机、用户名和类型做本地模糊排序,并直接复用 `sessionStore.handleConnectRequest()` 触发 SSH 工作区跳转或 RDP / VNC 弹窗连接;该检索弹层现在还会复用 `tags.store.ts` 读取标签名称映射,在结果卡片内补充显示每台服务器的标签 chips,便于快速区分同名或近似主机。快捷指令相关能力目前由 `AddEditQuickCommandForm.vue`、`QuickCommandsView.vue` 与新增的 `utils/quickCommandTemplate.ts` 协同实现:编辑弹窗左侧既可维护自定义 `${变量名}`,也提供 `${{date}}`、`${{time}}`、`${{timestamp}}`、`${{week}}`、`${{uuid}}`、`${{random:8}}`、`${{clipboard}}`、`${{password}}` 等动态变量的一键插入;实际执行时会统一走共享解析器,覆盖编辑弹窗执行、列表直接执行、粘贴到命令输入框和发送到全部服务器等链路,并对未定义变量、无法读取的剪贴板或不可用密码给出非阻断告警。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;该视图当前还支持命令项右键菜单,并已修正为实底卡片式上下文菜单,提供立即执行、粘贴到命令输入框(不自动发送)、复制命令、发送到全部服务器、编辑和删除等动作。快捷命令列表的鼠标主交互当前已从“单击立即执行”收紧为“单击仅更新选中态、双击才执行”,从而继续兼容键盘 `Enter` 的选中执行路径并降低误触风险;每条命令项同时会把完整 `command` 文本挂到浏览器原生 tooltip 上,便于在名称或命令被截断时直接 hover 核对完整内容。`Terminal.vue` 现在会跟踪 xterm 相对底部的视口偏移与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复;当隐藏标签在后台持续追加日志时,重新激活会基于“距底部偏移”而不是过期的绝对行号恢复 viewport,避免用户继续向下滚动时无法回到底部。组件同时继续在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`session.store` 当前会为同一 SSH 连接下的新终端分配递增的 `terminalIndex`。当前顶部 `TerminalTabBar.vue` 已改为服务器级入口:SSH 项只负责在不同服务器之间切换,全局 `+` 继续负责选择其他服务器;同一服务器下的多个终端则下沉到 `LayoutRenderer.vue` 的终端面板内部,以次级标签条承载切换、关闭和新增,从而让“进入服务器后再管理该服务器的多个终端”成为主要交互模型。服务器组头现在除主点击切换外,还额外提供了一个 hover 后出现的 `X` 按钮,点击后会复用既有 `session:close` 事件逐个关闭该 `connectionId` 下的全部终端。当前终端标签右键菜单继续复用 `WorkspaceView.vue` 中转的会话关闭链路,除关闭当前、关闭其他、关闭左右侧外,也支持直接触发“关闭全部”来清空当前工作区中的全部终端标签。连接新增弹窗中的脚本模式则继续由 `useAddConnectionForm.ts` 统一清洗输入:会先剔除空行、Markdown 代码围栏行,再按单引号/双引号感知切分参数,并去掉成对包裹值的外层引号,避免像 `-p '$Moka1998A'` 这样的输入把 `'` 一并保存。`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,当前左侧进一步支持基于标签名路径分隔符推导的多级标签树、树节点展开状态持久化、分组 scope 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;近期又补上了独立的左侧树搜索、命中节点及祖先路径过滤、命中链路自动展开、节点计数高亮,以及更接近资源管理器的树头部布局;本轮继续为树节点加入 hover 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单(编辑/测试/克隆/删除);连接页顶部工具条当前又补上了独立“标签管理”入口,打开 `ManageConnectionTagsModal.vue` 后可按标签名搜索、多选、批量删除标签,并通过显式危险开关决定删除标签时是否连带删除命中的连接;`tags.store.ts` 在该链路里会统一刷新标签与连接缓存,而 `ConnectionsView.vue` 会在当前 scope 指向已删标签或分组时自动回退到 `all`。`FileManager.vue` 当前已进一步收敛为固定 `/` 根节点的单栏资源管理器树,组件加载时会优先拉取 `/` 目录,树中按“目录在前、文件在后”同时显示目录和文件节点,点击目录只展开与聚焦,点击文件则沿用现有工作区文件打开链路;文件右键菜单链路则已补齐图标化菜单结构、危险态删除项、终端子菜单(执行 `cd` 命令到终端 / 新建终端到当前目录)、复制文件名与复制绝对路径等动作,并继续复用现有下载、权限、新建、上传和删除逻辑,同时又新增了独立“上传文件夹”入口:前端会先将本地目录打包为 zip,再复用现有 `sftp:upload` 链路上传,并在上传成功后自动调用远端解压、尝试清理临时压缩包;外部拖拽文件或目录上传时,则会按鼠标当前悬停的目录作为目标路径,其中目录同样走“先压缩再上传”的路径,从而显著降低小文件很多时的扫描与上传耗时;本轮又补上了拖拽上传前的目标路径确认、桌面端右键子菜单点击展开,以及目录删除时“仅删空目录 / 强制递归删除”的显式二选一;当前右键菜单的关闭职责已经收敛到 `FileManagerContextMenu.vue` 组件层处理,`useFileManagerContextMenu.ts` 不再额外注册捕获阶段的全局点击关闭监听,以避免“终端 / 上传 / 压缩”等带子菜单项在展开或点击前被提前关闭;同时 `useSftpActions.ts` 会在删除目录后自动回退当前或待加载的失效路径,避免文件树持续对已删除目录刷出 `No such file`。样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。 +**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 继续整合快捷指令、命令历史、文件管理和编辑器四个面板,导航入口保持为纯图标按钮,但已调整为位于 `Workbench` 标题区上方的横向 icon rail,四个入口自左向右排列、默认仅显示图标并通过 tooltip 暴露名称,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。应用根组件 `App.vue` 现在还新增了全局服务器快捷检索:已登录页面按下 `Ctrl+Shift+F` 会打开 `GlobalConnectionQuickSearch.vue`,通过 `utils/connectionSearch.ts` 对连接名称、主机、用户名、类型和标签做本地模糊排序,并直接复用 `sessionStore.handleConnectRequest()` 触发 SSH 工作区跳转或 RDP / VNC 弹窗连接;该检索弹层现在还会复用 `tags.store.ts` 读取标签名称映射,在结果卡片内补充显示每台服务器的标签 chips,便于快速区分同名或近似主机。快捷指令相关能力目前由 `AddEditQuickCommandForm.vue`、`QuickCommandsView.vue` 与新增的 `utils/quickCommandTemplate.ts` 协同实现:编辑弹窗左侧既可维护自定义 `${变量名}`,也提供 `${{date}}`、`${{time}}`、`${{timestamp}}`、`${{week}}`、`${{uuid}}`、`${{random:8}}`、`${{clipboard}}`、`${{password}}` 等动态变量的一键插入;实际执行时会统一走共享解析器,覆盖编辑弹窗执行、列表直接执行、粘贴到命令输入框和发送到全部服务器等链路,并对未定义变量、无法读取的剪贴板或不可用密码给出非阻断告警。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;该视图当前还支持命令项右键菜单,并已修正为实底卡片式上下文菜单,提供立即执行、粘贴到命令输入框(不自动发送)、复制命令、发送到全部服务器、编辑和删除等动作。快捷命令列表的鼠标主交互当前已从“单击立即执行”收紧为“单击仅更新选中态、双击才执行”,从而继续兼容键盘 `Enter` 的选中执行路径并降低误触风险;每条命令项同时会把完整 `command` 文本挂到浏览器原生 tooltip 上,便于在名称或命令被截断时直接 hover 核对完整内容。`Terminal.vue` 现在会跟踪 xterm 相对底部的视口偏移与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复;当隐藏标签在后台持续追加日志时,重新激活会基于“距底部偏移”而不是过期的绝对行号恢复 viewport,避免用户继续向下滚动时无法回到底部。组件同时继续在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`session.store` 当前会为同一 SSH 连接下的新终端分配递增的 `terminalIndex`。当前顶部 `TerminalTabBar.vue` 已改为服务器级入口:SSH 项只负责在不同服务器之间切换,全局 `+` 继续负责选择其他服务器;同一服务器下的多个终端则下沉到 `LayoutRenderer.vue` 的终端面板内部,以次级标签条承载切换、关闭和新增,从而让“进入服务器后再管理该服务器的多个终端”成为主要交互模型。服务器组头现在除主点击切换外,还额外提供了一个 hover 后出现的 `X` 按钮,点击后会复用既有 `session:close` 事件逐个关闭该 `connectionId` 下的全部终端。当前终端标签右键菜单继续复用 `WorkspaceView.vue` 中转的会话关闭链路,除关闭当前、关闭其他、关闭左右侧外,也支持直接触发“关闭全部”来清空当前工作区中的全部终端标签。连接新增弹窗中的脚本模式则继续由 `useAddConnectionForm.ts` 统一清洗输入:会先剔除空行、Markdown 代码围栏行,再按单引号/双引号感知切分参数,并去掉成对包裹值的外层引号,避免像 `-p '$Moka1998A'` 这样的输入把 `'` 一并保存。`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,当前左侧进一步支持基于标签名路径分隔符推导的多级标签树、树节点展开状态持久化、分组 scope 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;近期又补上了独立的左侧树搜索、命中节点及祖先路径过滤、命中链路自动展开、节点计数高亮,以及更接近资源管理器的树头部布局;本轮继续为树节点加入 hover 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单(编辑/测试/克隆/删除);连接页顶部工具条当前又补上了独立“标签管理”入口,打开 `ManageConnectionTagsModal.vue` 后可按标签名搜索、多选、批量删除标签,并通过显式危险开关决定删除标签时是否连带删除命中的连接;`tags.store.ts` 在该链路里会统一刷新标签与连接缓存,而 `ConnectionsView.vue` 会在当前 scope 指向已删标签或分组时自动回退到 `all`。`FileManager.vue` 当前已进一步收敛为固定 `/` 根节点的单栏资源管理器树,组件加载时会优先拉取 `/` 目录,树中按“目录在前、文件在后”同时显示目录和文件节点,点击目录只展开与聚焦,点击文件则沿用现有工作区文件打开链路;文件右键菜单链路则已补齐图标化菜单结构、危险态删除项、终端子菜单(执行 `cd` 命令到终端 / 新建终端到当前目录)、复制文件名与复制绝对路径等动作,并继续复用现有下载、权限、新建、上传和删除逻辑,同时又新增了独立“上传文件夹”入口:前端会先将本地目录打包为 zip,再复用现有 `sftp:upload` 链路上传,并在上传成功后自动调用远端解压、尝试清理临时压缩包;外部拖拽文件或目录上传时,则会按鼠标当前悬停的目录作为目标路径,其中目录同样走“先压缩再上传”的路径,从而显著降低小文件很多时的扫描与上传耗时;本轮又补上了拖拽上传前的目标路径确认、桌面端右键子菜单点击展开,以及目录删除时“仅删空目录 / 强制递归删除”的显式二选一;当前右键菜单的关闭职责已经收敛到 `FileManagerContextMenu.vue` 组件层处理,`useFileManagerContextMenu.ts` 不再额外注册捕获阶段的全局点击关闭监听,以避免“终端 / 上传 / 压缩”等带子菜单项在展开或点击前被提前关闭;同时 `useSftpActions.ts` 会在删除目录后自动回退当前或待加载的失效路径,避免文件树持续对已删除目录刷出 `No such file`。样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。 **结果**: 页面逻辑分散在 `views/`、`components/`、`stores/` 与 `composables/`,其中工作区终端行为和标签交互优先落在 `session.store.ts`、`session/actions/sessionActions.ts`、`session/getters.ts`、`TerminalTabBar.vue`、`WorkspaceView.vue`、`Terminal.vue` 与相关 locale 文件。 ### 仪表盘总览 @@ -58,6 +58,6 @@ ### 状态监控卡片 **条件**: 用户在 `/workspace` 右侧状态监控面板查看服务器资源状态。 -**行为**: `StatusMonitor.vue` 当前进一步重构为深色监控台式布局:顶部先展示系统信息、网卡与可复制 IP 的信息条,随后通过横向资源监控条汇总 CPU / 内存 / Swap / 磁盘占比,再把内存、网络和磁盘拆成三块独立监控卡片。内存卡片继续展示总量、已用、缓存、空闲和环形占比;网络卡片改为分别展示下载/上传瞬时速率、累计流量和相对负载条;磁盘卡片则展示设备名、文件系统、挂载点、纵向占比槽以及读写速率和容量信息。`StatusCharts.vue` 也同步改为与主卡片同风格的深色 CPU / 网络趋势面板。整组布局同时使用容器断点与媒体断点,在窄侧栏下优先单列堆叠,在更宽工作区中再切换为多列。 -**结果**: 状态监控从“卡片化资源块”进一步升级为“统一风格的响应式监控台”,既接近用户给定的高密度服务器面板参考图,又保持与现有组件数据结构和后端字段完全一致。 +**行为**: `StatusMonitor.vue` 当前继续向“服务器监控小屏”风格收紧:默认视图在顶部信息区之外,补充了服务器时区和运行时间,并新增“进程管理”概览卡片,直接展示总进程数、运行中、休眠中以及若干高占用进程预览;点击“查看全部”后会打开新的 `ProcessManagerModal.vue`。该 modal 采用深色控制台式表格布局,支持搜索 PID / 用户 / 命令、自动刷新、手动刷新,以及对单个进程执行“结束”或“强制结束”操作,并通过当前活动 SSH 会话的 `wsManager` 与后端 `process:list` / `process:signal` 消息交互。 +**结果**: 前端状态监控形成了“默认小屏概览 + 独立进程管理页”的双层结构:默认面板更贴近用户参考图,而完整进程管理又不会挤占侧栏监控本体。 diff --git a/packages/backend/src/services/status-monitor.service.ts b/packages/backend/src/services/status-monitor.service.ts index 0062a98..f49cc79 100644 --- a/packages/backend/src/services/status-monitor.service.ts +++ b/packages/backend/src/services/status-monitor.service.ts @@ -31,6 +31,21 @@ interface ServerStatus { netInterface?: string; osName?: string; loadAvg?: number[]; + timezone?: string; + uptimeSeconds?: number; + processTotal?: number; + processRunning?: number; + processSleeping?: number; + topProcesses?: Array<{ + pid: number; + user: string; + state: string; + cpu: number; + memPercent: number; + memMb: number; + startedAt: string; + command: string; + }>; timestamp: number; } @@ -50,6 +65,20 @@ interface DiskIoStats { const previousNetStats = new Map(); const previousDiskStats = new Map(); +const monthMap: Record = { + Jan: '01', + Feb: '02', + Mar: '03', + Apr: '04', + May: '05', + Jun: '06', + Jul: '07', + Aug: '08', + Sep: '09', + Oct: '10', + Nov: '11', + Dec: '12', +}; export class StatusMonitorService { private clientStates: Map; @@ -121,6 +150,8 @@ export class StatusMonitorService { status.osName = nameMatch ? nameMatch[1] : (osReleaseOutput.match(/^NAME="?([^"]+)"?/m)?.[1] ?? 'Unknown'); } catch (err) { /* noop */ } + await this.collectSystemTimeStatus(sshClient, status); + await this.collectCpuStatus(sshClient, status); await this.collectMemoryStatus(sshClient, status); @@ -163,6 +194,7 @@ export class StatusMonitorService { } catch (err) { /* noop */ } await this.collectNetworkStatus(sshClient, sessionId, timestamp, status); + await this.collectProcessSummary(sshClient, status); } catch (error) { console.error(`[StatusMonitor ${sessionId}] General error fetching server status:`, error); } @@ -191,6 +223,27 @@ export class StatusMonitorService { status.cpuCores = await this.resolveCpuCoreCount(sshClient); } + private async collectSystemTimeStatus(sshClient: Client, status: Partial): Promise { + try { + const [offsetOutput, timezoneOutput, uptimeOutput] = await Promise.all([ + this.executeSshCommand(sshClient, `date +"%z"`), + this.executeSshCommand(sshClient, `date +"%Z"`), + this.executeSshCommand(sshClient, `cat /proc/uptime | awk '{print int($1)}'`), + ]); + + const offset = offsetOutput.trim(); + const timezone = timezoneOutput.trim(); + if (offset || timezone) { + status.timezone = `${offset ? `GMT${offset}` : ''}${offset && timezone ? ' ' : ''}${timezone}`.trim(); + } + + const uptimeSeconds = parseInt(uptimeOutput.trim(), 10); + if (!isNaN(uptimeSeconds) && uptimeSeconds >= 0) { + status.uptimeSeconds = uptimeSeconds; + } + } catch (err) { /* noop */ } + } + private async resolveCpuCoreCount(sshClient: Client): Promise { const parseCpuCount = (raw?: string): number | undefined => { if (!raw) { @@ -441,6 +494,58 @@ export class StatusMonitorService { } catch (err) { /* noop */ } } + private async collectProcessSummary(sshClient: Client, status: Partial): Promise { + const processListCommand = `ps -eo pid=,user=,state=,pcpu=,pmem=,rss=,lstart=,args= --sort=-pcpu | awk 'NR<=5{cmd=""; for(i=12;i<=NF;i++) cmd=cmd (i==12?"":" ") $i; print $1 "\\t" $2 "\\t" $3 "\\t" $4 "\\t" $5 "\\t" $6 "\\t" $7 " " $8 " " $9 " " $10 " " $11 "\\t" cmd}'`; + const processSummaryCommand = `ps -eo state= | awk 'BEGIN{total=0; running=0; sleeping=0} {state=substr($1,1,1); total++; if(state=="R") running++; if(state=="S" || state=="D" || state=="I") sleeping++;} END {printf "%d\\t%d\\t%d", total, running, sleeping}'`; + + try { + const [processListOutput, processSummaryOutput] = await Promise.all([ + this.executeSshCommand(sshClient, processListCommand), + this.executeSshCommand(sshClient, processSummaryCommand), + ]); + + const summaryParts = processSummaryOutput.trim().split('\t'); + if (summaryParts.length >= 3) { + status.processTotal = parseInt(summaryParts[0], 10) || 0; + status.processRunning = parseInt(summaryParts[1], 10) || 0; + status.processSleeping = parseInt(summaryParts[2], 10) || 0; + } + + status.topProcesses = processListOutput + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(line => { + const [pidText, user, state, cpuText, memPercentText, rssKbText, startedAtRaw, command] = line.split('\t'); + const pid = parseInt(pidText, 10); + const cpu = parseFloat(cpuText); + const memPercent = parseFloat(memPercentText); + const rssKb = parseInt(rssKbText, 10); + + if (!Number.isInteger(pid) || !user || !state || Number.isNaN(cpu) || Number.isNaN(memPercent) || Number.isNaN(rssKb)) { + return null; + } + + const startedAtParts = startedAtRaw.trim().split(/\s+/); + const month = monthMap[startedAtParts[1]] ?? startedAtParts[1]; + const day = (startedAtParts[2] ?? '').padStart(2, '0'); + const time = startedAtParts[3] ?? ''; + + return { + pid, + user, + state: state.slice(0, 1).toUpperCase(), + cpu: Number(cpu.toFixed(1)), + memPercent: Number(memPercent.toFixed(1)), + memMb: Number((rssKb / 1024).toFixed(1)), + startedAt: month && day && time ? `${month}-${day} ${time}` : startedAtRaw.trim(), + command: command?.trim() || '-', + }; + }) + .filter((item): item is NonNullable => item !== null); + } catch (err) { /* noop */ } + } + private async parseProcNetDev(sshClient: Client): Promise { try { const output = await this.executeSshCommand(sshClient, 'cat /proc/net/dev'); diff --git a/packages/backend/src/websocket/connection.ts b/packages/backend/src/websocket/connection.ts index 29d7faa..3012eec 100644 --- a/packages/backend/src/websocket/connection.ts +++ b/packages/backend/src/websocket/connection.ts @@ -41,6 +41,10 @@ import { handleDockerCommand, handleDockerGetStats } from './handlers/docker.handler'; +import { + handleProcessList, + handleProcessSignal, +} from './handlers/process.handler'; import { handleSftpOperation, handleSftpUploadStart, @@ -104,6 +108,12 @@ export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendServ case 'docker:get_stats': await handleDockerGetStats(ws, sessionId, payload); break; + case 'process:list': + await handleProcessList(ws, sessionId, payload); + break; + case 'process:signal': + await handleProcessSignal(ws, sessionId, payload); + break; // SFTP Cases (generic operations) case 'sftp:readdir': @@ -471,4 +481,4 @@ export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendServ }); console.log('WebSocket connection handler initialized, including SshSuspendService event listener.'); -} \ No newline at end of file +} diff --git a/packages/backend/src/websocket/handlers/process.handler.ts b/packages/backend/src/websocket/handlers/process.handler.ts new file mode 100644 index 0000000..1b33c8f --- /dev/null +++ b/packages/backend/src/websocket/handlers/process.handler.ts @@ -0,0 +1,261 @@ +import WebSocket from 'ws'; +import type { ClientChannel } from 'ssh2'; +import type { AuthenticatedWebSocket } from '../types'; +import { clientStates } from '../state'; + +export interface RemoteProcessInfo { + pid: number; + user: string; + state: string; + cpu: number; + memPercent: number; + memMb: number; + startedAt: string; + command: string; +} + +export interface RemoteProcessSummary { + total: number; + running: number; + sleeping: number; +} + +interface ExecResult { + stdout: string; + stderr: string; + code: number; +} + +const PROCESS_LIST_LIMIT_DEFAULT = 200; + +const buildProcessListCommand = (limit = PROCESS_LIST_LIMIT_DEFAULT): string => { + const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(500, Math.floor(limit)) : PROCESS_LIST_LIMIT_DEFAULT; + return `ps -eo pid=,user=,state=,pcpu=,pmem=,rss=,lstart=,args= --sort=-pcpu | awk 'NR<=${safeLimit}{cmd=\"\"; for(i=12;i<=NF;i++) cmd=cmd (i==12?\"\":\" \") $i; print $1 "\\t" $2 "\\t" $3 "\\t" $4 "\\t" $5 "\\t" $6 "\\t" $7 " " $8 " " $9 " " $10 " " $11 "\\t" cmd}'`; +}; + +const PROCESS_SUMMARY_COMMAND = `ps -eo state= | awk 'BEGIN{total=0; running=0; sleeping=0} {state=substr($1,1,1); total++; if(state=="R") running++; if(state=="S" || state=="D" || state=="I") sleeping++;} END {printf "%d\\t%d\\t%d", total, running, sleeping}'`; + +const monthMap: Record = { + Jan: '01', + Feb: '02', + Mar: '03', + Apr: '04', + May: '05', + Jun: '06', + Jul: '07', + Aug: '08', + Sep: '09', + Oct: '10', + Nov: '11', + Dec: '12', +}; + +const formatStartedAt = (raw: string): string => { + const parts = raw.trim().split(/\s+/); + if (parts.length < 5) { + return raw.trim(); + } + + const [, month, day, time] = parts; + const monthValue = monthMap[month] ?? month; + const dayValue = day.padStart(2, '0'); + return `${monthValue}-${dayValue} ${time}`; +}; + +export const parseRemoteProcessList = (output: string): RemoteProcessInfo[] => { + return output + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(line => { + const [pidText, user, state, cpuText, memPercentText, rssKbText, startedAtRaw, command] = line.split('\t'); + const pid = parseInt(pidText, 10); + const cpu = parseFloat(cpuText); + const memPercent = parseFloat(memPercentText); + const rssKb = parseInt(rssKbText, 10); + + if (!Number.isInteger(pid) || !user || !state || Number.isNaN(cpu) || Number.isNaN(memPercent) || Number.isNaN(rssKb)) { + return null; + } + + return { + pid, + user, + state: state.slice(0, 1).toUpperCase(), + cpu: Number(cpu.toFixed(1)), + memPercent: Number(memPercent.toFixed(1)), + memMb: Number((rssKb / 1024).toFixed(1)), + startedAt: formatStartedAt(startedAtRaw), + command: command?.trim() || '-', + }; + }) + .filter((item): item is RemoteProcessInfo => item !== null); +}; + +export const parseRemoteProcessSummary = (output: string): RemoteProcessSummary => { + const [totalText, runningText, sleepingText] = output.trim().split('\t'); + return { + total: Number.parseInt(totalText, 10) || 0, + running: Number.parseInt(runningText, 10) || 0, + sleeping: Number.parseInt(sleepingText, 10) || 0, + }; +}; + +const executeSshCommand = (channelOwner: AuthenticatedWebSocket, command: string): Promise => { + const sessionId = channelOwner.sessionId; + if (!sessionId) { + throw new Error('缺少会话 ID'); + } + + const state = clientStates.get(sessionId); + if (!state?.sshClient) { + throw new Error('SSH 连接未就绪'); + } + + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let exitCode = 0; + + state.sshClient.exec(command, (err, stream: ClientChannel) => { + if (err) { + reject(err); + return; + } + + stream.on('close', (code?: number) => { + resolve({ + stdout: stdout.trim(), + stderr: stderr.trim(), + code: typeof code === 'number' ? code : exitCode, + }); + }); + + stream.on('exit', (code?: number) => { + if (typeof code === 'number') { + exitCode = code; + } + }); + + stream.on('data', (data: Buffer) => { + stdout += data.toString('utf8'); + }); + + stream.stderr.on('data', (data: Buffer) => { + stderr += data.toString('utf8'); + }); + }); + }); +}; + +export const fetchRemoteProcessSnapshot = async ( + ws: AuthenticatedWebSocket, + limit = PROCESS_LIST_LIMIT_DEFAULT, +): Promise<{ processes: RemoteProcessInfo[]; summary: RemoteProcessSummary }> => { + const [listResult, summaryResult] = await Promise.all([ + executeSshCommand(ws, buildProcessListCommand(limit)), + executeSshCommand(ws, PROCESS_SUMMARY_COMMAND), + ]); + + if (listResult.code !== 0 && listResult.stderr) { + throw new Error(listResult.stderr); + } + + if (summaryResult.code !== 0 && summaryResult.stderr) { + throw new Error(summaryResult.stderr); + } + + return { + processes: parseRemoteProcessList(listResult.stdout), + summary: parseRemoteProcessSummary(summaryResult.stdout), + }; +}; + +export const handleProcessList = async (ws: AuthenticatedWebSocket, sessionId: string | undefined, payload?: { limit?: number }) => { + try { + if (!sessionId) { + throw new Error('缺少活动会话'); + } + + const { processes, summary } = await fetchRemoteProcessSnapshot(ws, payload?.limit); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'process:list:response', + sessionId, + payload: { + processes, + total: summary.total, + running: summary.running, + sleeping: summary.sleeping, + requestedAt: Date.now(), + }, + })); + } + } catch (error: any) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'process:list:error', + sessionId, + payload: { + message: error.message || '获取进程列表失败', + }, + })); + } + } +}; + +export const handleProcessSignal = async ( + ws: AuthenticatedWebSocket, + sessionId: string | undefined, + payload?: { pid?: number; signal?: 'TERM' | 'KILL' }, +) => { + const pid = Number(payload?.pid); + const signal = payload?.signal === 'KILL' ? 'KILL' : 'TERM'; + + if (!sessionId || !Number.isInteger(pid) || pid <= 0) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'process:signal:response', + sessionId, + payload: { + pid, + signal, + success: false, + error: '无效的 PID 或会话', + }, + })); + } + return; + } + + try { + const result = await executeSshCommand(ws, `kill -${signal} ${pid}`); + const success = result.code === 0 && !result.stderr; + + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'process:signal:response', + sessionId, + payload: { + pid, + signal, + success, + error: success ? undefined : (result.stderr || `发送 ${signal} 信号失败`), + }, + })); + } + } catch (error: any) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'process:signal:response', + sessionId, + payload: { + pid, + signal, + success: false, + error: error.message || `发送 ${signal} 信号失败`, + }, + })); + } + } +}; diff --git a/packages/backend/src/websocket/types.ts b/packages/backend/src/websocket/types.ts index cc96dab..1350734 100644 --- a/packages/backend/src/websocket/types.ts +++ b/packages/backend/src/websocket/types.ts @@ -61,6 +61,21 @@ export interface DockerContainer { Labels: Record; stats?: DockerStats | null; // 可选的 stats 字段 } + +export interface ProcessListRequest { + type: 'process:list'; + payload?: { + limit?: number; + }; +} + +export interface ProcessSignalRequest { + type: 'process:signal'; + payload: { + pid: number; + signal: 'TERM' | 'KILL'; + }; +} // --- SSH Suspend Mode WebSocket Message Types --- // Client -> Server @@ -294,4 +309,4 @@ export interface SftpUploadProgressPayload { bytesWritten: number; totalSize: number; progress: number; // Calculated percentage (0-100) -} \ No newline at end of file +} diff --git a/packages/frontend/src/components/GlobalConnectionQuickSearch.vue b/packages/frontend/src/components/GlobalConnectionQuickSearch.vue index 7e18d68..fa32d58 100644 --- a/packages/frontend/src/components/GlobalConnectionQuickSearch.vue +++ b/packages/frontend/src/components/GlobalConnectionQuickSearch.vue @@ -23,7 +23,9 @@ const inputRef = ref(null); const query = ref(''); const selectedIndex = ref(0); -const results = computed(() => searchConnections(props.connections, query.value, 8)); +const results = computed(() => searchConnections(props.connections, query.value, 8, { + getAdditionalFields: getConnectionTagNames, +})); watch(results, async (nextResults) => { if (nextResults.length === 0) { diff --git a/packages/frontend/src/components/ProcessManagerModal.vue b/packages/frontend/src/components/ProcessManagerModal.vue new file mode 100644 index 0000000..34e3b7c --- /dev/null +++ b/packages/frontend/src/components/ProcessManagerModal.vue @@ -0,0 +1,578 @@ + + + + + diff --git a/packages/frontend/src/components/StatusMonitor.vue b/packages/frontend/src/components/StatusMonitor.vue index 6c0eeb3..c5c88a7 100644 --- a/packages/frontend/src/components/StatusMonitor.vue +++ b/packages/frontend/src/components/StatusMonitor.vue @@ -205,6 +205,52 @@ + +
+
+
+ + + +
+
{{ t('statusMonitor.processManager.title') }}
+

{{ t('statusMonitor.processManager.subtitle') }}

+
+
+ +
+ +
+
+ {{ t('statusMonitor.processManager.total') }} + {{ processTotalDisplay }} +
+
+ {{ t('statusMonitor.processManager.running') }} + {{ processRunningDisplay }} +
+
+ {{ t('statusMonitor.processManager.sleeping') }} + {{ processSleepingDisplay }} +
+
+ +
+
+
+ PID {{ item.pid }} + {{ item.cpu.toFixed(1) }}% + {{ formatProcessMemory(item.memMb) }} +
+
{{ item.command }}
+
+
+
+ {{ t('statusMonitor.processManager.empty') }} +
+
+ + @@ -220,12 +272,13 @@ import { computed, ref, watch, type CSSProperties, type PropType } from 'vue'; import { useI18n } from 'vue-i18n'; import { storeToRefs } from 'pinia'; +import ProcessManagerModal from './ProcessManagerModal.vue'; import StatusCharts from './StatusCharts.vue'; import { useSessionStore } from '../stores/session.store'; import { useSettingsStore } from '../stores/settings.store'; import { useConnectionsStore } from '../stores/connections.store'; import { useUiNotificationsStore } from '../stores/uiNotifications.store'; -import type { ServerStatus } from '../types/server.types'; +import type { ProcessListItem, ServerStatus } from '../types/server.types'; interface MonitorMetaItem { key: string; @@ -241,6 +294,7 @@ const connectionsStore = useConnectionsStore(); const uiNotificationsStore = useUiNotificationsStore(); const { sessions } = storeToRefs(sessionStore); const { statusMonitorShowIpBoolean } = storeToRefs(settingsStore); +const isProcessManagerVisible = ref(false); const props = defineProps({ activeSessionId: { @@ -265,6 +319,10 @@ const displaySwapPercent = computed(() => clampPercent(currentServerStatus.value const displayMemoryPercent = computed(() => clampPercent(currentServerStatus.value?.memPercent)); const displayDiskPercent = computed(() => clampPercent(currentServerStatus.value?.diskPercent)); const currentStatusError = computed(() => currentSessionState.value?.statusMonitorManager?.statusError?.value ?? null); +const timezoneDisplay = computed(() => currentServerStatus.value?.timezone || t('statusMonitor.notAvailable')); +const processTotalDisplay = computed(() => currentServerStatus.value?.processTotal ?? 0); +const processRunningDisplay = computed(() => currentServerStatus.value?.processRunning ?? 0); +const processSleepingDisplay = computed(() => currentServerStatus.value?.processSleeping ?? 0); const cachedCpuModel = ref(null); const cachedCpuCores = ref(null); @@ -340,6 +398,34 @@ const formatMemorySize = (mb?: number): string => { return `${(mb / 1024).toFixed(1)} ${t('statusMonitor.gigaBytes')}`; }; +const formatUptime = (seconds?: number): string => { + if (seconds === undefined || seconds === null || !Number.isFinite(seconds) || seconds < 0) { + return t('statusMonitor.notAvailable'); + } + + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (days > 0) { + return `${days}${t('statusMonitor.uptimeDaySuffix')} ${hours}${t('statusMonitor.uptimeHourSuffix')}`; + } + if (hours > 0) { + return `${hours}${t('statusMonitor.uptimeHourSuffix')} ${minutes}${t('statusMonitor.uptimeMinuteSuffix')}`; + } + return `${minutes}${t('statusMonitor.uptimeMinuteSuffix')}`; +}; + +const formatProcessMemory = (mb?: number): string => { + if (mb === undefined || mb === null || !Number.isFinite(mb)) { + return t('statusMonitor.notAvailable'); + } + if (mb < 1024) { + return `${mb.toFixed(1)} M`; + } + return `${(mb / 1024).toFixed(1)} G`; +}; + const swapDisplay = computed(() => { const total = currentServerStatus.value?.swapTotal ?? 0; const used = currentServerStatus.value?.swapUsed ?? 0; @@ -433,6 +519,9 @@ const totalTrafficDisplay = computed(() => { return `${totalDown} / ${totalUp}`; }); +const uptimeDisplay = computed(() => formatUptime(currentServerStatus.value?.uptimeSeconds)); +const topProcessPreview = computed(() => currentServerStatus.value?.topProcesses ?? []); + const maxCurrentNetworkRate = computed(() => { const rxRate = currentServerStatus.value?.netRxRate ?? 0; const txRate = currentServerStatus.value?.netTxRate ?? 0; @@ -450,6 +539,8 @@ const monitorMetaItems = computed(() => { const items: MonitorMetaItem[] = [ { key: 'cpu-model', label: t('statusMonitor.cpuModelLabel'), value: displayCpuModel.value }, { key: 'cpu-cores', label: t('statusMonitor.cpuLabel'), value: displayCpuCores.value }, + { key: 'timezone', label: t('statusMonitor.timezoneLabel'), value: timezoneDisplay.value }, + { key: 'uptime', label: t('statusMonitor.uptimeLabel'), value: uptimeDisplay.value }, { key: 'memory-total', label: t('statusMonitor.memoryCardTitle'), value: memoryTotalDisplay.value }, { key: 'disk-mount', label: t('statusMonitor.diskMountLabel'), value: diskMountPointDisplay.value }, ]; @@ -883,6 +974,10 @@ const copyIpToClipboard = async (ipAddress: string | null) => { color: #fbbf24; } +.monitor-card__icon--process { + color: #fda4af; +} + .monitor-card__title { margin: 0; font-size: 15px; @@ -1086,6 +1181,99 @@ const copyIpToClipboard = async (ipAddress: string | null) => { margin-top: 4px; } +.monitor-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + border-radius: 10px; + border: 1px solid rgba(96, 165, 250, 0.22); + background: rgba(37, 99, 235, 0.18); + padding: 0 12px; + color: #dbeafe; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.process-summary-grid { + display: grid; + gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.process-summary-item { + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.08); + background: rgba(255, 255, 255, 0.03); + padding: 10px; +} + +.process-summary-item__label { + display: block; + color: #8fa0b3; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.process-summary-item__value { + display: block; + margin-top: 6px; + color: #f8fbff; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 20px; + font-weight: 800; +} + +.process-preview-list { + display: grid; + gap: 8px; +} + +.process-preview-item { + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.08); + background: rgba(255, 255, 255, 0.03); + padding: 10px; +} + +.process-preview-item__meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + color: #9fb0bf; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +.process-preview-item__cpu { + color: #bfdbfe; +} + +.process-preview-item__mem { + color: #fde68a; +} + +.process-preview-item__command { + margin-top: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #f8fbff; + font-size: 13px; +} + +.process-preview-empty { + border-radius: 12px; + border: 1px dashed rgba(148, 163, 184, 0.16); + padding: 14px; + color: #8fa0b3; + font-size: 13px; + text-align: center; +} + @container (min-width: 560px) { .monitor-rail { grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1127,6 +1315,10 @@ const copyIpToClipboard = async (ipAddress: string | null) => { grid-column: auto; } + .monitor-card--process { + grid-column: 2 / span 2; + } + .disk-info-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -1163,7 +1355,8 @@ const copyIpToClipboard = async (ipAddress: string | null) => { .memory-stats-grid, .disk-rate-grid, - .disk-info-grid { + .disk-info-grid, + .process-summary-grid { grid-template-columns: 1fr; } } diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 5ea50ff..b323691 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -658,6 +658,11 @@ "totalTrafficLabel": "Traffic Since Boot", "downloadLabel": "Download", "uploadLabel": "Upload", + "timezoneLabel": "Timezone", + "uptimeLabel": "Uptime", + "uptimeDaySuffix": "d", + "uptimeHourSuffix": "h", + "uptimeMinuteSuffix": "m", "notAvailable": "N/A", "bytesPerSecond": "B/s", "kiloBytesPerSecond": "KB/s", @@ -687,7 +692,36 @@ "networkUploadLabelUnit": "Upload ({unit})", "latestCpuValue": "{value}%", "latestMemoryValue": "{value} {unit}", - "latestNetworkValue": "↓ {download} ↑ {upload} {unit}" + "latestNetworkValue": "↓ {download} ↑ {upload} {unit}", + "processManager": { + "title": "Process Manager", + "subtitle": "Summary view / open full manager", + "viewAll": "View All", + "total": "Total", + "running": "Running", + "sleeping": "Sleeping", + "updatedAt": "Updated", + "searchPlaceholder": "Search PID / user / command", + "autoRefresh": "Auto Refresh", + "refresh": "Refresh", + "empty": "No process data", + "loadFailed": "Failed to load process list", + "terminate": "Terminate", + "forceKill": "Force Kill", + "terminateSuccess": "Terminate signal sent to process {pid}", + "forceKillSuccess": "Force kill signal sent to process {pid}", + "signalFailed": "Process {pid} operation failed", + "columns": { + "pid": "PID", + "user": "User", + "state": "State", + "cpu": "CPU", + "mem": "MEM", + "start": "Start", + "command": "Command", + "actions": "Actions" + } + } }, "tags": { "title": "Tag Management", diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index 8a03bb4..2cc571a 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -658,6 +658,11 @@ "totalTrafficLabel": "开机累计流量", "downloadLabel": "下行", "uploadLabel": "上行", + "timezoneLabel": "时区", + "uptimeLabel": "运行时间", + "uptimeDaySuffix": "天", + "uptimeHourSuffix": "时", + "uptimeMinuteSuffix": "分", "notAvailable": "N/A", "bytesPerSecond": "B/s", "kiloBytesPerSecond": "KB/s", @@ -687,7 +692,36 @@ "networkUploadLabelUnit": "上传 ({unit})", "latestCpuValue": "{value}%", "latestMemoryValue": "{value} {unit}", - "latestNetworkValue": "↓ {download} ↑ {upload} {unit}" + "latestNetworkValue": "↓ {download} ↑ {upload} {unit}", + "processManager": { + "title": "进程管理", + "subtitle": "默认概览 / 点击查看全部", + "viewAll": "查看全部", + "total": "总数", + "running": "运行中", + "sleeping": "休眠中", + "updatedAt": "更新于", + "searchPlaceholder": "搜索 PID / 用户 / 命令", + "autoRefresh": "自动刷新", + "refresh": "刷新", + "empty": "暂无进程数据", + "loadFailed": "加载进程列表失败", + "terminate": "结束", + "forceKill": "强制结束", + "terminateSuccess": "已向进程 {pid} 发送结束信号", + "forceKillSuccess": "已向进程 {pid} 发送强制结束信号", + "signalFailed": "进程 {pid} 操作失败", + "columns": { + "pid": "PID", + "user": "用户", + "state": "状态", + "cpu": "CPU", + "mem": "MEM", + "start": "启动时间", + "command": "命令", + "actions": "操作" + } + } }, "tags": { "title": "标签管理", diff --git a/packages/frontend/src/types/server.types.ts b/packages/frontend/src/types/server.types.ts index 3ee7607..bc7779b 100644 --- a/packages/frontend/src/types/server.types.ts +++ b/packages/frontend/src/types/server.types.ts @@ -1,4 +1,15 @@ // 类型定义:用于服务器状态监控数据 (从 useStatusMonitor 迁移) +export interface ProcessListItem { + pid: number; + user: string; + state: string; + cpu: number; + memPercent: number; + memMb: number; + startedAt: string; + command: string; +} + export interface ServerStatus { cpuPercent?: number; cpuCores?: number; @@ -26,6 +37,12 @@ export interface ServerStatus { netTxTotalBytes?: number; // Bytes since boot netInterface?: string; osName?: string; + timezone?: string; + uptimeSeconds?: number; + processTotal?: number; + processRunning?: number; + processSleeping?: number; + topProcesses?: readonly ProcessListItem[]; } // 可以根据需要添加其他与服务器或连接状态相关的类型 diff --git a/packages/frontend/src/types/websocket.types.ts b/packages/frontend/src/types/websocket.types.ts index bf0f7de..e7bc7f2 100644 --- a/packages/frontend/src/types/websocket.types.ts +++ b/packages/frontend/src/types/websocket.types.ts @@ -27,6 +27,21 @@ export interface SftpUploadProgressMessage extends WebSocketMessage { payload: SftpUploadProgressPayload; } +export interface ProcessListResponsePayload { + processes: import('./server.types').ProcessListItem[]; + total: number; + running: number; + sleeping: number; + requestedAt: number; +} + +export interface ProcessSignalResponsePayload { + pid: number; + signal: 'TERM' | 'KILL'; + success: boolean; + error?: string; +} + // --- SSH Suspend Mode WebSocket Message Types --- // 导入挂起会话类型,用于相关消息的 payload diff --git a/packages/frontend/src/utils/connectionSearch.ts b/packages/frontend/src/utils/connectionSearch.ts index b2bbb46..805a3dc 100644 --- a/packages/frontend/src/utils/connectionSearch.ts +++ b/packages/frontend/src/utils/connectionSearch.ts @@ -5,6 +5,10 @@ export interface ConnectionSearchResult { score: number; } +export interface ConnectionSearchOptions { + getAdditionalFields?: (connection: ConnectionInfo) => Array; +} + const normalize = (value: string | null | undefined): string => (value ?? '').trim().toLowerCase(); const getDisplayName = (connection: ConnectionInfo): string => connection.name?.trim() || connection.host; @@ -58,7 +62,11 @@ const getFieldScore = (text: string, query: string): number => { return Math.max(70, 180 - firstMatchIndex * 4 - gapPenalty * 3); }; -const scoreConnection = (connection: ConnectionInfo, query: string): number => { +const scoreConnection = ( + connection: ConnectionInfo, + query: string, + options?: ConnectionSearchOptions, +): number => { const fields: Array<[string, number]> = [ [normalize(connection.name), 40], [normalize(connection.host), 28], @@ -66,6 +74,11 @@ const scoreConnection = (connection: ConnectionInfo, query: string): number => { [normalize(connection.type), 10], ]; + const additionalFields = options?.getAdditionalFields?.(connection) ?? []; + additionalFields.forEach((field) => { + fields.push([normalize(field), 14]); + }); + let bestScore = 0; for (const [field, weight] of fields) { @@ -84,6 +97,7 @@ export const searchConnections = ( connections: ConnectionInfo[], rawQuery: string, limit = 8, + options?: ConnectionSearchOptions, ): ConnectionSearchResult[] => { const query = normalize(rawQuery); @@ -104,7 +118,7 @@ export const searchConnections = ( return connections .map((connection) => ({ connection, - score: scoreConnection(connection, query), + score: scoreConnection(connection, query, options), })) .filter((item) => item.score > 0) .sort((left, right) => {