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.
This commit is contained in:
@@ -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/)
|
||||
|
||||
|
||||
+1
@@ -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"}
|
||||
+117
@@ -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
|
||||
+49
@@ -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` 通过,确认标签搜索接入未引入新的打包错误 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
> 本次只增强“标签参与搜索”,不调整空搜索排序,不扩展到其他连接列表或筛选器。
|
||||
+167
@@ -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 在桌面优先按参考图宽表格布局,小屏允许横向滚动
|
||||
+63
@@ -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 警告,但本次改动未引入新的阻断性错误
|
||||
@@ -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 | ✅完成 |
|
||||
|
||||
@@ -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 会话上下文安全复用进程查询与操作能力。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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<string, { rx: number; tx: number; timestamp: number }>();
|
||||
const previousDiskStats = new Map<string, { device: string; readBytes: number; writeBytes: number; timestamp: number }>();
|
||||
const monthMap: Record<string, string> = {
|
||||
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<string, ClientState>;
|
||||
@@ -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<ServerStatus>): Promise<void> {
|
||||
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<number | undefined> {
|
||||
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<ServerStatus>): Promise<void> {
|
||||
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<typeof item> => item !== null);
|
||||
} catch (err) { /* noop */ }
|
||||
}
|
||||
|
||||
private async parseProcNetDev(sshClient: Client): Promise<NetworkStats | null> {
|
||||
try {
|
||||
const output = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<ExecResult> => {
|
||||
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} 信号失败`,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -61,6 +61,21 @@ export interface DockerContainer {
|
||||
Labels: Record<string, string>;
|
||||
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
|
||||
|
||||
@@ -23,7 +23,9 @@ const inputRef = ref<HTMLInputElement | null>(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) {
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onUnmounted } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSessionStore } from '../stores/session.store';
|
||||
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
|
||||
import type { ProcessListItem } from '../types/server.types';
|
||||
import type { ProcessListResponsePayload, ProcessSignalResponsePayload, WebSocketMessage } from '../types/websocket.types';
|
||||
|
||||
const props = defineProps<{
|
||||
isVisible: boolean;
|
||||
sessionId: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const sessionStore = useSessionStore();
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
const { sessions } = storeToRefs(sessionStore);
|
||||
|
||||
const searchQuery = ref('');
|
||||
const autoRefresh = ref(true);
|
||||
const isLoading = ref(false);
|
||||
const processItems = ref<ProcessListItem[]>([]);
|
||||
const totalProcesses = ref(0);
|
||||
const runningProcesses = ref(0);
|
||||
const sleepingProcesses = ref(0);
|
||||
const lastUpdatedAt = ref<number | null>(null);
|
||||
const processError = ref<string | null>(null);
|
||||
|
||||
let unregisterListResponse: (() => void) | null = null;
|
||||
let unregisterListError: (() => void) | null = null;
|
||||
let unregisterSignalResponse: (() => void) | null = null;
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const currentSession = computed(() => (props.sessionId ? sessions.value.get(props.sessionId) ?? null : null));
|
||||
const currentWsManager = computed(() => currentSession.value?.wsManager ?? null);
|
||||
|
||||
const filteredProcesses = computed(() => {
|
||||
const keyword = searchQuery.value.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return processItems.value;
|
||||
}
|
||||
|
||||
return processItems.value.filter(item => {
|
||||
return (
|
||||
String(item.pid).includes(keyword) ||
|
||||
item.user.toLowerCase().includes(keyword) ||
|
||||
item.command.toLowerCase().includes(keyword)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const formatMemoryMb = (value: number): string => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return t('statusMonitor.notAvailable');
|
||||
}
|
||||
if (value < 1024) {
|
||||
return `${value.toFixed(1)} M`;
|
||||
}
|
||||
return `${(value / 1024).toFixed(1)} G`;
|
||||
};
|
||||
|
||||
const lastUpdatedText = computed(() => {
|
||||
if (!lastUpdatedAt.value) {
|
||||
return t('statusMonitor.notAvailable');
|
||||
}
|
||||
return new Date(lastUpdatedAt.value).toLocaleTimeString();
|
||||
});
|
||||
|
||||
const stateTone = (state: string) => {
|
||||
switch (state) {
|
||||
case 'R':
|
||||
return 'process-state--running';
|
||||
case 'S':
|
||||
case 'D':
|
||||
case 'I':
|
||||
return 'process-state--sleeping';
|
||||
default:
|
||||
return 'process-state--other';
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupHandlers = () => {
|
||||
unregisterListResponse?.();
|
||||
unregisterListError?.();
|
||||
unregisterSignalResponse?.();
|
||||
unregisterListResponse = null;
|
||||
unregisterListError = null;
|
||||
unregisterSignalResponse = null;
|
||||
};
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer);
|
||||
autoRefreshTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const requestProcessList = () => {
|
||||
if (!props.isVisible || !props.sessionId || !currentWsManager.value?.isConnected.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
currentWsManager.value.sendMessage({
|
||||
type: 'process:list',
|
||||
sessionId: props.sessionId,
|
||||
payload: { limit: 200 },
|
||||
});
|
||||
};
|
||||
|
||||
const handleSignal = (pid: number, signal: 'TERM' | 'KILL') => {
|
||||
if (!props.sessionId || !currentWsManager.value?.isConnected.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentWsManager.value.sendMessage({
|
||||
type: 'process:signal',
|
||||
sessionId: props.sessionId,
|
||||
payload: { pid, signal },
|
||||
});
|
||||
};
|
||||
|
||||
const attachHandlers = () => {
|
||||
cleanupHandlers();
|
||||
if (!currentWsManager.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
unregisterListResponse = currentWsManager.value.onMessage('process:list:response', (payload: ProcessListResponsePayload, message?: WebSocketMessage) => {
|
||||
if (message?.sessionId && message.sessionId !== props.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
processItems.value = payload.processes ?? [];
|
||||
totalProcesses.value = payload.total ?? processItems.value.length;
|
||||
runningProcesses.value = payload.running ?? 0;
|
||||
sleepingProcesses.value = payload.sleeping ?? 0;
|
||||
lastUpdatedAt.value = payload.requestedAt ?? Date.now();
|
||||
processError.value = null;
|
||||
isLoading.value = false;
|
||||
});
|
||||
|
||||
unregisterListError = currentWsManager.value.onMessage('process:list:error', (payload: { message?: string }, message?: WebSocketMessage) => {
|
||||
if (message?.sessionId && message.sessionId !== props.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = false;
|
||||
processError.value = payload?.message || t('statusMonitor.processManager.loadFailed');
|
||||
});
|
||||
|
||||
unregisterSignalResponse = currentWsManager.value.onMessage('process:signal:response', (payload: ProcessSignalResponsePayload, message?: WebSocketMessage) => {
|
||||
if (message?.sessionId && message.sessionId !== props.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.success) {
|
||||
uiNotificationsStore.showSuccess(
|
||||
payload.signal === 'KILL'
|
||||
? t('statusMonitor.processManager.forceKillSuccess', { pid: payload.pid })
|
||||
: t('statusMonitor.processManager.terminateSuccess', { pid: payload.pid }),
|
||||
);
|
||||
requestProcessList();
|
||||
return;
|
||||
}
|
||||
|
||||
uiNotificationsStore.showError(payload.error || t('statusMonitor.processManager.signalFailed', { pid: payload.pid }));
|
||||
});
|
||||
};
|
||||
|
||||
const syncAutoRefresh = () => {
|
||||
stopAutoRefresh();
|
||||
if (!props.isVisible || !autoRefresh.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
requestProcessList();
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.isVisible, props.sessionId, currentWsManager.value] as const,
|
||||
([visible, sessionId, wsManager]) => {
|
||||
stopAutoRefresh();
|
||||
cleanupHandlers();
|
||||
|
||||
if (!visible || !sessionId || !wsManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
attachHandlers();
|
||||
requestProcessList();
|
||||
syncAutoRefresh();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(autoRefresh, () => {
|
||||
syncAutoRefresh();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.isVisible,
|
||||
visible => {
|
||||
if (!visible) {
|
||||
stopAutoRefresh();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.isVisible,
|
||||
visible => {
|
||||
if (visible) {
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh();
|
||||
cleanupHandlers();
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isVisible" class="process-modal-overlay" @click.self="closeModal">
|
||||
<div class="process-modal-shell">
|
||||
<button class="process-modal-close" type="button" @click="closeModal" :title="t('common.close', '关闭')">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
|
||||
<div class="process-modal-handle"></div>
|
||||
|
||||
<header class="process-modal-toolbar">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
class="process-modal-search"
|
||||
type="text"
|
||||
:placeholder="t('statusMonitor.processManager.searchPlaceholder')"
|
||||
/>
|
||||
|
||||
<div class="process-modal-controls">
|
||||
<label class="process-auto-refresh">
|
||||
<span>{{ t('statusMonitor.processManager.autoRefresh') }}</span>
|
||||
<input v-model="autoRefresh" type="checkbox" />
|
||||
</label>
|
||||
|
||||
<button class="process-refresh-button" type="button" @click="requestProcessList">
|
||||
<i class="fas fa-rotate-right"></i>
|
||||
<span>{{ t('statusMonitor.processManager.refresh') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="process-modal-summary">
|
||||
<span class="process-summary-pill">{{ t('statusMonitor.processManager.total') }} {{ totalProcesses }}</span>
|
||||
<span class="process-summary-pill process-summary-pill--running">{{ t('statusMonitor.processManager.running') }} {{ runningProcesses }}</span>
|
||||
<span class="process-summary-pill">{{ t('statusMonitor.processManager.sleeping') }} {{ sleepingProcesses }}</span>
|
||||
<span class="process-summary-pill">{{ t('statusMonitor.processManager.updatedAt') }} {{ lastUpdatedText }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="processError" class="process-state process-state--error">
|
||||
{{ processError }}
|
||||
</div>
|
||||
|
||||
<div v-else class="process-table-wrap">
|
||||
<div v-if="!isLoading && filteredProcesses.length === 0" class="process-state">
|
||||
{{ t('statusMonitor.processManager.empty') }}
|
||||
</div>
|
||||
|
||||
<table v-else class="process-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('statusMonitor.processManager.columns.pid') }}</th>
|
||||
<th>{{ t('statusMonitor.processManager.columns.user') }}</th>
|
||||
<th>{{ t('statusMonitor.processManager.columns.state') }}</th>
|
||||
<th>{{ t('statusMonitor.processManager.columns.cpu') }}</th>
|
||||
<th>{{ t('statusMonitor.processManager.columns.mem') }}</th>
|
||||
<th>{{ t('statusMonitor.processManager.columns.start') }}</th>
|
||||
<th>{{ t('statusMonitor.processManager.columns.command') }}</th>
|
||||
<th>{{ t('statusMonitor.processManager.columns.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in filteredProcesses" :key="item.pid">
|
||||
<td class="process-table__mono">{{ item.pid }}</td>
|
||||
<td>{{ item.user }}</td>
|
||||
<td>
|
||||
<span :class="['process-state-pill', stateTone(item.state)]">{{ item.state }}</span>
|
||||
</td>
|
||||
<td class="process-table__mono">{{ item.cpu.toFixed(1) }}%</td>
|
||||
<td class="process-table__mono">{{ formatMemoryMb(item.memMb) }}</td>
|
||||
<td class="process-table__mono">{{ item.startedAt }}</td>
|
||||
<td class="process-table__command" :title="item.command">{{ item.command }}</td>
|
||||
<td>
|
||||
<div class="process-actions">
|
||||
<button class="process-action-button" type="button" @click="handleSignal(item.pid, 'TERM')">
|
||||
{{ t('statusMonitor.processManager.terminate') }}
|
||||
</button>
|
||||
<button class="process-action-button process-action-button--danger" type="button" @click="handleSignal(item.pid, 'KILL')">
|
||||
{{ t('statusMonitor.processManager.forceKill') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.process-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.68);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.process-modal-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
max-height: min(88vh, 760px);
|
||||
width: min(96vw, 1160px);
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: linear-gradient(180deg, rgba(18, 18, 18, 0.98), rgba(12, 12, 12, 0.98));
|
||||
padding: 16px;
|
||||
color: #f8fbff;
|
||||
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.process-modal-handle {
|
||||
align-self: center;
|
||||
width: 52px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.process-modal-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #cbd5e1;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.process-modal-toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.process-modal-search {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 0 14px;
|
||||
color: #f8fbff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.process-modal-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.process-auto-refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #d8e2ea;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.process-refresh-button,
|
||||
.process-action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #f8fbff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.process-refresh-button {
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.process-action-button {
|
||||
min-width: 58px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.process-refresh-button:hover,
|
||||
.process-action-button:hover {
|
||||
border-color: rgba(148, 163, 184, 0.38);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.process-action-button--danger {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.process-modal-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.process-summary-pill {
|
||||
display: inline-flex;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 0 10px;
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.process-summary-pill--running {
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.process-table-wrap {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.process-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 980px;
|
||||
background: rgba(12, 12, 12, 0.96);
|
||||
}
|
||||
|
||||
.process-table th,
|
||||
.process-table td {
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.process-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: rgba(22, 22, 22, 0.98);
|
||||
color: #9fb0bf;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.process-table__mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.process-table__command {
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.process-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.process-state,
|
||||
.process-state-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.process-state {
|
||||
min-height: 180px;
|
||||
color: #9fb0bf;
|
||||
}
|
||||
|
||||
.process-state--error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.process-state-pill {
|
||||
min-width: 36px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.process-state--running {
|
||||
border-color: rgba(34, 197, 94, 0.24);
|
||||
background: rgba(22, 101, 52, 0.26);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.process-state--sleeping {
|
||||
border-color: rgba(37, 99, 235, 0.24);
|
||||
background: rgba(30, 64, 175, 0.22);
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.process-state--other {
|
||||
border-color: rgba(148, 163, 184, 0.24);
|
||||
background: rgba(51, 65, 85, 0.28);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.process-modal-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.process-modal-controls {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -205,6 +205,52 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="monitor-card monitor-card--process">
|
||||
<div class="monitor-card__header">
|
||||
<div class="monitor-card__title-group">
|
||||
<span class="monitor-card__icon monitor-card__icon--process">
|
||||
<i class="fas fa-list-check"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h5 class="monitor-card__title">{{ t('statusMonitor.processManager.title') }}</h5>
|
||||
<p class="monitor-card__subtitle">{{ t('statusMonitor.processManager.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="monitor-action-button" type="button" @click="isProcessManagerVisible = true">
|
||||
{{ t('statusMonitor.processManager.viewAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="process-summary-grid">
|
||||
<div class="process-summary-item">
|
||||
<span class="process-summary-item__label">{{ t('statusMonitor.processManager.total') }}</span>
|
||||
<span class="process-summary-item__value">{{ processTotalDisplay }}</span>
|
||||
</div>
|
||||
<div class="process-summary-item">
|
||||
<span class="process-summary-item__label">{{ t('statusMonitor.processManager.running') }}</span>
|
||||
<span class="process-summary-item__value">{{ processRunningDisplay }}</span>
|
||||
</div>
|
||||
<div class="process-summary-item">
|
||||
<span class="process-summary-item__label">{{ t('statusMonitor.processManager.sleeping') }}</span>
|
||||
<span class="process-summary-item__value">{{ processSleepingDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="topProcessPreview.length > 0" class="process-preview-list">
|
||||
<article v-for="item in topProcessPreview" :key="item.pid" class="process-preview-item">
|
||||
<div class="process-preview-item__meta">
|
||||
<span class="process-preview-item__pid">PID {{ item.pid }}</span>
|
||||
<span class="process-preview-item__cpu">{{ item.cpu.toFixed(1) }}%</span>
|
||||
<span class="process-preview-item__mem">{{ formatProcessMemory(item.memMb) }}</span>
|
||||
</div>
|
||||
<div class="process-preview-item__command" :title="item.command">{{ item.command }}</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="process-preview-empty">
|
||||
{{ t('statusMonitor.processManager.empty') }}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<StatusCharts
|
||||
@@ -213,6 +259,12 @@
|
||||
:active-session-id="activeSessionId"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ProcessManagerModal
|
||||
:is-visible="isProcessManagerVisible"
|
||||
:session-id="activeSessionId"
|
||||
@close="isProcessManagerVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<string | null>(() => 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<string | null>(null);
|
||||
const cachedCpuCores = ref<number | null>(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<readonly ProcessListItem[]>(() => 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<MonitorMetaItem[]>(() => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "标签管理",
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
// 可以根据需要添加其他与服务器或连接状态相关的类型
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,10 @@ export interface ConnectionSearchResult {
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface ConnectionSearchOptions {
|
||||
getAdditionalFields?: (connection: ConnectionInfo) => Array<string | null | undefined>;
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user