diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index d0ae488..daa019a 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -16,6 +16,10 @@ - 方案: [202603250614_terminal-ansi-color-effects](archive/2026-03/202603250614_terminal-ansi-color-effects/) ### 新增 +- **[frontend]**: 将服务器状态中的内存与磁盘区域升级为卡片化监控视图,补齐环形内存占比、磁盘设备信息、读写速率与挂载表格展示 — by yinjianm + - 方案: [202603252200_server-status-memory-disk-cards](archive/2026-03/202603252200_server-status-memory-disk-cards/) +- **[backend]**: 扩展 `StatusMonitorService` 的内存/磁盘采集字段,新增缓存、空闲、挂载点、文件系统类型、磁盘设备与磁盘 I/O 速率 — by yinjianm + - 方案: [202603252200_server-status-memory-disk-cards](archive/2026-03/202603252200_server-status-memory-disk-cards/) - **[frontend]**: 将“黑暗模式”预设与终端默认主题统一调整为黑绿夜间风格 — by yinjianm - 方案: [202603250603_dark-green-night-theme](archive/2026-03/202603250603_dark-green-night-theme/) - **[backend]**: 将终端文字描边与阴影开关的外观默认值改为开启,与前端回退值保持一致 — by yinjianm @@ -24,5 +28,9 @@ - 方案: [202603250636_connections-view-tree-search-redesign](archive/2026-03/202603250636_connections-view-tree-search-redesign/) - **[frontend]**: 为连接管理页补充多级标签树、列头排序和行级更多菜单,并支持分组范围与展开状态持久化 — by yinjianm - 方案: [202603252152_connections-tree-sort-more-menu](archive/2026-03/202603252152_connections-tree-sort-more-menu/) +- **[frontend]**: 为连接管理页补树工具栏与展开/收起控制,并将行内次级操作整理进更完整的更多菜单 — by yinjianm + - 方案: [202603252220_connections-tree-toolbar-menu-polish](archive/2026-03/202603252220_connections-tree-toolbar-menu-polish/) - **[frontend]**: 为同一 SSH 服务器连接补充多终端入口与终端序号标识,默认首次仍只打开一个终端 — by yinjianm - 方案: [202603252207_ssh-connection-multi-terminal](archive/2026-03/202603252207_ssh-connection-multi-terminal/) +- **[frontend]**: 将顶部终端标签栏升级为“服务器组头 + 终端子标签 + 组尾新增按钮”,让同服务器多终端关系更直观 — by yinjianm + - 方案: [202603252229_terminal-tab-group-visual](archive/2026-03/202603252229_terminal-tab-group-visual/) diff --git a/.helloagents/INDEX.md b/.helloagents/INDEX.md index 5c0e47f..5b72f6e 100644 --- a/.helloagents/INDEX.md +++ b/.helloagents/INDEX.md @@ -31,7 +31,7 @@ ```yaml kb_version: 2.3.7 -最后更新: 2026-03-25 22:19 +最后更新: 2026-03-25 22:36 模块数量: 4 待执行方案: 2 ``` diff --git a/.helloagents/archive/2026-03/202603252200_server-status-memory-disk-cards/proposal.md b/.helloagents/archive/2026-03/202603252200_server-status-memory-disk-cards/proposal.md new file mode 100644 index 0000000..13be53c --- /dev/null +++ b/.helloagents/archive/2026-03/202603252200_server-status-memory-disk-cards/proposal.md @@ -0,0 +1,79 @@ +# 变更提案: server-status-memory-disk-cards + +## 元信息 +```yaml +类型: 功能增强 +方案类型: implementation +优先级: P1 +状态: 已完成 +状态说明: 已按参考图补齐服务器状态中的内存卡片和磁盘卡片,并接入真实监控数据 +创建: 2026-03-25 +完成: 2026-03-25 +``` + +--- + +## 1. 需求 +### 背景 +当前服务器状态面板原本只有 CPU、内存、Swap、磁盘进度条,以及 CPU / 网络速度折线图,无法承载参考图中的内存细分项、磁盘设备信息和读写速率展示。 + +### 目标 +- 新增参考图风格的内存监控卡片,展示总量、已用、缓存、空闲和环形占比。 +- 新增参考图风格的磁盘监控卡片,展示设备名、文件系统类型、读写速率以及挂载点/大小/可用/已用率。 +- 保留现有 CPU / 网络曲线和基础状态信息。 + +### 约束条件 +```yaml +范围约束: + - packages/backend/src/services/status-monitor.service.ts + - packages/frontend/src/components/StatusMonitor.vue + - packages/frontend/src/types/server.types.ts + - packages/frontend/src/locales/{zh-CN,en-US,ja-JP}.json +兼容约束: + - 不新增第三方依赖 + - 保留现有 StatusCharts.vue 的 CPU / 网络曲线 +数据约束: + - 新增 memFree、memCached + - 新增 diskAvailable、diskMountPoint、diskFsType、diskDevice、diskReadRate、diskWriteRate +``` + +### 验收标准 +- [x] 状态面板出现参考图风格的内存卡片并展示总量、已用、缓存、空闲 +- [x] 状态面板出现参考图风格的磁盘卡片并展示设备、类型、读写速率、挂载点、大小、可用、已用率 +- [x] 新字段来自实时状态采集链路 +- [x] 前端构建通过 +- [x] 后端 TypeScript 构建通过 + +--- + +## 2. 方案 + +### 技术方案 +- 后端重构 `StatusMonitorService`,在现有 SSH 轮询链路中补齐 `free`、`df`、`findmnt` 与 `/proc/diskstats` 的解析。 +- 前端在 `StatusMonitor.vue` 中以卡片替代简单内存/磁盘进度行,内存使用 CSS 环形图,磁盘使用竖向容量条与明细表格。 +- 新增多语言键值用于内存/磁盘卡片标题、统计标签和表头。 + +### 影响范围 +```yaml +涉及模块: + - frontend + - backend +变更文件: + - packages/backend/src/services/status-monitor.service.ts + - packages/frontend/src/components/StatusMonitor.vue + - packages/frontend/src/types/server.types.ts + - packages/frontend/src/locales/zh-CN.json + - packages/frontend/src/locales/en-US.json + - packages/frontend/src/locales/ja-JP.json +``` + +### 风险评估 +| 风险 | 等级 | 应对 | +|------|------|------| +| Linux 不同发行版的 `free` / `df` / `findmnt` 输出格式差异 | 中 | 采用多层回退解析,失败字段返回 `undefined` | +| 根挂载点对应设备可能是分区或 mapper 设备 | 中 | 优先规整到块设备名,无法精确匹配时 I/O 速率回退为 0 | +| 卡片信息较多可能挤压原状态布局 | 低 | 采用纵向卡片堆叠并保留现有图表区域 | + +### 技术决策 +- `server-status-memory-disk-cards#D001`: 采用卡片化监控视图,而不是继续在折线图区域追加内存/磁盘折线图。 +- `server-status-memory-disk-cards#D002`: 磁盘读写速率基于 `/proc/diskstats` 计算,不引入 `iostat` 等额外依赖。 diff --git a/.helloagents/archive/2026-03/202603252200_server-status-memory-disk-cards/tasks.md b/.helloagents/archive/2026-03/202603252200_server-status-memory-disk-cards/tasks.md new file mode 100644 index 0000000..57a4e97 --- /dev/null +++ b/.helloagents/archive/2026-03/202603252200_server-status-memory-disk-cards/tasks.md @@ -0,0 +1,50 @@ +# 任务清单: server-status-memory-disk-cards + +```yaml +@feature: server-status-memory-disk-cards +@created: 2026-03-25 +@status: completed +@mode: R3 +``` + +## 进度概览 + +| 完成 | 失败 | 跳过 | 总数 | +|------|------|------|------| +| 6 | 0 | 0 | 6 | + +--- + +## 任务列表 + +### 1. 方案与范围确认 +- [√] 1.1 创建服务器状态卡片增强方案包,并锁定为内存卡片、磁盘卡片与实时字段扩展 | depends_on: [] + +### 2. 后端状态采集扩展 +- [√] 2.1 在 `status-monitor.service.ts` 中补齐内存细分字段 `memFree` / `memCached` | depends_on: [1.1] +- [√] 2.2 在 `status-monitor.service.ts` 中补齐磁盘字段 `diskAvailable` / `diskMountPoint` / `diskFsType` / `diskDevice` / `diskReadRate` / `diskWriteRate` | depends_on: [2.1] + +### 3. 前端状态模型与界面实现 +- [√] 3.1 扩展前端 `ServerStatus` 类型与状态监控数据接收链路 | depends_on: [2.2] +- [√] 3.2 在 `StatusMonitor.vue` 中实现参考图风格的内存卡片与磁盘卡片,并保留现有 CPU / 网络图表 | depends_on: [3.1] +- [√] 3.3 补齐多语言文案并处理缺省值显示 | depends_on: [3.2] + +### 4. 验证与知识库同步 +- [√] 4.1 运行前后端构建验证,并同步 `.helloagents` 变更记录 | depends_on: [3.3] + +--- + +## 执行日志 + +| 时间 | 任务 | 状态 | 备注 | +|------|------|------|------| +| 2026-03-25 22:00 | 1.1 | 完成 | 创建 implementation 方案包,确定采用参考图卡片化改造 | +| 2026-03-25 22:18 | 2.1 | 完成 | 后端补齐内存缓存/空闲字段并统一状态采集结构 | +| 2026-03-25 22:24 | 2.2 | 完成 | 后端补齐磁盘可用量、挂载点、类型、设备名与 I/O 速率 | +| 2026-03-25 22:29 | 3.1 | 完成 | 前端 `ServerStatus` 类型补齐新增字段 | +| 2026-03-25 22:33 | 3.2 | 完成 | `StatusMonitor.vue` 改为内存/磁盘卡片布局并保留现有 CPU/网络图表 | +| 2026-03-25 22:37 | 3.3 / 4.1 | 完成 | 补齐中英日文案,前端 `npm run build` 与后端 `npm run build` 通过 | + +## 执行备注 +- 参考图中的内存/磁盘样式已落到状态监控面板主区域。 +- 磁盘元数据存在远端系统差异时,前端会以 `N/A` 或 0 速率优雅降级显示。 diff --git a/.helloagents/archive/2026-03/202603252207_ssh-connection-multi-terminal/.status.json b/.helloagents/archive/2026-03/202603252207_ssh-connection-multi-terminal/.status.json new file mode 100644 index 0000000..a02d09b --- /dev/null +++ b/.helloagents/archive/2026-03/202603252207_ssh-connection-multi-terminal/.status.json @@ -0,0 +1 @@ +{"status":"completed","completed":7,"failed":0,"pending":0,"total":7,"done":7,"percent":100,"current":"已完成 ssh-connection-multi-terminal 并通过前端构建验证","updated_at":"2026-03-25 22:19:31"} diff --git a/.helloagents/archive/2026-03/202603252207_ssh-connection-multi-terminal/proposal.md b/.helloagents/archive/2026-03/202603252207_ssh-connection-multi-terminal/proposal.md new file mode 100644 index 0000000..9ba4eb4 --- /dev/null +++ b/.helloagents/archive/2026-03/202603252207_ssh-connection-multi-terminal/proposal.md @@ -0,0 +1,72 @@ +# 变更提案: ssh-connection-multi-terminal + +## 元信息 +```yaml +类型: 功能增强 +方案类型: implementation +优先级: P1 +状态: 已完成 +状态说明: 已完成实现,前端构建验证通过 +创建: 2026-03-25 +``` + +--- + +## 1. 需求 + +### 背景 +当前工作区顶部标签栏以“全局会话”为主视角。虽然前端和后端都允许对同一 SSH 连接重复创建多个会话,但交互上仍然表现为“打开一个服务器就是一个标签”,缺少“某个服务器连接内部继续新增多个终端”的直观模型,和参考界面中的单连接多终端体验不一致。 + +### 目标 +- 仅针对 `SSH` 连接实现“单个服务器连接下支持多个终端”。 +- 用户首次打开某个服务器时,默认只创建 1 个终端。 +- 在该服务器已打开后,允许继续新增该服务器下的第 2、第 3 ... 个终端。 +- 顶部标签栏交互向“连接内多终端”靠拢,但保持当前项目的整体视觉和工作区结构。 + +### 约束条件 +```yaml +范围约束: 仅改 SSH 会话组织和标签栏交互,不改 RDP/VNC 行为 +架构约束: 复用现有 Vue 3 + Pinia + WebSocket + xterm 链路,不引入新状态库 +协议约束: 不新增后端 SSH 协议消息,优先复用现有“一次会话对应一个 WebSocket/SSH session”模型 +兼容约束: 不破坏现有工作区布局、文件管理、编辑器、SSH 挂起和状态监控能力 +``` + +### 验收标准 +- [ ] 首次点击某个 SSH 连接时,只打开 1 个终端实例 +- [ ] 已打开的 SSH 连接可继续新增同连接下的多个终端实例 +- [ ] 标签栏能清晰区分“连接”与“该连接下的终端序号/实例” +- [ ] 关闭某个终端时,不影响同连接下其他终端继续使用 +- [ ] `packages/frontend` 构建通过,核心类型链闭合 + +--- + +## 2. 方案 + +### 技术方案 +延续现有“一个终端实例对应一个独立 `SessionState` / WebSocket / SSH session”的实现,不修改后端协议和 SSH handler。前端新增“连接分组视角”作为展示层:为每个 SSH 会话补充分组元数据和终端序号,通过新的 getter 将全局会话整理为“按连接聚合的终端组”,再由 `TerminalTabBar` 基于该聚合结果渲染标签。默认点击连接仍只创建一个终端;当当前激活的是 SSH 连接组时,可在标签栏直接为该连接追加新终端。这样既满足单连接多终端体验,又避免重写后端会话模型。 + +### 影响范围 +```yaml +涉及模块: + - frontend: session store、WorkspaceView、TerminalTabBar、i18n、类型定义 + - backend: 原则上无协议改动,仅做兼容性核查 +预计变更文件: 6-10 +``` + +### 风险评估 +| 风险 | 等级 | 应对 | +|------|------|------| +| 全局标签排序逻辑与连接分组排序冲突 | 中 | 将排序收口到 session getter,保留现有 localStorage 顺序语义 | +| 旧代码仍按 `sessionId -> 标签` 直读,导致 UI 状态不一致 | 中 | 统一从 store getter 导出分组标签模型,避免组件各自拼装 | +| 新增同连接终端后命令、文件管理、挂起状态误串组 | 低 | 保持底层仍按独立 `sessionId` 路由,分组只存在于展示层 | + +--- + +## 3. 技术决策 + +### ssh-connection-multi-terminal#D001: 复用现有独立 session 模型,只在前端增加连接分组层 +**日期**: 2026-03-25 +**状态**: ✅采纳 +**决策**: 不改后端 SSH 协议和会话状态结构,继续保持“一个终端实例 = 一个独立 SSH session”,仅在前端用连接分组和终端序号实现“单连接多终端”体验。 +**理由**: 当前后端已经支持同一连接重复建立多个 SSH session,瓶颈在交互组织而非协议能力。前端分组方案改动更小、回滚边界更清晰,也不会引入 RDP/VNC 连带回归。 +**影响**: 主要影响 `session.store`、`TerminalTabBar.vue`、`WorkspaceView.vue` 和相关类型/i18n;后端仅需保持现有多 session 行为不退化。 diff --git a/.helloagents/archive/2026-03/202603252207_ssh-connection-multi-terminal/tasks.md b/.helloagents/archive/2026-03/202603252207_ssh-connection-multi-terminal/tasks.md new file mode 100644 index 0000000..4d9b30c --- /dev/null +++ b/.helloagents/archive/2026-03/202603252207_ssh-connection-multi-terminal/tasks.md @@ -0,0 +1,52 @@ +# 任务清单: ssh-connection-multi-terminal + +```yaml +@feature: ssh-connection-multi-terminal +@created: 2026-03-25 +@status: completed +@mode: R3 +``` + +## 进度概览 + +| 完成 | 失败 | 跳过 | 总数 | +|------|------|------|------| +| 7 | 0 | 0 | 7 | + +--- + +## 任务列表 + +### 1. 方案包与会话模型梳理 + +- [√] 1.1 创建单连接多终端方案包并固化前端分组决策 | depends_on: [] +- [√] 1.2 梳理现有 `session` 状态、标签排序和 SSH 连接入口的复用边界 | depends_on: [1.1] + +### 2. 前端会话分组与标签栏改造 + +- [√] 2.1 扩展 `SessionState` / getter,补充连接分组与终端序号信息 | depends_on: [1.2] +- [√] 2.2 改造 `TerminalTabBar.vue`,支持按连接展示并为当前 SSH 连接新增终端 | depends_on: [2.1] +- [√] 2.3 调整 `WorkspaceView.vue` 与会话入口逻辑,保证默认仅创建一个终端、追加时显式新增 | depends_on: [2.2] + +### 3. 文案与验证 + +- [√] 3.1 补充前端 i18n / 提示文案,并核查 RDP/VNC 不受影响 | depends_on: [2.3] +- [√] 3.2 运行前端构建验证并同步 `.helloagents` 文档与变更记录 | depends_on: [3.1] + +--- + +## 执行日志 + +| 时间 | 任务 | 状态 | 备注 | +|------|------|------|------| +| 2026-03-25 22:07 | 1.1 / 1.2 | 完成 | 创建 implementation 方案包,并确认复用现有独立 SSH session 模型 | +| 2026-03-25 22:14 | 2.1 / 2.2 / 2.3 | 完成 | 为 session 增加终端序号,顶部标签栏拆分“新增终端”和“选择服务器”入口 | +| 2026-03-25 22:19 | 3.1 / 3.2 | 完成 | 补充中英日文案,执行 `npm --prefix packages/frontend run build` 通过,并同步知识库文档 | + +--- + +## 执行备注 + +- 本次只做 SSH,不扩展到 RDP/VNC。 +- 组内每个终端仍对应独立 `sessionId`,避免影响现有 WebSocket / SFTP / 编辑器链路。 +- 若现有标签顺序持久化与连接分组发生冲突,优先保证同连接终端的可识别性和可关闭性。 diff --git a/.helloagents/archive/2026-03/202603252220_connections-tree-toolbar-menu-polish/.status.json b/.helloagents/archive/2026-03/202603252220_connections-tree-toolbar-menu-polish/.status.json new file mode 100644 index 0000000..9aa6d3c --- /dev/null +++ b/.helloagents/archive/2026-03/202603252220_connections-tree-toolbar-menu-polish/.status.json @@ -0,0 +1 @@ +{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"已完成连接管理页树工具栏与更多菜单整理,并完成构建和启动验证","updated_at":"2026-03-25 22:29:00"} diff --git a/.helloagents/archive/2026-03/202603252220_connections-tree-toolbar-menu-polish/proposal.md b/.helloagents/archive/2026-03/202603252220_connections-tree-toolbar-menu-polish/proposal.md new file mode 100644 index 0000000..a0546d3 --- /dev/null +++ b/.helloagents/archive/2026-03/202603252220_connections-tree-toolbar-menu-polish/proposal.md @@ -0,0 +1,65 @@ +# 变更提案: connections-tree-toolbar-menu-polish + +## 元信息 +```yaml +类型: 功能增强 +方案类型: implementation +优先级: P1 +状态: 已完成 +状态说明: 已完成连接管理页树工具栏与行内更多菜单整理,并完成构建及启动验证 +创建: 2026-03-25 +``` + +--- + +## 1. 需求 + +### 背景 +连接管理页上一轮已经补上多级标签树、列头排序和基础版“更多”菜单,但和参考图相比,左侧树仍缺少操作性工具栏,展开/折叠控制也不够直接;右侧列表的行内操作区仍显得偏拥挤,没有真正形成“连接主按钮 + 次级动作入菜单”的层次。 + +### 目标 +- 为左侧标签树补充更接近管理面板的树工具栏。 +- 提供明确的展开全部 / 收起全部控制。 +- 保留“连接”为主按钮,将编辑、测试、克隆、删除等操作整合进更完整的“更多”菜单。 + +### 约束条件 +```yaml +范围约束: 优先限制在 ConnectionsView.vue,不改后端接口和 store 结构 +兼容约束: 保留现有连接、编辑、测试、批量编辑和批量删除能力 +交互约束: 行内主操作只保留“连接”,其余动作进入更多菜单 +视觉约束: 延续当前黑绿主题和现有双栏管理台风格 +``` + +### 验收标准 +- [ ] 左侧树区域出现工具栏,并提供至少展开全部、收起全部两个控制 +- [ ] 行内主按钮只保留“连接”,编辑、测试、克隆、删除统一纳入更多菜单 +- [ ] 更多菜单在 SSH / 非 SSH 连接上都保持合理动作集合与禁用状态 +- [ ] 前端构建通过 + +--- + +## 2. 方案 + +### 技术方案 +在 `ConnectionsView.vue` 中补充树工具栏状态与辅助函数,基于现有 `tagTreeNodes` 统一派生全部可展开节点,实现展开全部/收起全部和范围清理;右侧结果区压缩主操作区,仅保留连接按钮,将测试、编辑、克隆、删除以受控下拉菜单方式集中呈现,并根据连接类型和批量模式控制可用性。 + +### 影响范围 +```yaml +涉及模块: + - frontend: ConnectionsView.vue +预计变更文件: 1 +``` + +### 风险评估 +| 风险 | 等级 | 应对 | +|------|------|------| +| 树工具栏和现有折叠按钮语义重叠 | 低 | 保留面板级收起,新增树级展开/收起,职责区分为“区域显隐”与“树层级控制” | +| 菜单动作收拢后可发现性下降 | 中 | 保留醒目的“更多”按钮文案和图标,并把最常用动作排在菜单前部 | +| 批量模式下菜单误触发行选择 | 低 | 菜单按钮和菜单项统一 stopPropagation,并在批量模式下明确禁用 | + +### 实施结果 +- `ConnectionsView.vue` 已新增树工具栏,提供展开全部、收起全部和重置范围控制,并在工具栏中回显当前范围标题。 +- 左侧树继续复用现有多级标签路径推导,同时把“区域显隐”和“层级展开控制”分开处理。 +- 右侧行内操作区已压缩为“连接”主按钮加“更多”菜单;编辑、测试、克隆、删除均移入菜单,SSH 测试项按连接类型显示。 +- `npm run build --workspace @nexus-terminal/frontend` 通过。 +- 运行态验证已完成前端预览启动,应用可进入登录页;但当前本地环境的 `/api/v1/*` 接口均返回 500,无法进入已登录的连接管理页做带数据目视验收。 diff --git a/.helloagents/archive/2026-03/202603252220_connections-tree-toolbar-menu-polish/tasks.md b/.helloagents/archive/2026-03/202603252220_connections-tree-toolbar-menu-polish/tasks.md new file mode 100644 index 0000000..cb6c89f --- /dev/null +++ b/.helloagents/archive/2026-03/202603252220_connections-tree-toolbar-menu-polish/tasks.md @@ -0,0 +1,48 @@ +# 任务清单: connections-tree-toolbar-menu-polish + +```yaml +@feature: connections-tree-toolbar-menu-polish +@created: 2026-03-25 +@status: completed +@mode: R2 +``` + +## 进度概览 + +| 完成 | 失败 | 跳过 | 总数 | +|------|------|------|------| +| 4 | 0 | 0 | 4 | + +--- + +## 任务列表 + +### 1. 方案与范围确认 + +- [√] 1.1 创建连接管理页树工具栏与菜单整理方案包 | depends_on: [] + +### 2. 交互增强实现 + +- [√] 2.1 在 `ConnectionsView.vue` 中补树工具栏与展开/收起控制 | depends_on: [1.1] +- [√] 2.2 调整行内操作布局,仅保留连接主按钮并扩充更多菜单 | depends_on: [2.1] + +### 3. 验证与同步 + +- [√] 3.1 执行前端验证并同步 `.helloagents` 文档与归档记录 | depends_on: [2.2] + +--- + +## 执行日志 + +| 时间 | 任务 | 状态 | 备注 | +|------|------|------|------| +| 2026-03-25 22:20 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为 ConnectionsView.vue 的树工具栏和行内菜单整理 | +| 2026-03-25 22:25 | 2.1 / 2.2 | 完成 | 增加树工具栏与展开/收起控制,并将编辑、测试、克隆、删除整理进更多菜单 | +| 2026-03-25 22:29 | 3.1 | 完成 | 前端构建通过,且预览服务可启动到登录页;因本地后端接口 500,未完成已登录连接页目视验收 | + +--- + +## 执行备注 + +- 本轮是 `connections-tree-sort-more-menu` 的后续整理,重点是把“树可操作性”和“行内操作层级”继续往参考图靠拢。 +- 运行态已验证前端可启动,但当前环境不具备可用登录态与连接数据,真实连接管理页视觉验收仍需在你的本地业务环境补做。 diff --git a/.helloagents/archive/2026-03/202603252229_terminal-tab-group-visual/.status.json b/.helloagents/archive/2026-03/202603252229_terminal-tab-group-visual/.status.json new file mode 100644 index 0000000..0d4e070 --- /dev/null +++ b/.helloagents/archive/2026-03/202603252229_terminal-tab-group-visual/.status.json @@ -0,0 +1 @@ +{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"已完成 terminal-tab-group-visual 并通过前端构建验证","updated_at":"2026-03-25 22:36:00"} diff --git a/.helloagents/archive/2026-03/202603252229_terminal-tab-group-visual/proposal.md b/.helloagents/archive/2026-03/202603252229_terminal-tab-group-visual/proposal.md new file mode 100644 index 0000000..17dbd63 --- /dev/null +++ b/.helloagents/archive/2026-03/202603252229_terminal-tab-group-visual/proposal.md @@ -0,0 +1,70 @@ +# 变更提案: terminal-tab-group-visual + +## 元信息 +```yaml +类型: 功能增强 +方案类型: implementation +优先级: P1 +状态: 已完成 +状态说明: 已完成实现,前端构建验证通过 +创建: 2026-03-25 +``` + +--- + +## 1. 需求 + +### 背景 +上一轮已经让同一 SSH 服务器支持多个终端,并补上了终端序号,但顶部标签栏仍然更像“连接名 + 终端编号”的单层标签,不够接近参考图里“同服务器终端属于一组”的视觉关系。 + +### 目标 +- 将顶部终端标签栏改成更明显的“同服务器终端组”视觉。 +- 采用“分组头 + 子标签”的表现方式。 +- 保留现有单连接多终端能力,不回退为单终端模型。 +- 继续保持当前黑绿主题和工作区整体风格。 + +### 约束条件 +```yaml +范围约束: 优先限制在 TerminalTabBar.vue 和相关前端文案,不改后端协议 +结构约束: 复用现有 session 排序与 terminalIndex 数据,不重写 session store 主模型 +交互约束: 同服务器组内仍能快速新增终端,其他服务器入口继续保留 +兼容约束: 不影响 RDP/VNC 既有入口和会话关闭逻辑 +``` + +### 验收标准 +- [ ] 连续同连接终端在标签栏中具备明确的“分组头 + 子标签”视觉关系 +- [ ] 组头能清晰展示服务器身份,子标签主要表达终端实例 +- [ ] 同组内仍能继续新增终端,且不影响现有关闭/切换逻辑 +- [ ] 前端构建通过 + +--- + +## 2. 方案 + +### 技术方案 +保留当前按 `sessionId` 排序的平铺会话数组,不引入新的嵌套 store 结构;在 `TerminalTabBar.vue` 内根据相邻会话的 `connectionId` 判定组起点与组终点,在连续同连接会话的首项前渲染组头,在末项后渲染组内新增终端按钮,子标签本身则弱化连接名、强化终端编号与激活态。这样能在不打破现有拖拽键和会话事件路由的前提下,得到更接近参考图的组视觉。 + +### 影响范围 +```yaml +涉及模块: + - frontend: TerminalTabBar.vue, locales +预计变更文件: 2-4 +``` + +### 风险评估 +| 风险 | 等级 | 应对 | +|------|------|------| +| 分组头和子标签都在 draggable item 中渲染,样式边界容易错位 | 中 | 让分组逻辑完全依赖前后相邻项判定,避免额外 DOM 状态 | +| 子标签弱化连接名后识别度下降 | 低 | 组头保留服务器名,子标签保留终端编号与 tooltip | +| 组内新增终端按钮和现有全局按钮职责重叠 | 中 | 将全局按钮收口为“选择其他服务器”,组内新增由组尾按钮承担 | + +--- + +## 3. 技术决策 + +### terminal-tab-group-visual#D001: 用相邻项判定的轻量分组视觉替代嵌套数据重构 +**日期**: 2026-03-25 +**状态**: ✅采纳 +**决策**: 不新增“连接组” store 层,而是在 `TerminalTabBar.vue` 里通过相邻 `connectionId` 判断组首/组尾,渲染组头、子标签和组尾新增按钮。 +**理由**: 当前 session 数据和事件路由已经稳定,视觉分组是展示层问题,没必要为此重写状态结构。轻量分组实现可以最大限度复用现有逻辑,同时更容易回滚。 +**影响**: 主要影响 `TerminalTabBar.vue` 的结构和样式,以及少量终端标签栏文案。 diff --git a/.helloagents/archive/2026-03/202603252229_terminal-tab-group-visual/tasks.md b/.helloagents/archive/2026-03/202603252229_terminal-tab-group-visual/tasks.md new file mode 100644 index 0000000..7647459 --- /dev/null +++ b/.helloagents/archive/2026-03/202603252229_terminal-tab-group-visual/tasks.md @@ -0,0 +1,49 @@ +# 任务清单: terminal-tab-group-visual + +```yaml +@feature: terminal-tab-group-visual +@created: 2026-03-25 +@status: completed +@mode: R2 +``` + +## 进度概览 + +| 完成 | 失败 | 跳过 | 总数 | +|------|------|------|------| +| 4 | 0 | 0 | 4 | + +--- + +## 任务列表 + +### 1. 方案与视觉方向确认 + +- [√] 1.1 创建顶部终端组视觉方案包并锁定“分组头 + 子标签”方向 | depends_on: [] + +### 2. 终端标签栏改造 + +- [√] 2.1 在 `TerminalTabBar.vue` 中实现组首/组尾判定与分组头渲染 | depends_on: [1.1] +- [√] 2.2 调整子标签与组尾新增终端按钮样式,并保留全局服务器入口 | depends_on: [2.1] + +### 3. 验证与同步 + +- [√] 3.1 运行前端构建验证并同步 `.helloagents` 文档与归档记录 | depends_on: [2.2] + +--- + +## 执行日志 + +| 时间 | 任务 | 状态 | 备注 | +|------|------|------|------| +| 2026-03-25 22:29 | 1.1 | 完成 | 创建 implementation 方案包,并锁定“分组头 + 子标签”方向 | +| 2026-03-25 22:33 | 2.1 / 2.2 | 完成 | 在 TerminalTabBar.vue 中加入组首组尾判定、服务器组头、子标签和组尾新增终端按钮 | +| 2026-03-25 22:36 | 3.1 | 完成 | `npm --prefix packages/frontend run build` 通过,并同步知识库文档 | + +--- + +## 执行备注 + +- 本轮是标签栏视觉升级,不改底层 session / WebSocket 协议。 +- 分组关系只存在于顶部标签栏展示层,底层仍按独立 `sessionId` 工作。 +- 若实现中发现拖拽和分组样式冲突,优先保证组视觉和终端可操作性。 diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md index fb6710a..09c82a1 100644 --- a/.helloagents/archive/_index.md +++ b/.helloagents/archive/_index.md @@ -14,11 +14,15 @@ | 202603250614 | terminal-ansi-color-effects | implementation | frontend, backend | - | ✅完成 | | 202603250636 | connections-view-tree-search-redesign | implementation | frontend | - | ✅完成 | | 202603252152 | connections-tree-sort-more-menu | implementation | frontend | - | ✅完成 | +| 202603252220 | connections-tree-toolbar-menu-polish | implementation | frontend | - | ✅完成 | | 202603252207 | ssh-connection-multi-terminal | implementation | frontend | ssh-connection-multi-terminal#D001 | ✅完成 | +| 202603252229 | terminal-tab-group-visual | implementation | frontend | terminal-tab-group-visual#D001 | ✅完成 | | 202603251200 | workspace-workbench-monitor | implementation | frontend, backend | workspace-workbench-monitor#D001 | ✅完成 | ## 按月归档 +| 202603252200 | server-status-memory-disk-cards | implementation | frontend, backend | server-status-memory-disk-cards#D001, server-status-memory-disk-cards#D002 | 鉁呭畬鎴?| + ### 2026-03 - [202603250317_ghcr-docker-publish](./2026-03/202603250317_ghcr-docker-publish/) - 新增 GHCR 镜像发布 workflow 并切换 compose 镜像来源 - [202603250532_quickcommands-theme-alignment](./2026-03/202603250532_quickcommands-theme-alignment/) - 统一快捷指令视图按钮主题适配,移除残留硬编码 hover 色值 @@ -27,9 +31,12 @@ - [202603250614_terminal-ansi-color-effects](./2026-03/202603250614_terminal-ansi-color-effects/) - 修复终端文字效果覆盖 ANSI 彩色输出的问题,并将文字效果默认开关改为开启 - [202603250636_connections-view-tree-search-redesign](./2026-03/202603250636_connections-view-tree-search-redesign/) - 将连接管理页升级为左侧标签树、顶部搜索工具条和右侧结果列表的双栏管理台 - [202603252152_connections-tree-sort-more-menu](./2026-03/202603252152_connections-tree-sort-more-menu/) - 为连接管理页补充多级标签树、列头排序和行级更多菜单 +- [202603252220_connections-tree-toolbar-menu-polish](./2026-03/202603252220_connections-tree-toolbar-menu-polish/) - 为连接管理页补树工具栏与展开/收起控制,并整理行内更多菜单 - [202603252207_ssh-connection-multi-terminal](./2026-03/202603252207_ssh-connection-multi-terminal/) - 为同一 SSH 服务器连接补充多终端入口与终端序号标识 +- [202603252229_terminal-tab-group-visual](./2026-03/202603252229_terminal-tab-group-visual/) - 将顶部终端标签栏改成更明显的服务器组头与终端子标签 - [202603251200_workspace-workbench-monitor](./2026-03/202603251200_workspace-workbench-monitor/) - `/workspace` 改为三栏 Workbench 布局,并新增开机累计流量监控 +- [202603252200_server-status-memory-disk-cards](./2026-03/202603252200_server-status-memory-disk-cards/) - 为服务器状态补齐参考图风格的内存/磁盘卡片,并扩展后端监控字段与磁盘 I/O 速率 ## 结果状态说明 - ✅ 完成 - ⚠️ 部分完成 diff --git a/.helloagents/modules/backend.md b/.helloagents/modules/backend.md index 23ceb53..c0bbed2 100644 --- a/.helloagents/modules/backend.md +++ b/.helloagents/modules/backend.md @@ -55,3 +55,8 @@ 依赖: workspace-root, sqlite(data), express-session, ssh2, ws 被依赖: frontend ``` + +### 状态监控字段扩展 +**条件**: `StatusMonitorService` 为前端工作区持续轮询服务器状态。 +**行为**: 当前状态采集链路除 `free`、`df`、`/proc/stat` 与 `/proc/net/dev` 外,还会补充解析 `memFree`、`memCached`、`diskAvailable`、`diskMountPoint`、`diskFsType`、`diskDevice`,并基于 `/proc/diskstats` 计算根设备的磁盘读写速率;设备名会尽量从分区名规整到块设备名,无法获取的字段则按 `undefined` 降级。 +**结果**: 前端状态监控可以直接展示参考图风格的内存/磁盘卡片,而不需要再自行推导缓存、空闲和磁盘元信息。 diff --git a/.helloagents/modules/frontend.md b/.helloagents/modules/frontend.md index 06a9d19..d961480 100644 --- a/.helloagents/modules/frontend.md +++ b/.helloagents/modules/frontend.md @@ -36,7 +36,7 @@ ### 工作区交互 **条件**: 用户进入 `/workspace` 或相关管理页面。 -**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;`Terminal.vue` 会跟踪 xterm 的视口行号与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复,并在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`session.store` 当前会为同一 SSH 连接下的新终端分配递增的 `terminalIndex`,`TerminalTabBar.vue` 则把当前连接“继续新增终端”和“改连其他服务器”拆成独立入口,并在标签上显示终端序号,从而实现“单连接默认 1 个终端、可继续追加多个终端”的交互;`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,当前左侧进一步支持基于标签名路径分隔符推导的多级标签树、树节点展开状态持久化和分组 scope 恢复,右侧结果列表则同时支持顶部排序控件、列头点击排序和行级“更多”菜单(克隆/删除);样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。 +**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;`Terminal.vue` 会跟踪 xterm 的视口行号与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复,并在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`session.store` 当前会为同一 SSH 连接下的新终端分配递增的 `terminalIndex`,`TerminalTabBar.vue` 则进一步把连续同连接会话渲染成“服务器组头 + 终端子标签 + 组尾新增按钮”,全局 `+` 只负责选择其他服务器,从而让“单连接默认 1 个终端、可继续追加多个终端”的关系在顶部标签栏里更接近参考图;`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,当前左侧进一步支持基于标签名路径分隔符推导的多级标签树、树节点展开状态持久化、分组 scope 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单(编辑/测试/克隆/删除);样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。 **结果**: 页面逻辑分散在 `views/`、`components/`、`stores/` 与 `composables/`,其中工作区终端行为和标签交互优先落在 `session.store.ts`、`session/actions/sessionActions.ts`、`session/getters.ts`、`TerminalTabBar.vue`、`WorkspaceView.vue`、`Terminal.vue` 与相关 locale 文件。 ## 依赖关系 @@ -45,3 +45,8 @@ 依赖: workspace-root, backend, remote-gateway, vue-router, pinia 被依赖: 无 ``` + +### 状态监控卡片 +**条件**: 用户在 `/workspace` 右侧状态监控面板查看服务器资源状态。 +**行为**: `StatusMonitor.vue` 当前将内存与磁盘区域升级为卡片化监控视图:内存卡片展示总量、已用、缓存、空闲和环形占比,磁盘卡片展示设备名、文件系统类型、读写速率以及挂载点/大小/可用/已用率表格;CPU、Swap、网络速率和 `StatusCharts.vue` 的 CPU / 网络曲线继续保留。 +**结果**: 状态监控从“简单进度行”升级为“高信息密度卡片”,并直接承接后端新增的内存细分字段与磁盘元数据。 diff --git a/packages/backend/src/services/status-monitor.service.ts b/packages/backend/src/services/status-monitor.service.ts index 318dfb4..a21249f 100644 --- a/packages/backend/src/services/status-monitor.service.ts +++ b/packages/backend/src/services/status-monitor.service.ts @@ -3,7 +3,6 @@ import { WebSocket } from 'ws'; import { ClientState } from '../websocket'; import { settingsService } from '../settings/settings.service'; - interface ServerStatus { cpuPercent?: number; memPercent?: number; @@ -30,39 +29,398 @@ interface ServerStatus { netTxTotalBytes?: number; // Bytes since boot netInterface?: string; osName?: string; - loadAvg?: number[]; // 系统平均负载 [1min, 5min, 15min] - timestamp: number; // 状态获取时间戳 + loadAvg?: number[]; + timestamp: number; } - interface NetworkStats { [interfaceName: string]: { rx_bytes: number; tx_bytes: number; - } + }; } interface DiskIoStats { [deviceName: string]: { readBytes: number; writeBytes: number; - } + }; } - -// 用于存储上一次的网络统计信息以计算速率 -const previousNetStats = new Map(); -const previousDiskStats = new Map(); +const previousNetStats = new Map(); +const previousDiskStats = new Map(); export class StatusMonitorService { - private clientStates: Map; // 使用导入的 ClientState - // 用于存储上一次的 CPU 统计信息以计算使用率 - private previousCpuStats = new Map(); + private clientStates: Map; + private previousCpuStats = new Map(); constructor(clientStates: Map) { this.clientStates = clientStates; } + async startStatusPolling(sessionId: string): Promise { + const state = this.clientStates.get(sessionId); + if (!state || !state.sshClient || state.statusIntervalId) { + return; + } + + let intervalMs = 3000; + try { + const intervalSeconds = await settingsService.getStatusMonitorIntervalSeconds(); + intervalMs = intervalSeconds * 1000; + console.log(`[StatusMonitor ${sessionId}] 使用配置的轮询间隔: ${intervalSeconds} 秒 (${intervalMs}ms)`); + } catch (error) { + console.error(`[StatusMonitor ${sessionId}] 获取轮询间隔设置失败,将使用默认值 3000ms:`, error); + } + + state.statusIntervalId = setInterval(() => { + this.fetchAndSendServerStatus(sessionId); + }, intervalMs); + } + + stopStatusPolling(sessionId: string): void { + const state = this.clientStates.get(sessionId); + if (!state?.statusIntervalId) { + return; + } + + clearInterval(state.statusIntervalId); + state.statusIntervalId = undefined; + previousNetStats.delete(sessionId); + previousDiskStats.delete(sessionId); + this.previousCpuStats.delete(sessionId); + } + + private async fetchAndSendServerStatus(sessionId: string): Promise { + const state = this.clientStates.get(sessionId); + if (!state || !state.sshClient || state.ws.readyState !== WebSocket.OPEN) { + this.stopStatusPolling(sessionId); + return; + } + + try { + const status = await this.fetchServerStatus(state.sshClient, sessionId); + state.ws.send(JSON.stringify({ type: 'status_update', payload: { connectionId: state.dbConnectionId, status } })); + } catch (error: any) { + state.ws.send(JSON.stringify({ + type: 'status_error', + payload: { connectionId: state.dbConnectionId, message: `获取状态失败: ${error.message}` }, + })); + } + } + + private async fetchServerStatus(sshClient: Client, sessionId: string): Promise { + const timestamp = Date.now(); + const status: Partial = { timestamp }; + + try { + try { + const osReleaseOutput = await this.executeSshCommand(sshClient, 'cat /etc/os-release'); + const nameMatch = osReleaseOutput.match(/^PRETTY_NAME="?([^"]+)"?/m); + status.osName = nameMatch ? nameMatch[1] : (osReleaseOutput.match(/^NAME="?([^"]+)"?/m)?.[1] ?? 'Unknown'); + } catch (err) { /* noop */ } + + try { + let cpuModelOutput = ''; + try { + cpuModelOutput = await this.executeSshCommand(sshClient, "cat /proc/cpuinfo | grep 'model name' | head -n 1"); + status.cpuModel = cpuModelOutput.match(/model name\s*:\s*(.*)/i)?.[1].trim(); + } catch (procErr) { + cpuModelOutput = await this.executeSshCommand(sshClient, "lscpu | grep 'Model name:'"); + status.cpuModel = cpuModelOutput.match(/Model name:\s+(.*)/)?.[1].trim(); + } + if (!status.cpuModel) { + status.cpuModel = 'Unknown'; + } + } catch (err) { + status.cpuModel = 'Unknown'; + } + + await this.collectMemoryStatus(sshClient, status); + await this.collectDiskStatus(sshClient, sessionId, timestamp, status); + + try { + const procStatOutput = await this.executeSshCommand(sshClient, 'cat /proc/stat'); + const currentCpuTimes = this.parseProcStat(procStatOutput); + const now = Date.now(); + + if (currentCpuTimes) { + const prevCpuStats = this.previousCpuStats.get(sessionId); + if (prevCpuStats && prevCpuStats.timestamp < now) { + const totalDiff = currentCpuTimes.total - prevCpuStats.total; + const idleDiff = currentCpuTimes.idle - prevCpuStats.idle; + const timeDiffMs = now - prevCpuStats.timestamp; + + if (totalDiff > 0 && timeDiffMs > 100) { + const usageRatio = 1.0 - (idleDiff / totalDiff); + status.cpuPercent = parseFloat((Math.max(0, Math.min(100, usageRatio * 100))).toFixed(1)); + } else { + status.cpuPercent = prevCpuStats.total > 0 ? status.cpuPercent : 0; + } + } else { + status.cpuPercent = 0; + } + + this.previousCpuStats.set(sessionId, { ...currentCpuTimes, timestamp: now }); + } + } catch (err) { + status.cpuPercent = undefined; + } + + try { + const uptimeOutput = await this.executeSshCommand(sshClient, 'uptime'); + const match = uptimeOutput.match(/load average(?:s)?:\s*([\d.]+)[, ]?\s*([\d.]+)[, ]?\s*([\d.]+)/); + if (match) { + status.loadAvg = [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3])]; + } + } catch (err) { /* noop */ } + + await this.collectNetworkStatus(sshClient, sessionId, timestamp, status); + } catch (error) { + console.error(`[StatusMonitor ${sessionId}] General error fetching server status:`, error); + } + + return status as ServerStatus; + } + + private async collectMemoryStatus(sshClient: Client, status: Partial): Promise { + try { + let freeCommand = 'free -m'; + let isBusyBox = false; + try { + const busyboxCheck = await this.executeSshCommand(sshClient, 'busybox --help'); + if (busyboxCheck.includes('BusyBox')) { + freeCommand = 'free'; + isBusyBox = true; + } + } catch (err) { /* noop */ } + + const normalizeMemory = (value: number): number => isBusyBox ? Math.round(value / 1024) : value; + const freeOutput = await this.executeSshCommand(sshClient, freeCommand); + const lines = freeOutput.split('\n'); + const headerLine = lines.find(line => line.toLowerCase().includes('total') && line.toLowerCase().includes('used')); + const memLine = lines.find(line => line.startsWith('Mem:')); + const swapLine = lines.find(line => line.startsWith('Swap:')); + + if (memLine && headerLine) { + const headers = headerLine.trim().split(/\s+/); + const values = memLine.trim().split(/\s+/).slice(1); + const memoryFields: Record = {}; + + headers.forEach((header, index) => { + const rawValue = parseInt(values[index], 10); + if (!isNaN(rawValue)) { + memoryFields[header.toLowerCase()] = normalizeMemory(rawValue); + } + }); + + const totalVal = memoryFields.total; + const usedVal = memoryFields.used; + const freeVal = memoryFields.free; + const cachedVal = memoryFields['buff/cache'] ?? ((memoryFields.buffers ?? 0) + (memoryFields.cached ?? 0)); + + if (!isNaN(totalVal) && !isNaN(usedVal)) { + status.memTotal = totalVal; + status.memUsed = usedVal; + status.memPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0; + status.memFree = !isNaN(freeVal) ? freeVal : Math.max(totalVal - usedVal - (cachedVal || 0), 0); + if (cachedVal > 0) { + status.memCached = cachedVal; + } + } + } else if (memLine) { + const parts = memLine.split(/\s+/); + if (parts.length >= 4) { + const totalVal = normalizeMemory(parseInt(parts[1], 10)); + const usedVal = normalizeMemory(parseInt(parts[2], 10)); + const freeVal = normalizeMemory(parseInt(parts[3], 10)); + + if (!isNaN(totalVal) && !isNaN(usedVal)) { + status.memTotal = totalVal; + status.memUsed = usedVal; + status.memFree = !isNaN(freeVal) ? freeVal : undefined; + status.memPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0; + } + } + } + + if (swapLine) { + const parts = swapLine.split(/\s+/); + if (parts.length >= 3) { + const totalVal = normalizeMemory(parseInt(parts[1], 10)); + const usedVal = normalizeMemory(parseInt(parts[2], 10)); + if (!isNaN(totalVal) && !isNaN(usedVal)) { + status.swapTotal = totalVal; + status.swapUsed = usedVal; + status.swapPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0; + } + } + } else { + status.swapTotal = 0; + status.swapUsed = 0; + status.swapPercent = 0; + } + } catch (err) { /* noop */ } + } + + private async collectDiskStatus( + sshClient: Client, + sessionId: string, + timestamp: number, + status: Partial, + ): Promise { + try { + let dfOutput = ''; + try { + dfOutput = await this.executeSshCommand(sshClient, 'df -kPT /'); + } catch (err) { + dfOutput = await this.executeSshCommand(sshClient, 'df -kP /'); + } + + let rawDiskDevice: string | undefined; + if (dfOutput) { + const lines = dfOutput.split('\n'); + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line.endsWith(' /')) { + continue; + } + + const parts = line.split(/\s+/); + const hasTypeColumn = parts.length >= 7; + const totalIndex = hasTypeColumn ? 2 : 1; + const usedIndex = hasTypeColumn ? 3 : 2; + const availableIndex = hasTypeColumn ? 4 : 3; + const percentIndex = hasTypeColumn ? 5 : 4; + const mountIndex = hasTypeColumn ? 6 : 5; + const total = parseInt(parts[totalIndex], 10); + const used = parseInt(parts[usedIndex], 10); + const available = parseInt(parts[availableIndex], 10); + const percentMatch = parts[percentIndex]?.match(/(\d+)%/); + + if (!isNaN(total) && !isNaN(used) && !isNaN(available) && percentMatch?.[1]) { + rawDiskDevice = parts[0]; + status.diskFsType = hasTypeColumn ? parts[1] : status.diskFsType; + status.diskTotal = total; + status.diskUsed = used; + status.diskAvailable = available; + status.diskPercent = parseFloat(percentMatch[1]); + status.diskMountPoint = parts[mountIndex] || '/'; + break; + } + } + } + + if (!rawDiskDevice || !status.diskFsType || !status.diskMountPoint) { + try { + const findmntOutput = await this.executeSshCommand(sshClient, 'findmnt -n -o SOURCE,FSTYPE,TARGET /'); + const findmntParts = findmntOutput.trim().split(/\s+/); + rawDiskDevice = rawDiskDevice || findmntParts[0]; + status.diskFsType = status.diskFsType || findmntParts[1]; + status.diskMountPoint = status.diskMountPoint || findmntParts[2] || '/'; + } catch (err) { /* noop */ } + } + + status.diskDevice = this.normalizeDiskDevice(rawDiskDevice); + if (!status.diskDevice) { + return; + } + + const currentDiskStats = await this.parseProcDiskStats(sshClient); + const deviceStats = currentDiskStats?.[status.diskDevice]; + if (!deviceStats) { + return; + } + + const previousStats = previousDiskStats.get(sessionId); + if (previousStats && previousStats.device === status.diskDevice && previousStats.timestamp < timestamp) { + const timeDiffSeconds = (timestamp - previousStats.timestamp) / 1000; + if (timeDiffSeconds > 0.1) { + status.diskReadRate = Math.max(0, Math.round((deviceStats.readBytes - previousStats.readBytes) / timeDiffSeconds)); + status.diskWriteRate = Math.max(0, Math.round((deviceStats.writeBytes - previousStats.writeBytes) / timeDiffSeconds)); + } else { + status.diskReadRate = 0; + status.diskWriteRate = 0; + } + } else { + status.diskReadRate = 0; + status.diskWriteRate = 0; + } + + previousDiskStats.set(sessionId, { + device: status.diskDevice, + readBytes: deviceStats.readBytes, + writeBytes: deviceStats.writeBytes, + timestamp, + }); + } catch (err) { /* noop */ } + } + + private async collectNetworkStatus( + sshClient: Client, + sessionId: string, + timestamp: number, + status: Partial, + ): Promise { + try { + const currentStats = await this.parseProcNetDev(sshClient); + if (!currentStats) { + return; + } + + const defaultInterface = await this.getDefaultInterface(sshClient) || Object.keys(currentStats).find(iface => iface !== 'lo'); + if (!defaultInterface || !currentStats[defaultInterface]) { + return; + } + + status.netInterface = defaultInterface; + const currentRx = currentStats[defaultInterface].rx_bytes; + const currentTx = currentStats[defaultInterface].tx_bytes; + status.netRxTotalBytes = currentRx; + status.netTxTotalBytes = currentTx; + + const prevStats = previousNetStats.get(sessionId); + if (prevStats && prevStats.timestamp < timestamp) { + const timeDiffSeconds = (timestamp - prevStats.timestamp) / 1000; + if (timeDiffSeconds > 0.1) { + status.netRxRate = Math.max(0, Math.round((currentRx - prevStats.rx) / timeDiffSeconds)); + status.netTxRate = Math.max(0, Math.round((currentTx - prevStats.tx) / timeDiffSeconds)); + } else { + status.netRxRate = 0; + status.netTxRate = 0; + } + } else { + status.netRxRate = 0; + status.netTxRate = 0; + } + + previousNetStats.set(sessionId, { rx: currentRx, tx: currentTx, timestamp }); + } catch (err) { /* noop */ } + } + + private async parseProcNetDev(sshClient: Client): Promise { + try { + const output = await this.executeSshCommand(sshClient, 'cat /proc/net/dev'); + const stats: NetworkStats = {}; + + for (const line of output.split('\n').slice(2)) { + const parts = line.trim().split(/:\s+|\s+/); + if (parts.length < 17) continue; + + const interfaceName = parts[0]; + const rx_bytes = parseInt(parts[1], 10); + const tx_bytes = parseInt(parts[9], 10); + if (!isNaN(rx_bytes) && !isNaN(tx_bytes)) { + stats[interfaceName] = { rx_bytes, tx_bytes }; + } + } + + return Object.keys(stats).length > 0 ? stats : null; + } catch (error) { + return null; + } + } + private async parseProcDiskStats(sshClient: Client): Promise { try { const output = await this.executeSshCommand(sshClient, 'cat /proc/diskstats'); @@ -89,464 +447,25 @@ export class StatusMonitorService { } } - /** - * 启动指定会话的状态轮询 - * @param sessionId 会话 ID - */ - async startStatusPolling(sessionId: string): Promise { - const state = this.clientStates.get(sessionId); - if (!state || !state.sshClient) { - return; - } - if (state.statusIntervalId) { - return; - } - - // +++ 从 settingsService 获取轮询间隔 +++ - let intervalMs: number; - try { - const intervalSeconds = await settingsService.getStatusMonitorIntervalSeconds(); - intervalMs = intervalSeconds * 1000; - console.log(`[StatusMonitor ${sessionId}] 使用配置的轮询间隔: ${intervalSeconds} 秒 (${intervalMs}ms)`); - } catch (error) { - console.error(`[StatusMonitor ${sessionId}] 获取轮询间隔设置失败,将使用默认值 3000ms:`, error); - intervalMs = 3000; // 出错时回退到 3 秒 - } - - // 移除立即执行,让 setInterval 负责第一次调用,给连接更多准备时间 - state.statusIntervalId = setInterval(() => { - this.fetchAndSendServerStatus(sessionId); - }, intervalMs); // --- 使用获取到的间隔 --- - } - - /** - * 停止指定会话的状态轮询 - * @param sessionId 会话 ID - */ - stopStatusPolling(sessionId: string): void { - const state = this.clientStates.get(sessionId); - if (state?.statusIntervalId) { - //console.warn(`[StatusMonitor] 停止会话 ${sessionId} 的状态轮询。`); - clearInterval(state.statusIntervalId); - state.statusIntervalId = undefined; - previousNetStats.delete(sessionId); // 清理网络统计缓存 - previousDiskStats.delete(sessionId); - this.previousCpuStats.delete(sessionId); // 清理 CPU 统计缓存 - } - } - - /** - * 获取并发送服务器状态给客户端 - * @param sessionId 会话 ID - */ - private async fetchAndSendServerStatus(sessionId: string): Promise { - const state = this.clientStates.get(sessionId); - if (!state || !state.sshClient || state.ws.readyState !== WebSocket.OPEN) { - //console.warn(`[StatusMonitor] 无法获取会话 ${sessionId} 的状态,停止轮询。原因:状态无效、SSH断开或WS关闭。`); - this.stopStatusPolling(sessionId); - return; - } - try { - // 传递 sessionId 给 fetchServerStatus 以便查找 previousNetStats - const status = await this.fetchServerStatus(state.sshClient, sessionId); - state.ws.send(JSON.stringify({ type: 'status_update', payload: { connectionId: state.dbConnectionId, status } })); - } catch (error: any) { - // --- 移除 console.warn --- - // console.warn(`[StatusMonitor] 获取会话 ${sessionId} 服务器状态失败:`, error); - state.ws.send(JSON.stringify({ type: 'status_error', payload: { connectionId: state.dbConnectionId, message: `获取状态失败: ${error.message}` } })); - } - } - - /** - * 通过 SSH 执行命令获取服务器状态信息 - * @param sshClient SSH 客户端实例 - * @param sessionId 当前会话 ID,用于网络速率计算 - * @returns Promise 服务器状态信息 - */ - private async fetchServerStatus(sshClient: Client, sessionId: string): Promise { - // console.debug(`[StatusMonitor ${sessionId}] Fetching server status...`); - const timestamp = Date.now(); - let status: Partial = { timestamp }; - - try { - // --- OS Name --- - try { - const osReleaseOutput = await this.executeSshCommand(sshClient, 'cat /etc/os-release'); - const nameMatch = osReleaseOutput.match(/^PRETTY_NAME="?([^"]+)"?/m); - status.osName = nameMatch ? nameMatch[1] : (osReleaseOutput.match(/^NAME="?([^"]+)"?/m)?.[1] ?? 'Unknown'); - } catch (err) { } - - try { - let cpuModelOutput = ''; - try { - cpuModelOutput = await this.executeSshCommand(sshClient, "cat /proc/cpuinfo | grep 'model name' | head -n 1"); - status.cpuModel = cpuModelOutput.match(/model name\s*:\s*(.*)/i)?.[1].trim(); - } catch (procErr) { - - try { - cpuModelOutput = await this.executeSshCommand(sshClient, "lscpu | grep 'Model name:'"); - status.cpuModel = cpuModelOutput.match(/Model name:\s+(.*)/)?.[1].trim(); - } catch (lscpuErr) { - - } - } - if (!status.cpuModel) { - status.cpuModel = 'Unknown'; - } - } catch (err) { - - status.cpuModel = 'Unknown'; - } - - - try { - let freeCommand = 'free -m'; - let isBusyBox = false; - try { - const busyboxCheck = await this.executeSshCommand(sshClient, 'busybox --help'); - if (busyboxCheck.includes('BusyBox')) { - freeCommand = 'free'; - isBusyBox = true; - } - } catch (err) { - // 如果检查失败,默认使用 free -m - } - const freeOutput = await this.executeSshCommand(sshClient, freeCommand); - const lines = freeOutput.split('\n'); - const headerLine = lines.find(line => line.toLowerCase().includes('total') && line.toLowerCase().includes('used')); - const memLine = lines.find(line => line.startsWith('Mem:')); - const swapLine = lines.find(line => line.startsWith('Swap:')); - if (memLine && headerLine) { - const headers = headerLine.trim().split(/\s+/); - const values = memLine.trim().split(/\s+/).slice(1); - const memoryFields: Record = {}; - - headers.forEach((header, index) => { - const rawValue = parseInt(values[index], 10); - if (!isNaN(rawValue)) { - memoryFields[header.toLowerCase()] = isBusyBox ? Math.round(rawValue / 1024) : rawValue; - } - }); - - const totalVal = memoryFields.total; - const usedVal = memoryFields.used; - const freeVal = memoryFields.free; - const cachedVal = memoryFields['buff/cache'] ?? ((memoryFields.buffers ?? 0) + (memoryFields.cached ?? 0)); - - if (!isNaN(totalVal) && !isNaN(usedVal)) { - status.memTotal = totalVal; - status.memUsed = usedVal; - status.memPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0; - status.memFree = !isNaN(freeVal) ? freeVal : Math.max(totalVal - usedVal - (cachedVal || 0), 0); - if (cachedVal > 0) { - status.memCached = cachedVal; - } - } - } else if (memLine) { - const parts = memLine.split(/\s+/); - if (parts.length >= 4) { - let totalVal = parseInt(parts[1], 10); - let usedVal = parseInt(parts[2], 10); - let freeVal = parseInt(parts[3], 10); - - if (isBusyBox) { - if (!isNaN(totalVal)) totalVal = Math.round(totalVal / 1024); - if (!isNaN(usedVal)) usedVal = Math.round(usedVal / 1024); - if (!isNaN(freeVal)) freeVal = Math.round(freeVal / 1024); - } - - if (!isNaN(totalVal) && !isNaN(usedVal)) { - status.memTotal = totalVal; - status.memUsed = usedVal; - status.memFree = !isNaN(freeVal) ? freeVal : undefined; - status.memPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0; - } - } - } - if (swapLine) { - const parts = swapLine.split(/\s+/); - if (parts.length >= 3) { - let totalVal = parseInt(parts[1], 10); - let usedVal = parseInt(parts[2], 10); - - if (isBusyBox) { - if (!isNaN(totalVal)) totalVal = Math.round(totalVal / 1024); - if (!isNaN(usedVal)) usedVal = Math.round(usedVal / 1024); - } - - if (!isNaN(totalVal) && !isNaN(usedVal)) { - status.swapTotal = totalVal; - status.swapUsed = usedVal; - status.swapPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0; - } - } - } else { - status.swapTotal = 0; - status.swapUsed = 0; - status.swapPercent = 0; - } - } catch (err) { /* 静默处理 */ } - - - try { - let dfCommand = "df -kPT /"; - let dfOutput: string; - try { - dfOutput = await this.executeSshCommand(sshClient, dfCommand); - } catch (errP) { - dfCommand = "df -kP /"; - try { - dfOutput = await this.executeSshCommand(sshClient, dfCommand); - } catch (errK) { - dfOutput = ""; - } - } - - if (dfOutput) { - const lines = dfOutput.split('\n'); - let rawDiskDevice: string | undefined; - let parsedDiskInfo = false; - // 从第二行开始查找根挂载点信息 (跳过表头) - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); - // 确保是根挂载点,通常以 " /" 结尾 - if (!line.endsWith(" /")) { - continue; - } - - const parts = line.split(/\s+/); - const hasTypeColumn = parts.length >= 7; - const totalIndex = hasTypeColumn ? 2 : 1; - const usedIndex = hasTypeColumn ? 3 : 2; - const availableIndex = hasTypeColumn ? 4 : 3; - const percentIndex = hasTypeColumn ? 5 : 4; - const mountIndex = hasTypeColumn ? 6 : 5; - const total = parseInt(parts[totalIndex], 10); - const used = parseInt(parts[usedIndex], 10); - const available = parseInt(parts[availableIndex], 10); - const percentMatch = parts[percentIndex]?.match(/(\d+)%/); - - if (!isNaN(total) && !isNaN(used) && !isNaN(available) && percentMatch?.[1]) { - rawDiskDevice = parts[0]; - status.diskFsType = hasTypeColumn ? parts[1] : status.diskFsType; - status.diskTotal = total; - status.diskUsed = used; - status.diskAvailable = available; - status.diskPercent = parseFloat(percentMatch[1]); - status.diskMountPoint = parts[mountIndex] || '/'; - break; - } - // 预期 parts 至少包含: 文件系统, 总量(KB), 已用(KB), 可用(KB), 百分比%, 挂载点 - // 例如: /dev/sda1 10307920 3841884 5941800 40% / - if (parts.length >= 5) { - const total = parseInt(parts[1], 10); - const used = parseInt(parts[2], 10); - const percentStr = parts.find(p => p.endsWith('%')); // 查找百分比字符串 - - if (percentStr) { - const percentMatch = percentStr.match(/(\d+)%/); - if (!isNaN(total) && !isNaN(used) && percentMatch && percentMatch[1]) { - status.diskTotal = total; // KB - status.diskUsed = used; // KB - status.diskPercent = parseFloat(percentMatch[1]); - parsedDiskInfo = true; - break; - } - } - } - } - } - - if (!rawDiskDevice || !status.diskFsType || !status.diskMountPoint) { - try { - const findmntOutput = await this.executeSshCommand(sshClient, 'findmnt -n -o SOURCE,FSTYPE,TARGET /'); - const findmntParts = findmntOutput.trim().split(/\s+/); - rawDiskDevice = rawDiskDevice || findmntParts[0]; - status.diskFsType = status.diskFsType || findmntParts[1]; - status.diskMountPoint = status.diskMountPoint || findmntParts[2] || '/'; - } catch (findmntErr) { /* 静默处理 */ } - } - - status.diskDevice = this.normalizeDiskDevice(rawDiskDevice); - - if (status.diskDevice) { - const currentDiskStats = await this.parseProcDiskStats(sshClient); - const deviceStats = currentDiskStats?.[status.diskDevice]; - if (deviceStats) { - const previousStats = previousDiskStats.get(sessionId); - if (previousStats && previousStats.device === status.diskDevice && previousStats.timestamp < timestamp) { - const timeDiffSeconds = (timestamp - previousStats.timestamp) / 1000; - if (timeDiffSeconds > 0.1) { - status.diskReadRate = Math.max(0, Math.round((deviceStats.readBytes - previousStats.readBytes) / timeDiffSeconds)); - status.diskWriteRate = Math.max(0, Math.round((deviceStats.writeBytes - previousStats.writeBytes) / timeDiffSeconds)); - } else { - status.diskReadRate = 0; - status.diskWriteRate = 0; - } - } else { - status.diskReadRate = 0; - status.diskWriteRate = 0; - } - - previousDiskStats.set(sessionId, { - device: status.diskDevice, - readBytes: deviceStats.readBytes, - writeBytes: deviceStats.writeBytes, - timestamp, - }); - } - } - - } - } catch (err) { - // 如果捕获到错误 (例如 executeSshCommand 内部的 Promise reject), disk* 字段将保持 undefined - } - - try { - const procStatOutput = await this.executeSshCommand(sshClient, 'cat /proc/stat'); - const currentCpuTimes = this.parseProcStat(procStatOutput); - const now = Date.now(); // Use a consistent timestamp - - if (currentCpuTimes) { - const prevCpuStats = this.previousCpuStats.get(sessionId); - - if (prevCpuStats && prevCpuStats.timestamp < now) { - const totalDiff = currentCpuTimes.total - prevCpuStats.total; - const idleDiff = currentCpuTimes.idle - prevCpuStats.idle; - const timeDiffMs = now - prevCpuStats.timestamp; // Time difference in ms - - // Ensure positive difference and minimal time gap (e.g., > 100ms) to avoid division by zero or erratic results - if (totalDiff > 0 && timeDiffMs > 100) { - const usageRatio = 1.0 - (idleDiff / totalDiff); - // Clamp value between 0 and 100, format to 1 decimal place - status.cpuPercent = parseFloat((Math.max(0, Math.min(100, usageRatio * 100))).toFixed(1)); - } else { - // If totalDiff is not positive or time gap too small, report 0 or keep previous value? - // Reporting 0 might be misleading if the system is actually busy but no change was detected in the short interval. - // Let's keep the previous value if available, otherwise 0. - status.cpuPercent = prevCpuStats?.total > 0 ? status.cpuPercent : 0; // Keep existing status.cpuPercent if valid prev exists, else 0 - } - } else { - // First run or timestamp issue, report 0 as we can't calculate a rate - status.cpuPercent = 0; - } - // Store current stats for the next iteration - this.previousCpuStats.set(sessionId, { ...currentCpuTimes, timestamp: now }); - } else { - // Failed to parse /proc/stat, set to undefined or keep previous? Let's use undefined. - status.cpuPercent = undefined; - } - } catch (err) { - // Failed to execute cat /proc/stat - status.cpuPercent = undefined; - // console.warn(`[StatusMonitor ${sessionId}] Failed to get CPU stats via /proc/stat:`, err); - } - - try { - const uptimeOutput = await this.executeSshCommand(sshClient, 'uptime'); - const match = uptimeOutput.match(/load average(?:s)?:\s*([\d.]+)[, ]?\s*([\d.]+)[, ]?\s*([\d.]+)/); - if (match) status.loadAvg = [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3])]; - } catch (err) { /* 静默处理 */ } - - - try { - const currentStats = await this.parseProcNetDev(sshClient); - if (currentStats) { - const defaultInterface = await this.getDefaultInterface(sshClient) || Object.keys(currentStats).find(iface => iface !== 'lo'); // Detect or fallback excluding loopback - - if (defaultInterface && currentStats[defaultInterface]) { - status.netInterface = defaultInterface; - const currentRx = currentStats[defaultInterface].rx_bytes; - const currentTx = currentStats[defaultInterface].tx_bytes; - status.netRxTotalBytes = currentRx; - status.netTxTotalBytes = currentTx; - const prevStats = previousNetStats.get(sessionId); - - if (prevStats && prevStats.timestamp < timestamp) { - const timeDiffSeconds = (timestamp - prevStats.timestamp) / 1000; - if (timeDiffSeconds > 0.1) { - status.netRxRate = Math.max(0, Math.round((currentRx - prevStats.rx) / timeDiffSeconds)); - status.netTxRate = Math.max(0, Math.round((currentTx - prevStats.tx) / timeDiffSeconds)); - } else { status.netRxRate = 0; status.netTxRate = 0; } - } else { status.netRxRate = 0; status.netTxRate = 0; } - - previousNetStats.set(sessionId, { rx: currentRx, tx: currentTx, timestamp }); - } else { /* 静默处理 */ } - } - } catch (err) { /* 静默处理 */ } - - } catch (error) { - console.error(`[StatusMonitor ${sessionId}] General error fetching server status:`, error); - } - - return status as ServerStatus; - } - - /** - * 解析 /proc/net/dev 的输出 - * @param sshClient SSH 客户端实例 - * @returns Promise 解析后的网络统计信息或 null - */ - private async parseProcNetDev(sshClient: Client): Promise { - let output: string; - try { - // 将命令执行放入 try...catch - output = await this.executeSshCommand(sshClient, 'cat /proc/net/dev'); - } catch (error) { - // 如果命令失败,记录警告并返回 null - - return null; - } - // 如果命令成功,继续解析 - try { - const lines = output.split('\n').slice(2); // Skip header lines - const stats: NetworkStats = {}; - for (const line of lines) { - const parts = line.trim().split(/:\s+|\s+/); - if (parts.length < 17) continue; - const interfaceName = parts[0]; - const rx_bytes = parseInt(parts[1], 10); - const tx_bytes = parseInt(parts[9], 10); - if (!isNaN(rx_bytes) && !isNaN(tx_bytes)) { - stats[interfaceName] = { rx_bytes, tx_bytes }; - } - } - return Object.keys(stats).length > 0 ? stats : null; - } catch (parseError) { - return null; - } - } - - /** - * 获取默认网络接口名称 (Linux specific) - * @param sshClient SSH 客户端实例 - * @returns Promise 默认接口名称或 null - */ private async getDefaultInterface(sshClient: Client): Promise { try { - // 使用 ip route 命令查找默认路由对应的接口 const output = await this.executeSshCommand(sshClient, "ip route get 1.1.1.1 | grep -oP 'dev\\s+\\K\\S+'"); const interfaceName = output.trim(); - if (interfaceName) return interfaceName; - // 如果 ip route 没返回有效接口名,也尝试 fallback - - - } catch (error) { - - try { - const netDevOutput = await this.executeSshCommand(sshClient, 'cat /proc/net/dev'); - const lines = netDevOutput.split('\n').slice(2); - for (const line of lines) { - const iface = line.trim().split(':')[0]; - if (iface && iface !== 'lo') { - return iface; - } - } - } catch (fallbackError) { - + if (interfaceName) { + return interfaceName; + } + } catch (error) { + try { + const netDevOutput = await this.executeSshCommand(sshClient, 'cat /proc/net/dev'); + for (const line of netDevOutput.split('\n').slice(2)) { + const iface = line.trim().split(':')[0]; + if (iface && iface !== 'lo') { + return iface; + } + } + } catch (fallbackError) { + return null; } - - return null; } return null; @@ -577,13 +496,6 @@ export class StatusMonitorService { return normalized; } - /** - * 在 SSH 连接上执行单个命令 - * @param sshClient SSH 客户端实例 - * @param command 要执行的命令 - * @returns Promise 命令的标准输出 - * @throws Error 如果命令执行失败 - */ private executeSshCommand(sshClient: Client, command: string): Promise { return new Promise((resolve, reject) => { let output = ''; @@ -591,70 +503,39 @@ export class StatusMonitorService { if (err) { return reject(new Error(`执行命令 '${command}' 失败: ${err.message}`)); } - stream.on('close', (code: number, signal?: string) => { - resolve(output.trim()); - }).on('data', (data: Buffer) => { - output += data.toString('utf8'); - }).stderr.on('data', (data: Buffer) => { - }); + + stream + .on('close', () => resolve(output.trim())) + .on('data', (data: Buffer) => { + output += data.toString('utf8'); + }) + .stderr.on('data', () => { + return; + }); }); }); } - /** - * 查找与给定 SSH 客户端关联的会话 ID (辅助函数) - * @param sshClientToFind 要查找的 SSH 客户端实例 - * @returns string | undefined 找到的会话 ID 或 undefined - */ - private findSessionIdForClient(sshClientToFind: Client): string | undefined { - for (const [sessionId, state] of this.clientStates.entries()) { - if (state.sshClient === sshClientToFind) { - return sessionId; - } - } - return undefined; - } - - /** - * Parses the output of /proc/stat to get total and idle CPU times. - * @param output The string output from `cat /proc/stat`. - * @returns An object with total and idle times, or null if parsing fails. - */ - private parseProcStat(output: string): { total: number, idle: number } | null { + private parseProcStat(output: string): { total: number; idle: number } | null { try { - const lines = output.split('\n'); - // Find the line starting with "cpu " (aggregate of all cores) - const cpuLine = lines.find(line => line.startsWith('cpu ')); + const cpuLine = output.split('\n').find(line => line.startsWith('cpu ')); if (!cpuLine) { - // console.warn("Could not find 'cpu ' line in /proc/stat"); return null; } - // Fields documented in `man proc`: cpu user nice system idle iowait irq softirq steal guest guest_nice - // We need to handle potential missing fields at the end (guest times are not always present) - const fieldsStr = cpuLine.trim().split(/\s+/).slice(1); // Remove 'cpu' prefix - const fields = fieldsStr.map(Number); // Convert remaining fields to numbers - - // We need at least the first 4 fields (user, nice, system, idle) + const fields = cpuLine.trim().split(/\s+/).slice(1).map(Number); if (fields.length < 4 || fields.slice(0, 4).some(isNaN)) { - // console.warn("Invalid format or missing required fields in 'cpu ' line:", cpuLine); return null; } - const idle = fields[3]; // The 4th field (index 3) is idle time - - // Total time is the sum of all fields. Filter out NaN values just in case. + const idle = fields[3]; const total = fields.reduce((sum, value) => sum + (isNaN(value) ? 0 : value), 0); - - // Final check for NaN just to be safe if (isNaN(total) || isNaN(idle)) { - // console.warn("NaN detected after parsing /proc/stat fields:", fields); return null; } return { total, idle }; } catch (e) { - // console.error("Error parsing /proc/stat:", e); return null; } } diff --git a/packages/frontend/src/components/StatusMonitor.vue b/packages/frontend/src/components/StatusMonitor.vue index 4977b89..8c8389c 100644 --- a/packages/frontend/src/components/StatusMonitor.vue +++ b/packages/frontend/src/components/StatusMonitor.vue @@ -1,148 +1,190 @@ - diff --git a/packages/frontend/src/components/TerminalTabBar.vue b/packages/frontend/src/components/TerminalTabBar.vue index 1a98088..dcf9b9b 100644 --- a/packages/frontend/src/components/TerminalTabBar.vue +++ b/packages/frontend/src/components/TerminalTabBar.vue @@ -64,37 +64,38 @@ const showConnectionListPopup = ref(false); // 连接列表弹出状态 const draggableSessions = ref([]); // + Local state for draggable const showTransferProgressModal = ref(false); // 控制传输进度模态框的显示状态 -const activeSessionState = computed(() => { - if (!props.activeSessionId) { - return null; - } +const openConnectionPicker = () => { + showConnectionListPopup.value = true; +}; - return sessionStore.sessions.get(props.activeSessionId) ?? null; -}); +const getSessionAtIndex = (index: number) => draggableSessions.value[index] ?? null; -const activeConnectionInfo = computed(() => { - const activeSession = activeSessionState.value; - if (!activeSession) { - return null; - } +const isGroupStart = (index: number) => { + const currentSession = getSessionAtIndex(index); + const previousSession = getSessionAtIndex(index - 1); - return connectionsStore.connections.find((connection) => connection.id === Number(activeSession.connectionId)) ?? null; -}); + return Boolean(currentSession && (!previousSession || previousSession.connectionId !== currentSession.connectionId)); +}; -const canAddTerminalToActiveConnection = computed(() => activeConnectionInfo.value?.type === 'SSH'); +const isGroupEnd = (index: number) => { + const currentSession = getSessionAtIndex(index); + const nextSession = getSessionAtIndex(index + 1); -const openNewTerminalForActiveConnection = () => { - const activeConnection = activeConnectionInfo.value; - if (!activeConnection || activeConnection.type !== 'SSH') { - showConnectionListPopup.value = true; + return Boolean(currentSession && (!nextSession || nextSession.connectionId !== currentSession.connectionId)); +}; + +const getConnectionInfoById = (connectionId: string) => + connectionsStore.connections.find((connection) => connection.id === Number(connectionId)) ?? null; + +const canOpenSiblingTerminal = (connectionId: string) => getConnectionInfoById(connectionId)?.type === 'SSH'; + +const openNewTerminalForConnection = (connectionId: string) => { + const connectionInfo = getConnectionInfoById(connectionId); + if (!connectionInfo || connectionInfo.type !== 'SSH') { return; } - sessionStore.handleOpenNewSession(activeConnection.id); -}; - -const openConnectionPicker = () => { - showConnectionListPopup.value = true; + sessionStore.handleOpenNewSession(connectionInfo.id); }; // + Watch prop changes to update local state @@ -477,52 +478,74 @@ onBeforeUnmount(() => { animation="150" :disabled="props.isMobile" > -