From d730d06c5e5e346a0719b3ff0ef2cc7b8fd5f89b Mon Sep 17 00:00:00 2001 From: yinjianm Date: Wed, 25 Mar 2026 22:25:37 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(workspace):=20=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E8=BF=9E=E6=8E=A5=E7=AE=A1=E7=90=86=E4=B8=8E=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E7=8A=B6=E6=80=81=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为连接管理页补充多级标签树、列头排序和行级更多菜单 - 支持同一 SSH 连接打开多个终端并显示终端序号 - 补充状态监控的内存与磁盘详情字段 --- ✨ feat(workspace): enhance connection management and terminal status visibility - add multi-level tag tree, sortable columns, and row-level more menu - support multiple terminals per SSH connection with terminal indices - extend status monitor with memory and disk detail fields --- .helloagents/CHANGELOG.md | 4 + .helloagents/INDEX.md | 4 +- .../.status.json | 1 + .../proposal.md | 65 +++ .../tasks.md | 48 ++ .helloagents/archive/_index.md | 4 + .helloagents/modules/frontend.md | 4 +- .../src/services/status-monitor.service.ts | 177 ++++++- .../src/components/TerminalTabBar.vue | 69 ++- packages/frontend/src/locales/en-US.json | 5 +- packages/frontend/src/locales/ja-JP.json | 5 +- packages/frontend/src/locales/zh-CN.json | 5 +- .../stores/session/actions/sessionActions.ts | 18 +- .../frontend/src/stores/session/getters.ts | 8 +- packages/frontend/src/stores/session/types.ts | 5 +- .../frontend/src/views/ConnectionsView.vue | 448 ++++++++++++++++-- 16 files changed, 798 insertions(+), 72 deletions(-) create mode 100644 .helloagents/archive/2026-03/202603252152_connections-tree-sort-more-menu/.status.json create mode 100644 .helloagents/archive/2026-03/202603252152_connections-tree-sort-more-menu/proposal.md create mode 100644 .helloagents/archive/2026-03/202603252152_connections-tree-sort-more-menu/tasks.md diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index bab982b..d0ae488 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -22,3 +22,7 @@ - 方案: [202603250614_terminal-ansi-color-effects](archive/2026-03/202603250614_terminal-ansi-color-effects/) - **[frontend]**: 将连接管理页升级为左侧标签树、顶部搜索工具条和右侧结果列表的双栏管理台 — by yinjianm - 方案: [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]**: 为同一 SSH 服务器连接补充多终端入口与终端序号标识,默认首次仍只打开一个终端 — by yinjianm + - 方案: [202603252207_ssh-connection-multi-terminal](archive/2026-03/202603252207_ssh-connection-multi-terminal/) diff --git a/.helloagents/INDEX.md b/.helloagents/INDEX.md index 23e914b..5c0e47f 100644 --- a/.helloagents/INDEX.md +++ b/.helloagents/INDEX.md @@ -31,9 +31,9 @@ ```yaml kb_version: 2.3.7 -最后更新: 2026-03-25 06:46 +最后更新: 2026-03-25 22:19 模块数量: 4 -待执行方案: 0 +待执行方案: 2 ``` ## 读取指引 diff --git a/.helloagents/archive/2026-03/202603252152_connections-tree-sort-more-menu/.status.json b/.helloagents/archive/2026-03/202603252152_connections-tree-sort-more-menu/.status.json new file mode 100644 index 0000000..b9a8794 --- /dev/null +++ b/.helloagents/archive/2026-03/202603252152_connections-tree-sort-more-menu/.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:13:10"} diff --git a/.helloagents/archive/2026-03/202603252152_connections-tree-sort-more-menu/proposal.md b/.helloagents/archive/2026-03/202603252152_connections-tree-sort-more-menu/proposal.md new file mode 100644 index 0000000..dc4f861 --- /dev/null +++ b/.helloagents/archive/2026-03/202603252152_connections-tree-sort-more-menu/proposal.md @@ -0,0 +1,65 @@ +# 变更提案: connections-tree-sort-more-menu + +## 元信息 +```yaml +类型: 功能增强 +方案类型: implementation +优先级: P1 +状态: 已完成 +状态说明: 已完成连接管理页多级标签树、列头排序和更多菜单增强,并通过前端构建验证 +创建: 2026-03-25 +``` + +--- + +## 1. 需求 + +### 背景 +连接管理页上一轮已经升级为左侧范围树、顶部工具条和右侧结果列表,但离参考图仍有明显差距:标签仍是单层列表,右侧列头无法直接排序,单行操作也缺少“更多”菜单承载克隆/删除等次级动作。 + +### 目标 +- 将左侧标签区升级为支持层级展开的多级标签树。 +- 将右侧结果列表升级为可点击列头排序。 +- 为每一行补上“更多”菜单,承载克隆、删除等次级操作。 + +### 约束条件 +```yaml +范围约束: 优先限制在 ConnectionsView.vue,不改后端接口和标签表结构 +兼容约束: 保留现有连接、编辑、测试、批量编辑和批量删除能力 +数据约束: 多级树基于现有标签名称推导,不新增 hierarchy 字段 +视觉约束: 延续当前黑绿主题和现有双栏结构,不新增仓库入口 +``` + +### 验收标准 +- [ ] 左侧标签区支持按层级展开/折叠浏览 +- [ ] 右侧列表列头可点击切换排序字段和方向 +- [ ] 每一行提供“更多”菜单,并可执行至少克隆和删除动作 +- [ ] 前端构建通过 + +--- + +## 2. 方案 + +### 技术方案 +在 `ConnectionsView.vue` 中把标签列表改成树形数据结构,按标签名称中的层级分隔符推导父子节点;右侧结果列表增加列配置和点击式排序入口,统一映射到已有 `localSortBy` / `localSortOrder` 状态;每一行补充受控下拉菜单,集中放置克隆和删除等次级操作,避免主按钮区继续膨胀。 + +### 影响范围 +```yaml +涉及模块: + - frontend: ConnectionsView.vue +预计变更文件: 1 +``` + +### 风险评估 +| 风险 | 等级 | 应对 | +|------|------|------| +| 现有标签名没有统一层级分隔符,树结构收益不稳定 | 中 | 同时兼容 `/`、`>`、`\\` 三类分隔符,无分隔符时退回单层节点 | +| 列头排序和顶部排序控件共存可能造成状态混乱 | 低 | 统一复用同一组排序状态,并在交互上以列头为主、顶部为辅助 | +| 行内更多菜单与批量模式选择冲突 | 低 | 菜单按钮在批量模式下保持可见但不触发行点击选择,事件显式 stopPropagation | + +### 实施结果 +- `ConnectionsView.vue` 已基于现有标签名推导出多级树结构,支持 `/`、`>`、`\\` 分隔的层级路径,并持久化展开状态。 +- 左侧树节点现在支持分组范围选择,`group:*` scope 会在刷新后继续恢复,不再退回“全部”。 +- 右侧结果列表新增名称、地址、上次连接三列的点击式排序,并继续复用顶部排序状态。 +- 每一行新增“更多”菜单,已接入克隆和删除动作,且通过 `stopPropagation` 避免与批量选择冲突。 +- `npm run build --workspace @nexus-terminal/frontend` 通过。 diff --git a/.helloagents/archive/2026-03/202603252152_connections-tree-sort-more-menu/tasks.md b/.helloagents/archive/2026-03/202603252152_connections-tree-sort-more-menu/tasks.md new file mode 100644 index 0000000..45ed7fa --- /dev/null +++ b/.helloagents/archive/2026-03/202603252152_connections-tree-sort-more-menu/tasks.md @@ -0,0 +1,48 @@ +# 任务清单: connections-tree-sort-more-menu + +```yaml +@feature: connections-tree-sort-more-menu +@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 21:52 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为 ConnectionsView.vue 的树形筛选与列表增强 | +| 2026-03-25 22:05 | 2.1 / 2.2 | 完成 | 实现多级标签树、展开状态持久化、列头排序与行级更多菜单,并补齐 group scope 刷新恢复 | +| 2026-03-25 22:13 | 3.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 通过,并同步知识库与归档记录 | + +--- + +## 执行备注 + +- 本轮是 `connections-view-tree-search-redesign` 的后续增强,目标是继续向参考图靠拢,但仍限制在单页增量改造。 +- 标签层级仍由现有标签名即时推导,不新增后端 hierarchy 字段,也不引入新的仓库视图入口。 diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md index 791fd9a..fb6710a 100644 --- a/.helloagents/archive/_index.md +++ b/.helloagents/archive/_index.md @@ -13,6 +13,8 @@ | 202603250603 | dark-green-night-theme | implementation | frontend, backend | - | ✅完成 | | 202603250614 | terminal-ansi-color-effects | implementation | frontend, backend | - | ✅完成 | | 202603250636 | connections-view-tree-search-redesign | implementation | frontend | - | ✅完成 | +| 202603252152 | connections-tree-sort-more-menu | implementation | frontend | - | ✅完成 | +| 202603252207 | ssh-connection-multi-terminal | implementation | frontend | ssh-connection-multi-terminal#D001 | ✅完成 | | 202603251200 | workspace-workbench-monitor | implementation | frontend, backend | workspace-workbench-monitor#D001 | ✅完成 | ## 按月归档 @@ -24,6 +26,8 @@ - [202603250603_dark-green-night-theme](./2026-03/202603250603_dark-green-night-theme/) - 将黑暗模式预设与终端默认主题统一调整为黑绿夜间风格 - [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/) - 为连接管理页补充多级标签树、列头排序和行级更多菜单 +- [202603252207_ssh-connection-multi-terminal](./2026-03/202603252207_ssh-connection-multi-terminal/) - 为同一 SSH 服务器连接补充多终端入口与终端序号标识 - [202603251200_workspace-workbench-monitor](./2026-03/202603251200_workspace-workbench-monitor/) - `/workspace` 改为三栏 Workbench 布局,并新增开机累计流量监控 ## 结果状态说明 diff --git a/.helloagents/modules/frontend.md b/.helloagents/modules/frontend.md index 42cecd8..06a9d19 100644 --- a/.helloagents/modules/frontend.md +++ b/.helloagents/modules/frontend.md @@ -36,8 +36,8 @@ ### 工作区交互 **条件**: 用户进入 `/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 彩色输出;`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,左侧支持全部/未标记/标签树切换,右侧列表与类型筛选、搜索、排序、批量编辑和批量删除共用同一过滤管线;样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。 -**结果**: 页面逻辑分散在 `views/`、`components/`、`stores/` 与 `composables/`,其中布局与交互微调优先落在 `layout.store.ts`、`LayoutRenderer.vue`、`WorkspaceWorkbench.vue`、`QuickCommandsView.vue`、`ConnectionsView.vue`、`Terminal.vue`、`StyleCustomizerUiTab.vue`、`StyleCustomizerTerminalTab.vue` 和 `features/appearance/config/default-themes.ts`。 +**行为**: 通过组件、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 文件。 ## 依赖关系 diff --git a/packages/backend/src/services/status-monitor.service.ts b/packages/backend/src/services/status-monitor.service.ts index 6fd00d9..318dfb4 100644 --- a/packages/backend/src/services/status-monitor.service.ts +++ b/packages/backend/src/services/status-monitor.service.ts @@ -9,12 +9,20 @@ interface ServerStatus { memPercent?: number; memUsed?: number; // MB memTotal?: number; // MB + memFree?: number; // MB + memCached?: number; // MB swapPercent?: number; swapUsed?: number; // MB swapTotal?: number; // MB diskPercent?: number; diskUsed?: number; // KB diskTotal?: number; // KB + diskAvailable?: number; // KB + diskMountPoint?: string; + diskFsType?: string; + diskDevice?: string; + diskReadRate?: number; // Bytes per second + diskWriteRate?: number; // Bytes per second cpuModel?: string; netRxRate?: number; // Bytes per second netTxRate?: number; // Bytes per second @@ -34,9 +42,17 @@ interface NetworkStats { } } +interface DiskIoStats { + [deviceName: string]: { + readBytes: number; + writeBytes: number; + } +} + // 用于存储上一次的网络统计信息以计算速率 const previousNetStats = new Map(); +const previousDiskStats = new Map(); export class StatusMonitorService { private clientStates: Map; // 使用导入的 ClientState @@ -47,6 +63,32 @@ export class StatusMonitorService { this.clientStates = clientStates; } + private async parseProcDiskStats(sshClient: Client): Promise { + try { + const output = await this.executeSshCommand(sshClient, 'cat /proc/diskstats'); + const stats: DiskIoStats = {}; + + for (const line of output.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length < 10) continue; + + const deviceName = parts[2]; + const sectorsRead = parseInt(parts[5], 10); + const sectorsWritten = parseInt(parts[9], 10); + if (!isNaN(sectorsRead) && !isNaN(sectorsWritten)) { + stats[deviceName] = { + readBytes: sectorsRead * 512, + writeBytes: sectorsWritten * 512, + }; + } + } + + return Object.keys(stats).length > 0 ? stats : null; + } catch (error) { + return null; + } + } + /** * 启动指定会话的状态轮询 * @param sessionId 会话 ID @@ -88,6 +130,7 @@ export class StatusMonitorService { clearInterval(state.statusIntervalId); state.statusIntervalId = undefined; previousNetStats.delete(sessionId); // 清理网络统计缓存 + previousDiskStats.delete(sessionId); this.previousCpuStats.delete(sessionId); // 清理 CPU 统计缓存 } } @@ -170,22 +213,52 @@ export class StatusMonitorService { } 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) { + 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 >= 3) { + 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 (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; } } @@ -216,12 +289,12 @@ export class StatusMonitorService { try { - let dfCommand = "df -kP /"; // 优先尝试 POSIX 标准格式 + let dfCommand = "df -kPT /"; let dfOutput: string; try { dfOutput = await this.executeSshCommand(sshClient, dfCommand); } catch (errP) { - dfCommand = "df -k /"; // 备用方案 + dfCommand = "df -kP /"; try { dfOutput = await this.executeSshCommand(sshClient, dfCommand); } catch (errK) { @@ -231,13 +304,38 @@ export class StatusMonitorService { 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(" /")) { - const parts = line.split(/\s+/); + 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) { @@ -259,6 +357,46 @@ export class StatusMonitorService { } } + 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 @@ -414,6 +552,31 @@ export class StatusMonitorService { return null; } + private normalizeDiskDevice(rawDevice?: string): string | undefined { + if (!rawDevice) { + return undefined; + } + + let normalized = rawDevice.trim(); + if (!normalized) { + return undefined; + } + + if (normalized.startsWith('/dev/')) { + normalized = normalized.slice(5); + } + + if (/^(sd[a-z]+|vd[a-z]+|xvd[a-z]+|hd[a-z]+)\d+$/.test(normalized)) { + return normalized.replace(/\d+$/, ''); + } + + if (/^(nvme\d+n\d+|mmcblk\d+)p\d+$/.test(normalized)) { + return normalized.replace(/p\d+$/, ''); + } + + return normalized; + } + /** * 在 SSH 连接上执行单个命令 * @param sshClient SSH 客户端实例 diff --git a/packages/frontend/src/components/TerminalTabBar.vue b/packages/frontend/src/components/TerminalTabBar.vue index 457601b..1a98088 100644 --- a/packages/frontend/src/components/TerminalTabBar.vue +++ b/packages/frontend/src/components/TerminalTabBar.vue @@ -64,6 +64,39 @@ const showConnectionListPopup = ref(false); // 连接列表弹出状态 const draggableSessions = ref([]); // + Local state for draggable const showTransferProgressModal = ref(false); // 控制传输进度模态框的显示状态 +const activeSessionState = computed(() => { + if (!props.activeSessionId) { + return null; + } + + return sessionStore.sessions.get(props.activeSessionId) ?? null; +}); + +const activeConnectionInfo = computed(() => { + const activeSession = activeSessionState.value; + if (!activeSession) { + return null; + } + + return connectionsStore.connections.find((connection) => connection.id === Number(activeSession.connectionId)) ?? null; +}); + +const canAddTerminalToActiveConnection = computed(() => activeConnectionInfo.value?.type === 'SSH'); + +const openNewTerminalForActiveConnection = () => { + const activeConnection = activeConnectionInfo.value; + if (!activeConnection || activeConnection.type !== 'SSH') { + showConnectionListPopup.value = true; + return; + } + + sessionStore.handleOpenNewSession(activeConnection.id); +}; + +const openConnectionPicker = () => { + showConnectionListPopup.value = true; +}; + // + Watch prop changes to update local state watch(() => props.sessions, (newSessions) => { // Create a shallow copy to avoid modifying the prop directly @@ -174,6 +207,22 @@ const handleContextMenuAction = (payload: { action: string; targetId: string | n // 注意:关闭左侧通常不包括当前标签本身 emitWorkspaceEvent('session:closeToLeft', { targetSessionId: targetId }); break; + case 'new-terminal': { + const targetSessionState = sessionStore.sessions.get(targetId); + if (!targetSessionState) { + console.warn(`[TabBar] 'new-terminal' action failed: session ${targetId} not found.`); + break; + } + + const targetConnectionInfo = connectionsStore.connections.find(c => c.id === Number(targetSessionState.connectionId)); + if (!targetConnectionInfo || targetConnectionInfo.type !== 'SSH') { + console.warn(`[TabBar] 'new-terminal' action ignored for non-SSH connection. targetId=${targetId}`); + break; + } + + sessionStore.handleOpenNewSession(targetConnectionInfo.id); + break; + } case 'mark-for-suspend': // +++ 修改 action 名称 +++ if (typeof targetId === 'string') { console.log(`[TabBar] Context menu action 'mark-for-suspend' requested for session ID: ${targetId}`); @@ -213,6 +262,7 @@ const contextMenuItems = computed(() => { // 添加标记/取消标记挂起会话菜单项(如果适用) if (connectionInfo && connectionInfo.type === 'SSH') { + items.push({ label: 'terminalTabBar.newTerminalTooltip', action: 'new-terminal' }); const isActiveSession = targetSessionState.wsManager.isConnected.value; if (isActiveSession) { // 只对活动的SSH会话显示相关操作 if (targetSessionState.isMarkedForSuspend) { @@ -446,6 +496,12 @@ onBeforeUnmount(() => { session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' : session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"> {{ session.connectionName }} + + {{ session.terminalIndex }} + + +
diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 29a81b6..17922be 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -1457,7 +1457,10 @@ }, "terminalTabBar": { "selectServerTitle": "Select server to connect", - "showTransferProgressTooltip": "Show/Hide Transfer Progress" + "showTransferProgressTooltip": "Show/Hide Transfer Progress", + "newTerminalTooltip": "Open another terminal for the current server", + "openConnectionPickerTooltip": "Choose another server", + "terminalBadge": "Terminal {index}" }, "tabs": { "contextMenu": { diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index a133812..46dfaad 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -1419,7 +1419,10 @@ }, "terminalTabBar": { "selectServerTitle": "接続するサーバーを選択", - "showTransferProgressTooltip": "転送進捗の表示/非表示" + "showTransferProgressTooltip": "転送進捗の表示/非表示", + "newTerminalTooltip": "現在のサーバーに新しいターミナルを追加", + "openConnectionPickerTooltip": "別のサーバーを選択", + "terminalBadge": "端末 {index}" }, "workspace": { "terminal": { diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index 45da27d..4c88696 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -1461,7 +1461,10 @@ }, "terminalTabBar": { "selectServerTitle": "选择要连接的服务器", - "showTransferProgressTooltip": "显示/隐藏传输进度" + "showTransferProgressTooltip": "显示/隐藏传输进度", + "newTerminalTooltip": "为当前服务器新增终端", + "openConnectionPickerTooltip": "选择其他服务器", + "terminalBadge": "终端 {index}" }, "tabs": { "contextMenu": { diff --git a/packages/frontend/src/stores/session/actions/sessionActions.ts b/packages/frontend/src/stores/session/actions/sessionActions.ts index 407e40c..4cb1c9c 100644 --- a/packages/frontend/src/stores/session/actions/sessionActions.ts +++ b/packages/frontend/src/stores/session/actions/sessionActions.ts @@ -21,6 +21,18 @@ const findConnectionInfo = (connectionId: number | string, connectionsStore: Ret return connectionsStore.connections.find(c => c.id === Number(connectionId)); }; +const getNextTerminalIndex = (connectionId: string): number => { + let maxTerminalIndex = 0; + + sessions.value.forEach((session) => { + if (session.connectionId === connectionId) { + maxTerminalIndex = Math.max(maxTerminalIndex, session.terminalIndex || 0); + } + }); + + return maxTerminalIndex + 1; +}; + // --- Actions --- export const openNewSession = ( connectionOrId: ConnectionInfo | number | string, @@ -51,6 +63,7 @@ export const openNewSession = ( const newSessionId = existingSessionId || generateSessionId(); const dbConnId = String(connInfo.id); // connInfo is now guaranteed to be defined here + const terminalIndex = getNextTerminalIndex(dbConnId); // 1. 创建管理器实例 const isResume = !!existingSessionId; // 如果提供了 existingSessionId,则为恢复流程 @@ -60,6 +73,7 @@ export const openNewSession = ( sessionId: newSessionId, connectionId: dbConnId, connectionName: connInfo.name || connInfo.host, + terminalIndex, editorTabs: ref([]), activeEditorTabId: ref(null), commandInputContent: ref(''), @@ -115,7 +129,7 @@ export const openNewSession = ( newSessionsMap.set(newSessionId, newSession); sessions.value = newSessionsMap; activeSessionId.value = newSessionId; - console.log(`[SessionActions] 已创建新会话实例: ${newSessionId} for connection ${dbConnId}`); + console.log(`[SessionActions] 已创建新会话实例: ${newSessionId} for connection ${dbConnId} (terminal #${terminalIndex})`); // +++ 在连接前设置 ssh:connected 处理器以更新 sessionId +++ const originalFrontendSessionIdForHandler = newSessionId; // 捕获初始ID给闭包 @@ -320,4 +334,4 @@ export const cleanupAllSessions = () => { sessions.value = newSessionsMap; } activeSessionId.value = null; -}; \ No newline at end of file +}; diff --git a/packages/frontend/src/stores/session/getters.ts b/packages/frontend/src/stores/session/getters.ts index 600ce02..78ec71f 100644 --- a/packages/frontend/src/stores/session/getters.ts +++ b/packages/frontend/src/stores/session/getters.ts @@ -7,7 +7,9 @@ import type { SessionState, SessionTabInfoWithStatus } from './types'; export const sessionTabs = computed(() => { return Array.from(sessions.value.values()).map(session => ({ sessionId: session.sessionId, + connectionId: session.connectionId, connectionName: session.connectionName, + terminalIndex: session.terminalIndex, })); }); @@ -39,7 +41,9 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] => }) .map(session => ({ sessionId: session.sessionId, + connectionId: session.connectionId, connectionName: session.connectionName, + terminalIndex: session.terminalIndex, status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态 isMarkedForSuspend: session.isMarkedForSuspend, })); @@ -49,7 +53,9 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] => .sort((a, b) => a.createdAt - b.createdAt) .map(session => ({ sessionId: session.sessionId, + connectionId: session.connectionId, connectionName: session.connectionName, + terminalIndex: session.terminalIndex, status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态 isMarkedForSuspend: session.isMarkedForSuspend, })); @@ -59,4 +65,4 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] => export const activeSession = computed((): SessionState | null => { if (!activeSessionId.value) return null; return sessions.value.get(activeSessionId.value) || null; -}); \ No newline at end of file +}); diff --git a/packages/frontend/src/stores/session/types.ts b/packages/frontend/src/stores/session/types.ts index 5aef33e..f47ec8d 100644 --- a/packages/frontend/src/stores/session/types.ts +++ b/packages/frontend/src/stores/session/types.ts @@ -29,6 +29,7 @@ export interface SessionState { sessionId: string; connectionId: string; // 数据库中的连接 ID connectionName: string; // 用于显示 + terminalIndex: number; // 同一连接下的终端序号,从 1 开始 wsManager: WsManagerInstance; sftpManagers: Map; // 使用 Map 管理多个实例 terminalManager: SshTerminalInstance; @@ -49,7 +50,9 @@ export interface SessionState { // 为标签栏定义包含状态的类型 export interface SessionTabInfoWithStatus { sessionId: string; + connectionId: string; connectionName: string; + terminalIndex: number; status: WsConnectionStatus; // 添加状态字段 isMarkedForSuspend?: boolean; // +++ 用于UI指示会话是否已标记待挂起 +++ -} \ No newline at end of file +} diff --git a/packages/frontend/src/views/ConnectionsView.vue b/packages/frontend/src/views/ConnectionsView.vue index d7fcbc5..9b10390 100644 --- a/packages/frontend/src/views/ConnectionsView.vue +++ b/packages/frontend/src/views/ConnectionsView.vue @@ -1,5 +1,5 @@