From 1081c7325461b77cfda9400e58ce835e85d8f219 Mon Sep 17 00:00:00 2001 From: yinjianm Date: Thu, 26 Mar 2026 00:17:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(connection):=20=E6=94=AF=E6=8C=81=E5=B7=B2?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E7=99=BB=E5=BD=95=E5=87=AD=E8=AF=81=E5=B9=B6?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=A6=96=E9=A1=B5=E4=BB=AA=E8=A1=A8=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增登录凭证管理接口、数据表与前端选择器,连接创建、 编辑和测试现已支持复用已保存凭证 重构首页为管理驾驶舱,增加统计卡片、趋势与分布图、 活跃连接排行,并通过 summary 聚合数据统一驱动 --- .helloagents/CHANGELOG.md | 4 + .../.status.json | 10 + .../proposal.md | 6 +- .../tasks.md | 54 ++ .helloagents/archive/_index.md | 2 + .helloagents/modules/backend.md | 5 + .helloagents/modules/frontend.md | 5 + .../tasks.md | 50 -- .../src/connections/connection.repository.ts | 39 +- .../src/connections/connection.service.ts | 278 +++---- .../src/connections/connections.controller.ts | 86 ++- packages/backend/src/database/migrations.ts | 28 + .../backend/src/database/schema.registry.ts | 3 +- packages/backend/src/database/schema.ts | 24 +- packages/backend/src/index.ts | 2 + .../login-credential.repository.ts | 101 +++ .../login-credential.service.ts | 187 +++++ .../login-credentials.controller.ts | 105 +++ .../login-credentials.routes.ts | 21 + packages/backend/src/services/ssh.service.ts | 34 +- .../backend/src/types/connection.types.ts | 10 +- .../src/types/login-credential.types.ts | 47 ++ .../src/components/AddConnectionFormAuth.vue | 37 +- .../src/components/DashboardOverviewPanel.vue | 432 +++++++++++ .../LoginCredentialManagementModal.vue | 337 +++++++++ .../components/LoginCredentialSelector.vue | 92 +++ .../src/composables/useAddConnectionForm.ts | 40 +- packages/frontend/src/locales/en-US.json | 34 +- packages/frontend/src/locales/ja-JP.json | 34 +- packages/frontend/src/locales/zh-CN.json | 34 +- .../frontend/src/stores/dashboard.store.ts | 59 ++ .../src/stores/loginCredentials.store.ts | 130 ++++ packages/frontend/src/types/server.types.ts | 53 +- packages/frontend/src/views/DashboardView.vue | 692 +++++++++--------- 34 files changed, 2472 insertions(+), 603 deletions(-) create mode 100644 .helloagents/archive/2026-03/202603252343_dashboard-management-cockpit/.status.json rename .helloagents/{plan => archive/2026-03}/202603252343_dashboard-management-cockpit/proposal.md (97%) create mode 100644 .helloagents/archive/2026-03/202603252343_dashboard-management-cockpit/tasks.md delete mode 100644 .helloagents/plan/202603252343_dashboard-management-cockpit/tasks.md create mode 100644 packages/backend/src/login-credentials/login-credential.repository.ts create mode 100644 packages/backend/src/login-credentials/login-credential.service.ts create mode 100644 packages/backend/src/login-credentials/login-credentials.controller.ts create mode 100644 packages/backend/src/login-credentials/login-credentials.routes.ts create mode 100644 packages/backend/src/types/login-credential.types.ts create mode 100644 packages/frontend/src/components/DashboardOverviewPanel.vue create mode 100644 packages/frontend/src/components/LoginCredentialManagementModal.vue create mode 100644 packages/frontend/src/components/LoginCredentialSelector.vue create mode 100644 packages/frontend/src/stores/dashboard.store.ts create mode 100644 packages/frontend/src/stores/loginCredentials.store.ts diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index e6f253a..443e5d3 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -18,6 +18,10 @@ - 方案: [202603250614_terminal-ansi-color-effects](archive/2026-03/202603250614_terminal-ansi-color-effects/) ### 新增 +- **[frontend]**: 将首页仪表盘升级为统计卡片、趋势/分布图和活跃连接排行组成的管理驾驶舱 — by yinjianm + - 方案: [202603252343_dashboard-management-cockpit](archive/2026-03/202603252343_dashboard-management-cockpit/) +- **[backend]**: 新增 `/api/v1/dashboard/summary` 聚合接口,统一输出首页所需的连接、审计和 SSH 统计摘要 — by yinjianm + - 方案: [202603252343_dashboard-management-cockpit](archive/2026-03/202603252343_dashboard-management-cockpit/) - **[frontend]**: 将底部命令输入框升级为支持多行草稿与自动增高,并把发送快捷键改为 `Ctrl+Shift+Enter` — by yinjianm - 方案: [202603252340_command-input-multiline-shortcut](archive/2026-03/202603252340_command-input-multiline-shortcut/) - **[frontend]**: 将服务器状态中的内存与磁盘区域升级为卡片化监控视图,补齐环形内存占比、磁盘设备信息、读写速率与挂载表格展示 — by yinjianm diff --git a/.helloagents/archive/2026-03/202603252343_dashboard-management-cockpit/.status.json b/.helloagents/archive/2026-03/202603252343_dashboard-management-cockpit/.status.json new file mode 100644 index 0000000..a82002d --- /dev/null +++ b/.helloagents/archive/2026-03/202603252343_dashboard-management-cockpit/.status.json @@ -0,0 +1,10 @@ +{ + "status": "completed", + "completed": 6, + "failed": 0, + "pending": 0, + "total": 6, + "percent": 100, + "current": "全部任务完成,等待知识库归档", + "updated_at": "2026-03-26 00:08:00" +} diff --git a/.helloagents/plan/202603252343_dashboard-management-cockpit/proposal.md b/.helloagents/archive/2026-03/202603252343_dashboard-management-cockpit/proposal.md similarity index 97% rename from .helloagents/plan/202603252343_dashboard-management-cockpit/proposal.md rename to .helloagents/archive/2026-03/202603252343_dashboard-management-cockpit/proposal.md index 4041f7b..30603f4 100644 --- a/.helloagents/plan/202603252343_dashboard-management-cockpit/proposal.md +++ b/.helloagents/archive/2026-03/202603252343_dashboard-management-cockpit/proposal.md @@ -18,7 +18,7 @@ ### 目标 - 将首页升级为管理驾驶舱式仪表盘,而不再只是列表首页。 -- 新增可快速扫读的统计卡片、近 7 天趋势图、连接类型分布和高频连接排行。 +- 新增可快速扫读的统计卡片、近 7 天趋势图、连接类型分布、事件类型分布和高频连接排行。 - 通过后端聚合接口统一提供 dashboard summary,避免前端依赖多接口拼装复杂统计。 - 保留现有连接列表、最近活动和跳转链路,让首页既能看总览,也能继续执行常用操作。 @@ -32,7 +32,7 @@ ### 验收标准 - [ ] 首页新增核心统计卡片,至少覆盖连接总数、近 7 天活跃连接数、审计日志总数、近 24 小时 SSH 成功/失败次数 -- [ ] 首页新增趋势和分布图表,至少覆盖近 7 天审计事件趋势和连接类型分布 +- [ ] 首页新增趋势和分布图表,至少覆盖近 7 天审计事件趋势、连接类型分布和事件类型分布 - [ ] 首页新增“近期最活跃连接”排行,基于审计日志中的连接事件聚合 - [ ] 后端提供单独的 dashboard summary API,并由前端页面消费 - [ ] `packages/frontend` 与 `packages/backend` 的构建校验通过 @@ -42,7 +42,7 @@ ## 2. 方案 ### 技术方案 -在后端新增 `dashboard` 聚合模块,提供 `GET /api/v1/dashboard/summary` 接口,直接基于 SQLite 中的 `connections`、`connection_tags`、`audit_logs` 数据生成首页所需的统计摘要。前端新增 dashboard store 与 summary 类型定义,重构 `DashboardView.vue`,将当前首页拆分为“统计卡片 + 图表区 + 排行/列表区 + 最近活动”,并继续复用现有连接列表与最近活动交互。图表继续使用仓库已存在的 `chart.js` / `vue-chartjs` 技术栈。 +在后端新增 `dashboard` 聚合模块,提供 `GET /api/v1/dashboard/summary` 接口,直接基于 SQLite 中的 `connections`、`connection_tags`、`audit_logs` 数据生成首页所需的统计摘要。前端新增 dashboard store 与 summary 类型定义,重构 `DashboardView.vue`,将当前首页拆分为“统计卡片 + 图表区 + 排行/列表区 + 最近活动”,并继续复用现有连接列表与最近活动交互。图表继续使用仓库已存在的 `chart.js` / `vue-chartjs` 技术栈,并新增事件类型分布图表用于补足首页对审计行为结构的感知。 ### 影响范围 ```yaml diff --git a/.helloagents/archive/2026-03/202603252343_dashboard-management-cockpit/tasks.md b/.helloagents/archive/2026-03/202603252343_dashboard-management-cockpit/tasks.md new file mode 100644 index 0000000..3c7ab37 --- /dev/null +++ b/.helloagents/archive/2026-03/202603252343_dashboard-management-cockpit/tasks.md @@ -0,0 +1,54 @@ +# 任务清单: dashboard-management-cockpit + +```yaml +@feature: dashboard-management-cockpit +@created: 2026-03-25 +@status: completed +@mode: R2 +``` + +## 进度概览 + +| 完成 | 失败 | 跳过 | 总数 | +|------|------|------|------| +| 6 | 0 | 0 | 6 | + +--- + +## 任务列表 + +### 1. 后端 Dashboard 聚合接口 + +- [√] 1.1 在 `packages/backend/src/dashboard/` 下新增 summary 查询与 service/controller/routes,实现基于连接表、标签关联表和审计日志表的聚合统计接口 | depends_on: [] +- [√] 1.2 在 `packages/backend/src/index.ts` 中注册 dashboard 路由,并补齐必要的后端类型定义 | depends_on: [1.1] + +### 2. 前端数据接入 + +- [√] 2.1 在 `packages/frontend/src/types/server.types.ts` 中新增 dashboard summary 响应类型,并在 `packages/frontend/src/stores/` 中新增 dashboard store 负责获取首页聚合数据 | depends_on: [1.2] + +### 3. 仪表盘页面重构 + +- [√] 3.1 重构 `packages/frontend/src/views/DashboardView.vue`,新增统计卡片、近 7 天趋势图、连接类型分布图、事件类型分布图和活跃连接排行,同时保留现有连接列表与最近活动区域 | depends_on: [2.1] +- [√] 3.2 更新 `packages/frontend/src/locales/zh-CN.json`、`packages/frontend/src/locales/en-US.json`、`packages/frontend/src/locales/ja-JP.json` 的 dashboard 文案,补齐新增统计与图表标签 | depends_on: [3.1] + +### 4. 验证 + +- [√] 4.1 执行 `packages/backend` 与 `packages/frontend` 的构建校验,确认新增 dashboard 接口和页面改造通过类型检查与打包 | depends_on: [3.2] + +--- + +## 执行日志 + +| 时间 | 任务 | 状态 | 备注 | +|------|------|------|------| +| 2026-03-25 23:50 | 1.1 | completed | 新增 backend dashboard 聚合模块,输出总量、趋势、分布和活跃连接排行 | +| 2026-03-25 23:51 | 1.2 | completed | 已注册 `/api/v1/dashboard/summary` 路由并接入后端入口 | +| 2026-03-25 23:56 | 2.1 | completed | 新增 dashboard store 与 summary 类型定义,首页改为单接口聚合数据驱动 | +| 2026-03-26 00:05 | 3.1 | completed | 重构 DashboardView 并拆出 DashboardOverviewPanel,加入统计卡片、趋势/分布图和活跃连接排行 | +| 2026-03-26 00:08 | 4.1 | completed | `packages/backend` 与 `packages/frontend` 构建通过,仅保留既有 chunk 警告 | + +--- + +## 执行备注 + +> `create_package.py` 与 `validate_package.py` 在当前环境均未返回有效执行报告,方案包与校验结果已按模板规则手工接管。仓库存在若干与本任务无关的未提交后端改动,本次仅同步仪表盘相关文件。 diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md index 2730021..653e862 100644 --- a/.helloagents/archive/_index.md +++ b/.helloagents/archive/_index.md @@ -20,6 +20,7 @@ | 202603252310 | connections-tree-search-explorer-polish | implementation | frontend | - | ✅完成 | | 202603252336 | connections-tree-hover-drag-polish | implementation | frontend | - | ✅完成 | | 202603252340 | command-input-multiline-shortcut | implementation | frontend | command-input-multiline-shortcut#D001 | ✅完成 | +| 202603252343 | dashboard-management-cockpit | implementation | frontend, backend | dashboard-management-cockpit#D001, dashboard-management-cockpit#D002 | ✅完成 | | 202603252229 | terminal-tab-group-visual | implementation | frontend | terminal-tab-group-visual#D001 | ✅完成 | | 202603252256 | workspace-monitor-terminal-polish | implementation | workspace-root | workspace-monitor-terminal-polish#D001 | ✅完成 | | 202603251200 | workspace-workbench-monitor | implementation | frontend, backend | workspace-workbench-monitor#D001 | ✅完成 | @@ -40,6 +41,7 @@ - [202603252310_connections-tree-search-explorer-polish](./2026-03/202603252310_connections-tree-search-explorer-polish/) - 为连接管理页补左侧树搜索、命中链路过滤、节点计数高亮和资源管理器式头部布局 - [202603252336_connections-tree-hover-drag-polish](./2026-03/202603252336_connections-tree-hover-drag-polish/) - 为连接管理页补树节点 hover 工具、分隔标题行和拖拽重排占位反馈 - [202603252340_command-input-multiline-shortcut](./2026-03/202603252340_command-input-multiline-shortcut/) - 将命令输入框改为多行自动增高,并改用 Ctrl+Shift+Enter 发送 +- [202603252343_dashboard-management-cockpit](./2026-03/202603252343_dashboard-management-cockpit/) - 将首页升级为统计卡片、趋势/分布图和活跃连接排行组成的 dashboard 驾驶舱,并补充 summary 聚合接口 - [202603252229_terminal-tab-group-visual](./2026-03/202603252229_terminal-tab-group-visual/) - 将顶部终端标签栏改成更明显的服务器组头与终端子标签 - [202603252256_workspace-monitor-terminal-polish](./2026-03/202603252256_workspace-monitor-terminal-polish/) - 重新核对状态监控与终端标签剩余改动,并修正知识库归档索引与活跃方案状态 - [202603251200_workspace-workbench-monitor](./2026-03/202603251200_workspace-workbench-monitor/) - `/workspace` 改为三栏 Workbench 布局,并新增开机累计流量监控 diff --git a/.helloagents/modules/backend.md b/.helloagents/modules/backend.md index c0bbed2..4812bc6 100644 --- a/.helloagents/modules/backend.md +++ b/.helloagents/modules/backend.md @@ -39,6 +39,11 @@ **行为**: 按 `controller/service/repository/routes` 的分层模式组织连接、通知、设置、快速命令、主题等功能。 **结果**: 新增后端能力时应优先延续现有业务域目录结构,而不是在入口文件堆叠逻辑。 +### 仪表盘聚合接口 +**条件**: 前端首页需要一次性获取可视化仪表盘统计。 +**行为**: 后端新增 `packages/backend/src/dashboard/` 业务域,当前通过 `GET /api/v1/dashboard/summary` 聚合 SQLite 中的 `connections`、`connection_tags` 和 `audit_logs`,输出连接总量、近 7 天活跃连接数、标签覆盖连接数、24 小时 SSH 成功/失败计数、近 7 天活动趋势、连接类型分布、事件类型分布以及活跃连接排行;其中高频连接排行会安全解析审计日志 `details` 中的 `connectionId/connectionName`,解析失败的单条日志会被忽略而不阻断整体 summary。 +**结果**: 仪表盘统计口径集中在后端统一维护,首页不再依赖多接口前端拼装,后续扩展更多运营指标时可沿用同一聚合模块。 + ### 外观默认值 **条件**: 数据库初始化、外观设置重置或前后端默认主题定义调整。 **行为**: `appearance.repository.ts` 负责写入默认 UI 外观设置,`config/default-themes.ts` 保持与前端同名默认主题定义一致,作为默认外观与终端主题的镜像基线;当前默认外观中终端文字描边和阴影开关默认开启,但仅作为“无保存值时”的回退,不主动覆盖数据库里已有用户配置。 diff --git a/.helloagents/modules/frontend.md b/.helloagents/modules/frontend.md index 8165fe6..21c6afe 100644 --- a/.helloagents/modules/frontend.md +++ b/.helloagents/modules/frontend.md @@ -39,6 +39,11 @@ **行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。`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 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;近期又补上了独立的左侧树搜索、命中节点及祖先路径过滤、命中链路自动展开、节点计数高亮,以及更接近资源管理器的树头部布局;本轮继续为树节点加入 hover 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单(编辑/测试/克隆/删除);样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。 **结果**: 页面逻辑分散在 `views/`、`components/`、`stores/` 与 `composables/`,其中工作区终端行为和标签交互优先落在 `session.store.ts`、`session/actions/sessionActions.ts`、`session/getters.ts`、`TerminalTabBar.vue`、`WorkspaceView.vue`、`Terminal.vue` 与相关 locale 文件。 +### 仪表盘总览 +**条件**: 用户登录后访问 `/` 首页仪表盘。 +**行为**: `DashboardView.vue` 当前会并行拉取连接列表、最近审计日志、标签列表和新的 dashboard summary 数据,并将总览区拆到 `DashboardOverviewPanel.vue`:顶部提供连接总数、近 7 天活跃连接、标签覆盖率、审计日志总量、24 小时 SSH 成功/失败等统计卡片,中部展示近 7 天活动趋势、连接类型分布和事件类型分布图表,下方展示高频连接排行;`dashboard.store.ts` 负责缓存 `/api/v1/dashboard/summary` 的聚合结果,原有连接列表和最近活动则继续复用 `connections.store.ts` 与 `audit.store.ts`。 +**结果**: 首页从“列表首页”升级为“总览 + 操作入口”的管理驾驶舱,用户可在同一页面完成扫描、筛选和直接连接。 + ## 依赖关系 ```yaml diff --git a/.helloagents/plan/202603252343_dashboard-management-cockpit/tasks.md b/.helloagents/plan/202603252343_dashboard-management-cockpit/tasks.md deleted file mode 100644 index 5923053..0000000 --- a/.helloagents/plan/202603252343_dashboard-management-cockpit/tasks.md +++ /dev/null @@ -1,50 +0,0 @@ -# 任务清单: dashboard-management-cockpit - -```yaml -@feature: dashboard-management-cockpit -@created: 2026-03-25 -@status: pending -@mode: R2 -``` - -## 进度概览 - -| 完成 | 失败 | 跳过 | 总数 | -|------|------|------|------| -| 0 | 0 | 0 | 6 | - ---- - -## 任务列表 - -### 1. 后端 Dashboard 聚合接口 - -- [ ] 1.1 在 `packages/backend/src/dashboard/` 下新增 summary 查询与 service/controller/routes,实现基于连接表、标签关联表和审计日志表的聚合统计接口 | depends_on: [] -- [ ] 1.2 在 `packages/backend/src/index.ts` 中注册 dashboard 路由,并补齐必要的后端类型定义 | depends_on: [1.1] - -### 2. 前端数据接入 - -- [ ] 2.1 在 `packages/frontend/src/types/server.types.ts` 中新增 dashboard summary 响应类型,并在 `packages/frontend/src/stores/` 中新增 dashboard store 负责获取首页聚合数据 | depends_on: [1.2] - -### 3. 仪表盘页面重构 - -- [ ] 3.1 重构 `packages/frontend/src/views/DashboardView.vue`,新增统计卡片、近 7 天趋势图、连接类型分布图和活跃连接排行,同时保留现有连接列表与最近活动区域 | depends_on: [2.1] -- [ ] 3.2 更新 `packages/frontend/src/locales/zh-CN.json`、`packages/frontend/src/locales/en-US.json`、`packages/frontend/src/locales/ja-JP.json` 的 dashboard 文案,补齐新增统计与图表标签 | depends_on: [3.1] - -### 4. 验证 - -- [ ] 4.1 执行 `packages/backend` 与 `packages/frontend` 的构建校验,确认新增 dashboard 接口和页面改造通过类型检查与打包 | depends_on: [3.2] - ---- - -## 执行日志 - -| 时间 | 任务 | 状态 | 备注 | -|------|------|------|------| -| 2026-03-25 23:43 | DESIGN | completed | 已确认采用“后端 summary 接口 + 前端驾驶舱重构”的实现路径,统计口径优先使用稳定数据库数据 | - ---- - -## 执行备注 - -> `create_package.py` 在当前环境未返回有效执行报告,方案包已按模板规则手工创建。开发实施阶段需同步前后端、locale 和知识库变更记录。 diff --git a/packages/backend/src/connections/connection.repository.ts b/packages/backend/src/connections/connection.repository.ts index 09f2203..63ad4f4 100644 --- a/packages/backend/src/connections/connection.repository.ts +++ b/packages/backend/src/connections/connection.repository.ts @@ -12,6 +12,7 @@ interface ConnectionBase { port: number; username: string; auth_method: 'password' | 'key'; + login_credential_id?: number | null; proxy_id: number | null; proxy_type?: 'proxy' | 'jump' | null; // 新增连接本身的 proxy_type created_at: number; @@ -50,8 +51,16 @@ notes?: string | null; interface FullConnectionDbRow extends Omit { // Omit service layer type, and tag_ids (not directly on connections table) ssh_key_id?: number | null; + login_credential_id?: number | null; jump_chain: string | null; // Stored as JSON string in DB proxy_type?: 'proxy' | 'jump' | null; // 连接本身的 proxy_type, from c.proxy_type + login_credential_type?: 'SSH' | 'RDP' | 'VNC' | null; + login_credential_username?: string | null; + login_credential_auth_method?: 'password' | 'key' | null; + login_credential_encrypted_password?: string | null; + login_credential_encrypted_private_key?: string | null; + login_credential_encrypted_passphrase?: string | null; + login_credential_ssh_key_id?: number | null; proxy_db_id: number | null; proxy_name: string | null; actual_proxy_server_type: string | null; // p.type AS actual_proxy_server_type @@ -70,10 +79,14 @@ interface FullConnectionDbRow extends Omit => { const sql = ` SELECT - c.id, c.name, c.type, c.host, c.port, c.username, c.auth_method, c.proxy_id, c.proxy_type, c.ssh_key_id, c.notes, c.jump_chain, -- +++ Select ssh_key_id, notes, jump_chain AND proxy_type +++ + c.id, c.name, c.type, c.host, c.port, + COALESCE(lc.username, c.username) as username, + COALESCE(lc.auth_method, c.auth_method) as auth_method, + c.login_credential_id, c.proxy_id, c.proxy_type, c.ssh_key_id, c.notes, c.jump_chain, c.created_at, c.updated_at, c.last_connected_at, GROUP_CONCAT(ct.tag_id) as tag_ids_str FROM connections c + LEFT JOIN login_credentials lc ON c.login_credential_id = lc.id LEFT JOIN connection_tags ct ON c.id = ct.connection_id GROUP BY c.id ORDER BY c.name ASC`; @@ -100,10 +113,14 @@ export const findAllConnectionsWithTags = async (): Promise => { const sql = ` SELECT - c.id, c.name, c.type, c.host, c.port, c.username, c.auth_method, c.proxy_id, c.proxy_type, c.ssh_key_id, c.notes, c.jump_chain, -- +++ Select ssh_key_id, notes, jump_chain AND proxy_type +++ + c.id, c.name, c.type, c.host, c.port, + COALESCE(lc.username, c.username) as username, + COALESCE(lc.auth_method, c.auth_method) as auth_method, + c.login_credential_id, c.proxy_id, c.proxy_type, c.ssh_key_id, c.notes, c.jump_chain, c.created_at, c.updated_at, c.last_connected_at, GROUP_CONCAT(ct.tag_id) as tag_ids_str FROM connections c + LEFT JOIN login_credentials lc ON c.login_credential_id = lc.id LEFT JOIN connection_tags ct ON c.id = ct.connection_id WHERE c.id = ? GROUP BY c.id`; @@ -133,12 +150,20 @@ export const findFullConnectionById = async (id: number): Promise => { - const sql = `SELECT id, name, type, host, port, username, auth_method, proxy_id, proxy_type, ssh_key_id, notes, jump_chain, created_at, updated_at, last_connected_at FROM connections WHERE name = ?`; // Added jump_chain and proxy_type + const sql = `SELECT id, name, type, host, port, username, auth_method, login_credential_id, proxy_id, proxy_type, ssh_key_id, notes, jump_chain, created_at, updated_at, last_connected_at FROM connections WHERE name = ?`; // Added jump_chain and proxy_type try { const db = await getDbInstance(); // Cast to ConnectionWithTagsRow to read jump_chain as string, then parse. It will now also have proxy_type @@ -190,8 +215,8 @@ export const createConnection = async (data: Omit 0) ? JSON.stringify(data.jump_chain) : null; console.log(`[Repository:createConnection] jump_chain input: ${JSON.stringify(data.jump_chain)}, stringified to: ${jumpChainStringified}`); @@ -204,6 +229,7 @@ export const createConnection = async (data: Omit & { tag_ids?: number[] }> ): Promise<{ connectionId: number, originalData: any }[]> => { - const insertConnSql = `INSERT INTO connections (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, proxy_type, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; // Add type, proxy_type and notes columns and placeholders + const insertConnSql = `INSERT INTO connections (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, proxy_type, login_credential_id, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; // Add type, proxy_type and notes columns and placeholders const results: { connectionId: number, originalData: any }[] = []; const now = Math.floor(Date.now() / 1000); @@ -412,6 +438,7 @@ export const bulkInsertConnections = async ( connData.encrypted_passphrase || null, connData.proxy_id || null, connData.proxy_type || null, // Add proxy_type parameter + connData.login_credential_id || null, connData.notes || null, // Add notes parameter now, now ]; diff --git a/packages/backend/src/connections/connection.service.ts b/packages/backend/src/connections/connection.service.ts index 9b25ef5..b3e1a4f 100644 --- a/packages/backend/src/connections/connection.service.ts +++ b/packages/backend/src/connections/connection.service.ts @@ -2,6 +2,7 @@ import * as ConnectionRepository from './connection.repository'; import { encrypt, decrypt } from '../utils/crypto'; import { AuditLogService } from '../audit/audit.service'; import * as SshKeyService from '../ssh_keys/ssh_key.service'; +import * as LoginCredentialService from '../login-credentials/login-credential.service'; import { ConnectionBase, ConnectionWithTags, @@ -54,6 +55,20 @@ const _validateAndProcessJumpChain = async ( const auditLogService = new AuditLogService(); +const _getSavedCredentialSnapshot = async ( + loginCredentialId: number, + connectionType: 'SSH' | 'RDP' | 'VNC' +) => { + const credential = await LoginCredentialService.getLoginCredentialById(loginCredentialId); + if (!credential) { + throw new Error(`登录凭证 ID ${loginCredentialId} 不存在。`); + } + if (credential.type !== connectionType) { + throw new Error(`登录凭证类型 ${credential.type} 与连接类型 ${connectionType} 不匹配。`); + } + return credential; +}; + /** * 获取所有连接(包含标签) */ @@ -76,159 +91,130 @@ export const getConnectionById = async (id: number): Promise => { - // +++ Define a local type alias for clarity, including ssh_key_id +++ type ConnectionDataForRepo = Omit & { jump_chain?: number[] | null; proxy_type?: 'proxy' | 'jump' | null }; console.log('[Service:createConnection] Received input:', JSON.stringify(input, null, 2)); // Log input - // 0. 处理和验证 jump_chain const processedJumpChain = await _validateAndProcessJumpChain(input.jump_chain, input.proxy_id); - - - // 1. 验证输入 (包含 type) - // Convert type to uppercase for validation and consistency - const connectionType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined; // Ensure type safety + const connectionType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined; if (!connectionType || !['SSH', 'RDP', 'VNC'].includes(connectionType)) { throw new Error('必须提供有效的连接类型 (SSH, RDP 或 VNC)。'); } - if (!input.host || !input.username) { - throw new Error('缺少必要的连接信息 (host, username)。'); - } - // Type-specific validation using the uppercase version - if (connectionType === 'SSH') { - if (!input.auth_method || !['password', 'key'].includes(input.auth_method)) { - throw new Error('SSH 连接必须提供有效的认证方式 (password 或 key)。'); - } - if (input.auth_method === 'password' && !input.password) { - throw new Error('SSH 密码认证方式需要提供 password。'); - } - // If using ssh_key_id, private_key is not required in the input - if (input.auth_method === 'key' && !input.ssh_key_id && !input.private_key) { - throw new Error('SSH 密钥认证方式需要提供 private_key 或选择一个已保存的密钥 (ssh_key_id)。'); - } - if (input.auth_method === 'key' && input.ssh_key_id && input.private_key) { - throw new Error('不能同时提供 private_key 和 ssh_key_id。'); - } - } else if (connectionType === 'RDP') { - if (!input.password) { - throw new Error('RDP 连接需要提供 password。'); - } - // For RDP, we'll ignore auth_method, private_key, passphrase from input if provided - } else if (connectionType === 'VNC') { - if (!input.password) { - throw new Error('VNC 连接需要提供 password。'); - } - // For VNC, auth_method is implicitly 'password'. - // ssh_key_id, private_key, passphrase are not applicable. - if (input.auth_method && input.auth_method !== 'password') { - throw new Error('VNC 连接的认证方式必须是 password。'); - } - if (input.ssh_key_id || input.private_key) { - throw new Error('VNC 连接不支持 SSH 密钥认证。'); - } + if (!input.host) { + throw new Error('缺少必要的连接信息 (host)。'); } - // 2. 处理凭证和 ssh_key_id (根据 type) + const savedCredential = typeof input.login_credential_id === 'number' + ? await _getSavedCredentialSnapshot(input.login_credential_id, connectionType) + : null; + let encryptedPassword = null; let encryptedPrivateKey = null; let encryptedPassphrase = null; - let sshKeyIdToSave: number | null = null; // +++ Variable for ssh_key_id +++ - // Default to 'password' for DB compatibility, especially for RDP + let sshKeyIdToSave: number | null = null; let authMethodForDb: 'password' | 'key' = 'password'; + let usernameToSave = input.username ?? savedCredential?.username ?? ''; + let loginCredentialIdToSave: number | null = savedCredential?.id ?? null; - if (connectionType === 'SSH') { - authMethodForDb = input.auth_method!; // Already validated above + if (!usernameToSave) { + throw new Error('缺少必要的连接信息 (username)。'); + } + + if (savedCredential) { + usernameToSave = savedCredential.username; + authMethodForDb = savedCredential.auth_method; + encryptedPassword = savedCredential.encrypted_password ?? null; + encryptedPrivateKey = savedCredential.encrypted_private_key ?? null; + encryptedPassphrase = savedCredential.encrypted_passphrase ?? null; + sshKeyIdToSave = savedCredential.ssh_key_id ?? null; + } else if (connectionType === 'SSH') { + if (!input.auth_method || !['password', 'key'].includes(input.auth_method)) { + throw new Error('SSH 连接必须提供有效的认证方式 (password 或 key)。'); + } + authMethodForDb = input.auth_method; if (input.auth_method === 'password') { + if (!input.password) { + throw new Error('SSH 密码认证方式需要提供 password。'); + } encryptedPassword = encrypt(input.password!); - sshKeyIdToSave = null; // Password auth cannot use ssh_key_id - } else { // auth_method is 'key' + sshKeyIdToSave = null; + } else { if (input.ssh_key_id) { - // Validate the provided ssh_key_id const keyExists = await SshKeyService.getSshKeyDbRowById(input.ssh_key_id); if (!keyExists) { throw new Error(`提供的 SSH 密钥 ID ${input.ssh_key_id} 无效或不存在。`); } sshKeyIdToSave = input.ssh_key_id; - // When using ssh_key_id, connection's own key fields should be null encryptedPrivateKey = null; encryptedPassphrase = null; } else if (input.private_key) { - // Encrypt the provided private key and passphrase encryptedPrivateKey = encrypt(input.private_key!); if (input.passphrase) { encryptedPassphrase = encrypt(input.passphrase); } - sshKeyIdToSave = null; // Ensure ssh_key_id is null if providing key directly + sshKeyIdToSave = null; } else { - // This case should be caught by validation above, but as a safeguard: throw new Error('SSH 密钥认证方式内部错误:未提供 private_key 或 ssh_key_id。'); } } - } else if (connectionType === 'RDP') { // RDP + } else if (connectionType === 'RDP') { + if (!input.password) { + throw new Error('RDP 连接需要提供 password。'); + } encryptedPassword = encrypt(input.password!); - // authMethodForDb remains 'password' for RDP encryptedPrivateKey = null; encryptedPassphrase = null; sshKeyIdToSave = null; - } else { // VNC + } else { + if (!input.password) { + throw new Error('VNC 连接需要提供 password。'); + } encryptedPassword = encrypt(input.password!); - authMethodForDb = 'password'; // VNC always uses password auth + authMethodForDb = 'password'; encryptedPrivateKey = null; encryptedPassphrase = null; sshKeyIdToSave = null; } - // 3. 准备仓库数据 let defaultPort = 22; // Default for SSH if (connectionType === 'RDP') { defaultPort = 3389; } else if (connectionType === 'VNC') { defaultPort = 5900; // Default VNC port } - // +++ Explicitly type connectionData using the local alias +++ const connectionData: ConnectionDataForRepo = { name: input.name || '', type: connectionType, host: input.host, - port: input.port ?? defaultPort, // Use type-specific default port - username: input.username, - auth_method: authMethodForDb, // Use determined auth method + port: input.port ?? defaultPort, + username: usernameToSave, + auth_method: authMethodForDb, encrypted_password: encryptedPassword, - encrypted_private_key: encryptedPrivateKey, // Null if using ssh_key_id or RDP - encrypted_passphrase: encryptedPassphrase, // Null if using ssh_key_id or RDP - ssh_key_id: sshKeyIdToSave, // +++ Add ssh_key_id +++ -notes: input.notes ?? null, // Add notes field - proxy_id: input.proxy_id ?? null, // 直接使用输入的 proxy_id - proxy_type: input.proxy_type ?? null, // 新增 proxy_type + encrypted_private_key: encryptedPrivateKey, + encrypted_passphrase: encryptedPassphrase, + ssh_key_id: sshKeyIdToSave, + login_credential_id: loginCredentialIdToSave, +notes: input.notes ?? null, + proxy_id: input.proxy_id ?? null, + proxy_type: input.proxy_type ?? null, jump_chain: processedJumpChain, }; - // Remove ssh_key_id property if it's null before logging/saving if repository expects exact type match without optional nulls - const finalConnectionData = { ...connectionData }; - if (finalConnectionData.ssh_key_id === null) { - delete (finalConnectionData as any).ssh_key_id; // Adjust based on repository function signature if needed - } - console.log('[Service:createConnection] Data being passed to ConnectionRepository.createConnection:', JSON.stringify(finalConnectionData, null, 2)); // Log data before saving + console.log('[Service:createConnection] Data being passed to ConnectionRepository.createConnection:', JSON.stringify(connectionData, null, 2)); - // 4. 在仓库中创建连接记录 - // Pass the potentially modified finalConnectionData - const newConnectionId = await ConnectionRepository.createConnection(finalConnectionData as Omit); + const newConnectionId = await ConnectionRepository.createConnection(connectionData as Omit); - // 5. 处理标签 const tagIds = input.tag_ids?.filter(id => typeof id === 'number' && id > 0) ?? []; if (tagIds.length > 0) { await ConnectionRepository.updateConnectionTags(newConnectionId, tagIds); } - // 6. 记录审计操作 const newConnection = await getConnectionById(newConnectionId); if (!newConnection) { - // 如果创建成功,这理论上不应该发生 console.error(`[Audit Log Error] Failed to retrieve connection ${newConnectionId} after creation.`); throw new Error('创建连接后无法检索到该连接。'); } - auditLogService.logAction('CONNECTION_CREATED', { connectionId: newConnection.id, type: newConnection.type, name: newConnection.name, host: newConnection.host }); // Add type to audit log + auditLogService.logAction('CONNECTION_CREATED', { connectionId: newConnection.id, type: newConnection.type, name: newConnection.name, host: newConnection.host }); - // 7. 返回新创建的带标签的连接 return newConnection; }; @@ -236,20 +222,18 @@ notes: input.notes ?? null, // Add notes field * 更新连接信息 */ export const updateConnection = async (id: number, input: UpdateConnectionInput): Promise => { - // 1. 获取当前连接数据(包括加密字段)以进行比较 const currentFullConnection = await ConnectionRepository.findFullConnectionById(id); if (!currentFullConnection) { - return null; // 未找到连接 + return null; } - // 2. 准备更新数据 - // Explicitly type dataToUpdate to match the repository's expected input, including ssh_key_id, jump_chain and proxy_type - const dataToUpdate: Partial> = {}; + const dataToUpdate: Partial> = {}; let needsCredentialUpdate = false; - // Determine the final type, converting input type to uppercase if provided const targetType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined || currentFullConnection.type; + if (currentFullConnection.login_credential_id && input.login_credential_id === undefined && targetType !== currentFullConnection.type) { + throw new Error('当前连接正在使用已保存登录凭证,切换连接类型前请先改为直填或重新选择匹配类型的登录凭证。'); + } - // 处理 jump_chain 和 proxy_id if (input.jump_chain !== undefined || input.proxy_id !== undefined) { const currentProxyId = input.proxy_id !== undefined ? input.proxy_id : currentFullConnection.proxy_id; @@ -268,75 +252,72 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput) const processedJumpChain = await _validateAndProcessJumpChain(currentJumpChainForValidation, currentProxyId, id); dataToUpdate.jump_chain = processedJumpChain; - // 直接使用 currentProxyId,不再因为 jump_chain 存在而将其设为 null dataToUpdate.proxy_id = currentProxyId; } - // 更新非凭证字段 if (input.name !== undefined) dataToUpdate.name = input.name || ''; - // Update type if changed, using the uppercase version if (input.type !== undefined && targetType !== currentFullConnection.type) dataToUpdate.type = targetType; if (input.host !== undefined) dataToUpdate.host = input.host; if (input.port !== undefined) dataToUpdate.port = input.port; if (input.username !== undefined) dataToUpdate.username = input.username; - if (input.notes !== undefined) dataToUpdate.notes = input.notes; // Add notes update - // proxy_id 的处理已移至 jump_chain 逻辑块中 - // if (input.proxy_id !== undefined) dataToUpdate.proxy_id = input.proxy_id; - if (input.proxy_type !== undefined) dataToUpdate.proxy_type = input.proxy_type; // 新增 proxy_type 更新 - // Handle ssh_key_id update (can be set to null or a new ID) - if (input.ssh_key_id !== undefined) dataToUpdate.ssh_key_id = input.ssh_key_id; + if (input.notes !== undefined) dataToUpdate.notes = input.notes; + if (input.proxy_type !== undefined) dataToUpdate.proxy_type = input.proxy_type; - // 处理认证方法更改或凭证更新 (根据 targetType) - // Use the validated targetType for logic - if (targetType === 'SSH') { + if (input.login_credential_id !== undefined) { + if (input.login_credential_id === null) { + dataToUpdate.login_credential_id = null; + } else { + const savedCredential = await _getSavedCredentialSnapshot(input.login_credential_id, targetType); + dataToUpdate.login_credential_id = savedCredential.id; + dataToUpdate.username = savedCredential.username; + dataToUpdate.auth_method = savedCredential.auth_method; + dataToUpdate.encrypted_password = savedCredential.encrypted_password ?? null; + dataToUpdate.encrypted_private_key = savedCredential.encrypted_private_key ?? null; + dataToUpdate.encrypted_passphrase = savedCredential.encrypted_passphrase ?? null; + dataToUpdate.ssh_key_id = savedCredential.ssh_key_id ?? null; + needsCredentialUpdate = true; + } + } + + const allowDirectCredentialEdit = + input.login_credential_id === null || + (!currentFullConnection.login_credential_id && input.login_credential_id === undefined); + + if (allowDirectCredentialEdit && targetType === 'SSH') { const currentAuthMethod = currentFullConnection.auth_method; const inputAuthMethod = input.auth_method; - // Determine the final auth method for SSH const finalAuthMethod = inputAuthMethod || currentAuthMethod; if (finalAuthMethod !== currentAuthMethod) { - dataToUpdate.auth_method = finalAuthMethod; // Update auth_method if it changed + dataToUpdate.auth_method = finalAuthMethod; } if (finalAuthMethod === 'password') { - // If switching to password or updating password - if (input.password !== undefined) { // Check if password was provided in input + if (input.password !== undefined) { if (!input.password && finalAuthMethod !== currentAuthMethod) { - // Switching to password requires a password throw new Error('切换到密码认证时需要提供 password。'); } - // Encrypt if password is not empty, otherwise set to null (to clear) dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null; needsCredentialUpdate = true; } - // When switching to password, clear key fields and ssh_key_id if (finalAuthMethod !== currentAuthMethod) { dataToUpdate.encrypted_private_key = null; dataToUpdate.encrypted_passphrase = null; - dataToUpdate.ssh_key_id = null; // Clear ssh_key_id when switching to password + dataToUpdate.ssh_key_id = null; } - } else { // finalAuthMethod is 'key' - // Handle ssh_key_id selection or direct key input + } else { if (input.ssh_key_id !== undefined) { - // User selected a stored key if (input.ssh_key_id === null) { - // User explicitly wants to clear the stored key association dataToUpdate.ssh_key_id = null; - // If clearing ssh_key_id, we might need a direct key, but validation should handle this? - // Or assume clearing means switching back to direct key input (which might be empty) - // Let's assume clearing ssh_key_id means we expect a direct key or nothing if (input.private_key === undefined) { - // If no direct key provided when clearing ssh_key_id, clear connection's key fields dataToUpdate.encrypted_private_key = null; dataToUpdate.encrypted_passphrase = null; } else { - // Encrypt the direct key provided alongside clearing ssh_key_id dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null; dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; } } else { - // Validate the provided ssh_key_id const keyExists = await SshKeyService.getSshKeyDbRowById(input.ssh_key_id); if (!keyExists) { throw new Error(`提供的 SSH 密钥 ID ${input.ssh_key_id} 无效或不存在。`); @@ -346,90 +327,73 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput) dataToUpdate.encrypted_private_key = null; dataToUpdate.encrypted_passphrase = null; } - needsCredentialUpdate = true; // Changing key source is a credential update + needsCredentialUpdate = true; } else if (input.private_key !== undefined) { - // User provided a direct key if (!input.private_key && finalAuthMethod !== currentAuthMethod) { - // Switching to key requires a private key if not using ssh_key_id throw new Error('切换到密钥认证时需要提供 private_key 或选择一个已保存的密钥。'); } - // Encrypt if key is not empty, otherwise set to null (to clear) dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null; - // Update passphrase only if direct key was provided OR passphrase itself was provided if (input.passphrase !== undefined) { dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; } else if (input.private_key) { - // If only private_key is provided, clear passphrase dataToUpdate.encrypted_passphrase = null; } - dataToUpdate.ssh_key_id = null; // Clear ssh_key_id when providing direct key + dataToUpdate.ssh_key_id = null; needsCredentialUpdate = true; } else if (input.passphrase !== undefined && !input.ssh_key_id && currentFullConnection.encrypted_private_key) { - // Only passphrase provided, and not using ssh_key_id, and a direct key already exists dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; needsCredentialUpdate = true; } - // When switching to key, clear password field if (finalAuthMethod !== currentAuthMethod) { dataToUpdate.encrypted_password = null; } } - } else if (targetType === 'RDP') { // targetType is 'RDP' - // RDP only uses password - if (input.password !== undefined) { // Check if password was provided + } else if (allowDirectCredentialEdit && targetType === 'RDP') { + if (input.password !== undefined) { dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null; needsCredentialUpdate = true; } - // Ensure SSH specific fields are nullified if switching to RDP or updating RDP if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) { - dataToUpdate.auth_method = 'password'; // RDP uses password auth method in DB + dataToUpdate.auth_method = 'password'; dataToUpdate.encrypted_private_key = null; dataToUpdate.encrypted_passphrase = null; - dataToUpdate.ssh_key_id = null; // RDP cannot use ssh_key_id + dataToUpdate.ssh_key_id = null; } - } else { // targetType is 'VNC' - // VNC only uses password - if (input.password !== undefined) { // Check if password was provided + } else if (allowDirectCredentialEdit) { + if (input.password !== undefined) { dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null; needsCredentialUpdate = true; } - // Ensure SSH specific fields are nullified if switching to VNC or updating VNC if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) { - dataToUpdate.auth_method = 'password'; // VNC uses password auth method in DB + dataToUpdate.auth_method = 'password'; dataToUpdate.encrypted_private_key = null; dataToUpdate.encrypted_passphrase = null; - dataToUpdate.ssh_key_id = null; // VNC cannot use ssh_key_id + dataToUpdate.ssh_key_id = null; } } - // 3. 如果有更改,则更新连接记录 const hasNonTagChanges = Object.keys(dataToUpdate).length > 0; - let updatedFieldsForAudit: string[] = []; // 跟踪审计日志的字段 + let updatedFieldsForAudit: string[] = []; if (hasNonTagChanges) { - updatedFieldsForAudit = Object.keys(dataToUpdate); // 在更新调用之前获取字段 - console.log(`[Service:updateConnection] Data being passed to ConnectionRepository.updateConnection for ID ${id}:`, JSON.stringify(dataToUpdate, null, 2)); // ADD THIS LOG + updatedFieldsForAudit = Object.keys(dataToUpdate); + console.log(`[Service:updateConnection] Data being passed to ConnectionRepository.updateConnection for ID ${id}:`, JSON.stringify(dataToUpdate, null, 2)); const updated = await ConnectionRepository.updateConnection(id, dataToUpdate); if (!updated) { - // 如果 findFullConnectionById 成功,则不应发生这种情况,但这是良好的实践 throw new Error('更新连接记录失败。'); } } - // 4. 如果提供了 tag_ids,则处理标签更新 if (input.tag_ids !== undefined) { const validTagIds = input.tag_ids.filter(tagId => typeof tagId === 'number' && tagId > 0); await ConnectionRepository.updateConnectionTags(id, validTagIds); } - // 如果 tag_ids 已更新,则将其添加到审计日志 if (input.tag_ids !== undefined) { updatedFieldsForAudit.push('tag_ids'); } - // 5. 如果进行了任何更改,则记录审计操作 if (hasNonTagChanges || input.tag_ids !== undefined) { - // Add type to audit log if it was updated const auditDetails: any = { connectionId: id, updatedFields: updatedFieldsForAudit }; if (dataToUpdate.type) { auditDetails.newType = dataToUpdate.type; @@ -437,7 +401,6 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput) auditLogService.logAction('CONNECTION_UPDATED', auditDetails); } - // 6. 获取并返回更新后的连接 return getConnectionById(id); }; @@ -472,11 +435,13 @@ export const getConnectionWithDecryptedCredentials = async ( // Handle potential undefined by defaulting to null const fullConnection: FullConnectionData = { ...fullConnectionDbRow, - encrypted_password: fullConnectionDbRow.encrypted_password ?? null, - encrypted_private_key: fullConnectionDbRow.encrypted_private_key ?? null, // May be null if using ssh_key_id - encrypted_passphrase: fullConnectionDbRow.encrypted_passphrase ?? null, // May be null if using ssh_key_id - ssh_key_id: fullConnectionDbRow.ssh_key_id ?? null, // +++ Include ssh_key_id +++ - // Ensure other fields match FullConnectionData if necessary + username: fullConnectionDbRow.login_credential_username ?? fullConnectionDbRow.username, + auth_method: fullConnectionDbRow.login_credential_auth_method ?? fullConnectionDbRow.auth_method, + encrypted_password: fullConnectionDbRow.login_credential_encrypted_password ?? fullConnectionDbRow.encrypted_password ?? null, + encrypted_private_key: fullConnectionDbRow.login_credential_encrypted_private_key ?? fullConnectionDbRow.encrypted_private_key ?? null, + encrypted_passphrase: fullConnectionDbRow.login_credential_encrypted_passphrase ?? fullConnectionDbRow.encrypted_passphrase ?? null, + ssh_key_id: fullConnectionDbRow.login_credential_ssh_key_id ?? fullConnectionDbRow.ssh_key_id ?? null, + login_credential_id: fullConnectionDbRow.login_credential_id ?? null, } as FullConnectionData & { ssh_key_id: number | null }; // Type assertion // 2. 获取带标签的连接数据(用于返回给调用者) @@ -576,6 +541,7 @@ export const cloneConnection = async (originalId: number, newName: string): Prom encrypted_private_key: originalFullConnection.encrypted_private_key ?? null, encrypted_passphrase: originalFullConnection.encrypted_passphrase ?? null, ssh_key_id: originalFullConnection.ssh_key_id ?? null, // 保留原始的 ssh_key_id + login_credential_id: originalFullConnection.login_credential_id ?? null, proxy_id: originalFullConnection.proxy_id ?? null, proxy_type: originalFullConnection.proxy_type ?? null, // 新增 proxy_type 复制 notes: originalFullConnection.notes ?? null, // 确保 notes 被复制 diff --git a/packages/backend/src/connections/connections.controller.ts b/packages/backend/src/connections/connections.controller.ts index 86f1d30..687f147 100644 --- a/packages/backend/src/connections/connections.controller.ts +++ b/packages/backend/src/connections/connections.controller.ts @@ -4,6 +4,7 @@ import * as SshService from '../services/ssh.service'; import * as GuacamoleService from '../services/guacamole.service'; import * as ImportExportService from '../services/import-export.service'; import * as ConnectionRepository from './connection.repository'; +import * as LoginCredentialService from '../login-credentials/login-credential.service'; @@ -144,42 +145,65 @@ export const testConnection = async (req: Request, res: Response): Promise */ export const testUnsavedConnection = async (req: Request, res: Response): Promise => { try { - // 从请求体中提取连接信息 (添加 ssh_key_id) - const { host, port, username, auth_method, password, private_key, passphrase, proxy_id, ssh_key_id } = req.body; + const { host, port, username, auth_method, password, private_key, passphrase, proxy_id, ssh_key_id, login_credential_id } = req.body; - // 基本验证 - if (!host || !port || !username || !auth_method) { - res.status(400).json({ success: false, message: '缺少必要的连接信息 (host, port, username, auth_method)。' }); + if (!host || !port) { + res.status(400).json({ success: false, message: '缺少必要的连接信息 (host, port)。' }); return; } - // 密码认证时,password 字段必须存在,但可以为空字符串 - if (auth_method === 'password' && password === undefined) { - res.status(400).json({ success: false, message: '密码认证方式需要提供 password 字段 (可以为空字符串)。' }); - return; - } - // 密钥认证时,必须提供 ssh_key_id 或 private_key - if (auth_method === 'key' && !ssh_key_id && !private_key) { - res.status(400).json({ success: false, message: '密钥认证方式需要提供 ssh_key_id 或 private_key。' }); - return; - } - // 如果同时提供了 ssh_key_id 和 private_key,优先使用 ssh_key_id (或者可以报错,这里选择优先) - if (auth_method === 'key' && ssh_key_id && private_key) { - console.warn('[testUnsavedConnection] 同时提供了 ssh_key_id 和 private_key,将优先使用 ssh_key_id。'); - // 不需要额外操作,后续逻辑会处理 - } - // 构建传递给服务层的连接配置对象 - // 注意:这里传递的是未经验证和加密处理的原始数据 + let resolvedUsername = username; + let resolvedAuthMethod = auth_method; + let resolvedPassword = password; + let resolvedPrivateKey = private_key; + let resolvedPassphrase = passphrase; + let resolvedSshKeyId = ssh_key_id ? parseInt(ssh_key_id, 10) : null; + + if (login_credential_id !== undefined && login_credential_id !== null) { + const credentialId = parseInt(login_credential_id, 10); + if (isNaN(credentialId)) { + res.status(400).json({ success: false, message: '登录凭证 ID 必须是有效的数字。' }); + return; + } + + const credential = await LoginCredentialService.getDecryptedLoginCredentialById(credentialId); + if (!credential) { + res.status(400).json({ success: false, message: `登录凭证 ID ${credentialId} 未找到。` }); + return; + } + + resolvedUsername = credential.username; + resolvedAuthMethod = credential.auth_method; + resolvedPassword = credential.password; + resolvedPrivateKey = credential.privateKey; + resolvedPassphrase = credential.passphrase; + resolvedSshKeyId = credential.ssh_key_id ?? null; + } else { + if (!resolvedUsername || !resolvedAuthMethod) { + res.status(400).json({ success: false, message: '缺少必要的连接信息 (username, auth_method)。' }); + return; + } + if (resolvedAuthMethod === 'password' && resolvedPassword === undefined) { + res.status(400).json({ success: false, message: '密码认证方式需要提供 password 字段 (可以为空字符串)。' }); + return; + } + if (resolvedAuthMethod === 'key' && !resolvedSshKeyId && !resolvedPrivateKey) { + res.status(400).json({ success: false, message: '密钥认证方式需要提供 ssh_key_id 或 private_key。' }); + return; + } + } + const connectionConfig = { host, - port: parseInt(port, 10), // 确保 port 是数字 - username, - auth_method, - password, // 传递原始密码 - private_key: ssh_key_id ? undefined : private_key, // 如果有 ssh_key_id,则不传递 private_key - passphrase: ssh_key_id ? undefined : passphrase, // 如果有 ssh_key_id,则不传递 passphrase - ssh_key_id: ssh_key_id ? parseInt(ssh_key_id, 10) : null, // 传递 ssh_key_id (确保是数字或 null) - proxy_id: proxy_id ? parseInt(proxy_id, 10) : null // 确保 proxy_id 是数字或 null + port: parseInt(port, 10), + username: resolvedUsername, + auth_method: resolvedAuthMethod, + password: resolvedPassword, + private_key: resolvedSshKeyId ? undefined : resolvedPrivateKey, + passphrase: resolvedSshKeyId ? undefined : resolvedPassphrase, + ssh_key_id: resolvedSshKeyId, + login_credential_id: login_credential_id ? parseInt(login_credential_id, 10) : null, + proxy_id: proxy_id ? parseInt(proxy_id, 10) : null }; // 验证 port 和 proxy_id 是否为有效数字 @@ -192,7 +216,7 @@ export const testUnsavedConnection = async (req: Request, res: Response): Promis return; } // 验证 ssh_key_id (如果提供了) - if (ssh_key_id && isNaN(connectionConfig.ssh_key_id as number)) { + if (resolvedSshKeyId && isNaN(connectionConfig.ssh_key_id as number)) { res.status(400).json({ success: false, message: 'SSH 密钥 ID 必须是有效的数字。' }); return; } diff --git a/packages/backend/src/database/migrations.ts b/packages/backend/src/database/migrations.ts index 117edb8..09918f1 100644 --- a/packages/backend/src/database/migrations.ts +++ b/packages/backend/src/database/migrations.ts @@ -306,6 +306,34 @@ const definedMigrations: Migration[] = [ sql: ` ALTER TABLE quick_commands ADD COLUMN variables TEXT NULL; ` + }, + { + id: 11, + name: 'Add login_credentials table and login_credential_id to connections', + check: async (db: Database): Promise => { + const credentialsTableExists = await tableExists(db, 'login_credentials'); + const loginCredentialColumnExists = await columnExists(db, 'connections', 'login_credential_id'); + return !credentialsTableExists || !loginCredentialColumnExists; + }, + sql: ` + CREATE TABLE IF NOT EXISTS login_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP', 'VNC')), + username TEXT NOT NULL, + auth_method TEXT NOT NULL CHECK(auth_method IN ('password', 'key')), + encrypted_password TEXT NULL, + encrypted_private_key TEXT NULL, + encrypted_passphrase TEXT NULL, + ssh_key_id INTEGER NULL, + notes TEXT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE SET NULL + ); + + ALTER TABLE connections ADD COLUMN login_credential_id INTEGER NULL REFERENCES login_credentials(id) ON DELETE SET NULL; + ` } ]; diff --git a/packages/backend/src/database/schema.registry.ts b/packages/backend/src/database/schema.registry.ts index c87c039..31ec612 100644 --- a/packages/backend/src/database/schema.registry.ts +++ b/packages/backend/src/database/schema.registry.ts @@ -65,6 +65,7 @@ export const tableDefinitions: TableDefinition[] = [ // Features like proxies, connections, tags { name: 'proxies', sql: schemaSql.createProxiesTableSQL }, { name: 'ssh_keys', sql: schemaSql.createSshKeysTableSQL }, // Added SSH Keys table + { name: 'login_credentials', sql: schemaSql.createLoginCredentialsTableSQL }, { name: 'connections', sql: schemaSql.createConnectionsTableSQL }, // Depends on proxies, ssh_keys { name: 'tags', sql: schemaSql.createTagsTableSQL }, { name: 'connection_tags', sql: schemaSql.createConnectionTagsTableSQL }, // Depends on connections, tags @@ -87,4 +88,4 @@ export const tableDefinitions: TableDefinition[] = [ sql: schemaSql.createAppearanceSettingsTableSQL, init: initAppearanceSettingsTable }, // Depends on terminal_themes -]; \ No newline at end of file +]; diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index 89bd002..f84aac9 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -94,6 +94,7 @@ CREATE TABLE IF NOT EXISTS connections ( encrypted_passphrase TEXT NULL, proxy_id INTEGER NULL, ssh_key_id INTEGER NULL, + login_credential_id INTEGER NULL, notes TEXT NULL, jump_chain TEXT NULL, proxy_type TEXT NULL, @@ -101,7 +102,26 @@ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), last_connected_at INTEGER NULL, FOREIGN KEY (proxy_id) REFERENCES proxies(id) ON DELETE SET NULL, - FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE SET NULL + FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE SET NULL, + FOREIGN KEY (login_credential_id) REFERENCES login_credentials(id) ON DELETE SET NULL +); +`; + +export const createLoginCredentialsTableSQL = ` +CREATE TABLE IF NOT EXISTS login_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP', 'VNC')), + username TEXT NOT NULL, + auth_method TEXT NOT NULL CHECK(auth_method IN ('password', 'key')), + encrypted_password TEXT NULL, + encrypted_private_key TEXT NULL, + encrypted_passphrase TEXT NULL, + ssh_key_id INTEGER NULL, + notes TEXT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE SET NULL ); `; @@ -244,4 +264,4 @@ CREATE TABLE IF NOT EXISTS favorite_paths ( created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) ); -`; \ No newline at end of file +`; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index d9970ad..49bb10f 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -51,6 +51,7 @@ import quickCommandsRoutes from './quick-commands/quick-commands.routes'; import terminalThemeRoutes from './terminal-themes/terminal-theme.routes'; import appearanceRoutes from './appearance/appearance.routes'; import sshKeysRouter from './ssh_keys/ssh_keys.routes'; +import loginCredentialsRouter from './login-credentials/login-credentials.routes'; import quickCommandTagRoutes from './quick-command-tags/quick-command-tag.routes'; import sshSuspendRouter from './ssh-suspend/ssh-suspend.routes'; import { transfersRoutes } from './transfers/transfers.routes'; @@ -259,6 +260,7 @@ const startServer = () => { app.use('/api/v1/terminal-themes', terminalThemeRoutes); app.use('/api/v1/appearance', appearanceRoutes); app.use('/api/v1/ssh-keys', sshKeysRouter); + app.use('/api/v1/login-credentials', loginCredentialsRouter); app.use('/api/v1/quick-command-tags', quickCommandTagRoutes); app.use('/api/v1/ssh-suspend', sshSuspendRouter); app.use('/api/v1/transfers', transfersRoutes()); diff --git a/packages/backend/src/login-credentials/login-credential.repository.ts b/packages/backend/src/login-credentials/login-credential.repository.ts new file mode 100644 index 0000000..06fa5a5 --- /dev/null +++ b/packages/backend/src/login-credentials/login-credential.repository.ts @@ -0,0 +1,101 @@ +import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; +import { + LoginCredentialBase, + FullLoginCredentialData, +} from '../types/login-credential.types'; + +export interface LoginCredentialDbRow extends FullLoginCredentialData {} + +export const findAllLoginCredentials = async (): Promise => { + const sql = ` + SELECT + id, name, type, username, auth_method, ssh_key_id, notes, created_at, updated_at + FROM login_credentials + ORDER BY updated_at DESC, name ASC + `; + + const db = await getDbInstance(); + return allDb(db, sql); +}; + +export const findLoginCredentialById = async (id: number): Promise => { + const sql = ` + SELECT + id, name, type, username, auth_method, + encrypted_password, encrypted_private_key, encrypted_passphrase, + ssh_key_id, notes, created_at, updated_at + FROM login_credentials + WHERE id = ? + `; + + const db = await getDbInstance(); + const row = await getDbRow(db, sql, [id]); + return row || null; +}; + +export const createLoginCredential = async ( + data: Omit +): Promise => { + const now = Math.floor(Date.now() / 1000); + const sql = ` + INSERT INTO login_credentials ( + name, type, username, auth_method, + encrypted_password, encrypted_private_key, encrypted_passphrase, + ssh_key_id, notes, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + + const db = await getDbInstance(); + const result = await runDb(db, sql, [ + data.name, + data.type, + data.username, + data.auth_method, + data.encrypted_password ?? null, + data.encrypted_private_key ?? null, + data.encrypted_passphrase ?? null, + data.ssh_key_id ?? null, + data.notes ?? null, + now, + now, + ]); + + if (typeof result.lastID !== 'number' || result.lastID <= 0) { + throw new Error('创建登录凭证后未能获取有效 ID。'); + } + + return result.lastID; +}; + +export const updateLoginCredential = async ( + id: number, + data: Partial> +): Promise => { + const fieldsToUpdate: Record = { ...data }; + delete fieldsToUpdate.id; + delete fieldsToUpdate.created_at; + delete fieldsToUpdate.updated_at; + + fieldsToUpdate.updated_at = Math.floor(Date.now() / 1000); + + const setClauses = Object.keys(fieldsToUpdate).map((key) => `${key} = ?`).join(', '); + if (!setClauses) { + return false; + } + + const params = Object.keys(fieldsToUpdate).map((key) => fieldsToUpdate[key] ?? null); + params.push(id); + + const sql = `UPDATE login_credentials SET ${setClauses} WHERE id = ?`; + const db = await getDbInstance(); + const result = await runDb(db, sql, params); + return result.changes > 0; +}; + +export const deleteLoginCredential = async (id: number): Promise => { + const sql = `DELETE FROM login_credentials WHERE id = ?`; + const db = await getDbInstance(); + const result = await runDb(db, sql, [id]); + return result.changes > 0; +}; diff --git a/packages/backend/src/login-credentials/login-credential.service.ts b/packages/backend/src/login-credentials/login-credential.service.ts new file mode 100644 index 0000000..c4f7b00 --- /dev/null +++ b/packages/backend/src/login-credentials/login-credential.service.ts @@ -0,0 +1,187 @@ +import { encrypt, decrypt } from '../utils/crypto'; +import * as LoginCredentialRepository from './login-credential.repository'; +import * as SshKeyService from '../ssh_keys/ssh_key.service'; +import { + CreateLoginCredentialInput, + UpdateLoginCredentialInput, + LoginCredentialBase, + FullLoginCredentialData, + DecryptedLoginCredentialDetails, +} from '../types/login-credential.types'; + +export type { + CreateLoginCredentialInput, + UpdateLoginCredentialInput, + LoginCredentialBase, + FullLoginCredentialData, + DecryptedLoginCredentialDetails, +}; + +const buildCredentialPayload = async ( + input: CreateLoginCredentialInput | UpdateLoginCredentialInput, + existing?: LoginCredentialRepository.LoginCredentialDbRow +): Promise> => { + const credentialType = (input.type ?? existing?.type)?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined; + if (!credentialType || !['SSH', 'RDP', 'VNC'].includes(credentialType)) { + throw new Error('必须提供有效的登录凭证类型 (SSH, RDP 或 VNC)。'); + } + + const username = input.username ?? existing?.username; + if (!username) { + throw new Error('登录凭证必须提供用户名。'); + } + + let authMethod: 'password' | 'key' = credentialType === 'SSH' + ? ((input.auth_method ?? existing?.auth_method) as 'password' | 'key' | undefined) || 'password' + : 'password'; + + if (credentialType === 'SSH' && !['password', 'key'].includes(authMethod)) { + throw new Error('SSH 登录凭证必须提供有效的认证方式 (password 或 key)。'); + } + + let encryptedPassword = existing?.encrypted_password ?? null; + let encryptedPrivateKey = existing?.encrypted_private_key ?? null; + let encryptedPassphrase = existing?.encrypted_passphrase ?? null; + let sshKeyId = existing?.ssh_key_id ?? null; + + if (credentialType === 'SSH') { + if (authMethod === 'password') { + if (input.password !== undefined) { + encryptedPassword = input.password ? encrypt(input.password) : null; + } + if (!encryptedPassword) { + throw new Error('SSH 密码认证方式需要提供 password。'); + } + encryptedPrivateKey = null; + encryptedPassphrase = null; + sshKeyId = null; + } else { + if (input.ssh_key_id !== undefined) { + sshKeyId = input.ssh_key_id; + } + + if (sshKeyId) { + const keyExists = await SshKeyService.getSshKeyDbRowById(sshKeyId); + if (!keyExists) { + throw new Error(`提供的 SSH 密钥 ID ${sshKeyId} 无效或不存在。`); + } + encryptedPassword = null; + encryptedPrivateKey = null; + encryptedPassphrase = null; + } else if (input.private_key !== undefined) { + encryptedPrivateKey = input.private_key ? encrypt(input.private_key) : null; + encryptedPassphrase = input.passphrase ? encrypt(input.passphrase) : null; + encryptedPassword = null; + } + + if (!sshKeyId && !encryptedPrivateKey) { + throw new Error('SSH 密钥认证方式需要提供 private_key 或 ssh_key_id。'); + } + } + } else { + authMethod = 'password'; + if (input.password !== undefined) { + encryptedPassword = input.password ? encrypt(input.password) : null; + } + if (!encryptedPassword) { + throw new Error(`${credentialType} 登录凭证需要提供 password。`); + } + encryptedPrivateKey = null; + encryptedPassphrase = null; + sshKeyId = null; + } + + return { + name: input.name ?? existing?.name ?? '', + type: credentialType, + username, + auth_method: authMethod, + encrypted_password: encryptedPassword, + encrypted_private_key: encryptedPrivateKey, + encrypted_passphrase: encryptedPassphrase, + ssh_key_id: sshKeyId, + notes: input.notes ?? existing?.notes ?? null, + }; +}; + +export const getAllLoginCredentials = async (): Promise => { + return LoginCredentialRepository.findAllLoginCredentials(); +}; + +export const getLoginCredentialById = async (id: number): Promise => { + return LoginCredentialRepository.findLoginCredentialById(id); +}; + +export const getDecryptedLoginCredentialById = async (id: number): Promise => { + const credential = await LoginCredentialRepository.findLoginCredentialById(id); + if (!credential) { + return null; + } + + return { + id: credential.id, + name: credential.name, + type: credential.type, + username: credential.username, + auth_method: credential.auth_method, + ssh_key_id: credential.ssh_key_id ?? null, + notes: credential.notes ?? null, + created_at: credential.created_at, + updated_at: credential.updated_at, + password: credential.encrypted_password ? decrypt(credential.encrypted_password) : undefined, + privateKey: credential.encrypted_private_key ? decrypt(credential.encrypted_private_key) : undefined, + passphrase: credential.encrypted_passphrase ? decrypt(credential.encrypted_passphrase) : undefined, + }; +}; + +export const createLoginCredential = async (input: CreateLoginCredentialInput): Promise => { + if (!input.name) { + throw new Error('必须提供登录凭证名称。'); + } + + const payload = await buildCredentialPayload(input); + const credentialId = await LoginCredentialRepository.createLoginCredential(payload); + return { + id: credentialId, + name: payload.name, + type: payload.type, + username: payload.username, + auth_method: payload.auth_method, + ssh_key_id: payload.ssh_key_id ?? null, + notes: payload.notes ?? null, + created_at: 0, + updated_at: 0, + }; +}; + +export const updateLoginCredential = async ( + id: number, + input: UpdateLoginCredentialInput +): Promise => { + const existing = await LoginCredentialRepository.findLoginCredentialById(id); + if (!existing) { + return null; + } + + const payload = await buildCredentialPayload(input, existing); + const updated = await LoginCredentialRepository.updateLoginCredential(id, payload); + if (!updated) { + throw new Error('更新登录凭证失败。'); + } + + return { + id, + name: payload.name, + type: payload.type, + username: payload.username, + auth_method: payload.auth_method, + ssh_key_id: payload.ssh_key_id ?? null, + notes: payload.notes ?? null, + created_at: existing.created_at, + updated_at: Math.floor(Date.now() / 1000), + }; +}; + +export const deleteLoginCredential = async (id: number): Promise => { + return LoginCredentialRepository.deleteLoginCredential(id); +}; diff --git a/packages/backend/src/login-credentials/login-credentials.controller.ts b/packages/backend/src/login-credentials/login-credentials.controller.ts new file mode 100644 index 0000000..64ed6a7 --- /dev/null +++ b/packages/backend/src/login-credentials/login-credentials.controller.ts @@ -0,0 +1,105 @@ +import { Request, Response } from 'express'; +import * as LoginCredentialService from './login-credential.service'; +import { CreateLoginCredentialInput, UpdateLoginCredentialInput } from './login-credential.service'; + +export const getLoginCredentials = async (req: Request, res: Response): Promise => { + try { + const credentials = await LoginCredentialService.getAllLoginCredentials(); + res.status(200).json(credentials); + } catch (error: any) { + console.error('Controller: 获取登录凭证列表失败:', error); + res.status(500).json({ message: error.message || '获取登录凭证列表时发生内部服务器错误。' }); + } +}; + +export const createLoginCredential = async (req: Request, res: Response): Promise => { + try { + const input: CreateLoginCredentialInput = req.body; + if (!input.name || !input.type || !input.username) { + res.status(400).json({ message: '请求体必须包含 name、type 和 username。' }); + return; + } + const credential = await LoginCredentialService.createLoginCredential(input); + res.status(201).json({ message: '登录凭证创建成功。', credential }); + } catch (error: any) { + console.error('Controller: 创建登录凭证失败:', error); + if (error.message.includes('必须提供') || error.message.includes('需要提供') || error.message.includes('无效')) { + res.status(400).json({ message: error.message }); + return; + } + res.status(500).json({ message: error.message || '创建登录凭证时发生内部服务器错误。' }); + } +}; + +export const getDecryptedLoginCredential = async (req: Request, res: Response): Promise => { + try { + const credentialId = parseInt(req.params.id, 10); + if (isNaN(credentialId)) { + res.status(400).json({ message: '无效的登录凭证 ID。' }); + return; + } + + const credential = await LoginCredentialService.getDecryptedLoginCredentialById(credentialId); + if (!credential) { + res.status(404).json({ message: '登录凭证未找到。' }); + return; + } + + res.status(200).json(credential); + } catch (error: any) { + console.error(`Controller: 获取登录凭证 ${req.params.id} 详情失败:`, error); + res.status(500).json({ message: error.message || '获取登录凭证详情时发生内部服务器错误。' }); + } +}; + +export const updateLoginCredential = async (req: Request, res: Response): Promise => { + try { + const credentialId = parseInt(req.params.id, 10); + if (isNaN(credentialId)) { + res.status(400).json({ message: '无效的登录凭证 ID。' }); + return; + } + + const input: UpdateLoginCredentialInput = req.body; + if (Object.keys(input).length === 0) { + res.status(400).json({ message: '请求体不能为空。' }); + return; + } + + const credential = await LoginCredentialService.updateLoginCredential(credentialId, input); + if (!credential) { + res.status(404).json({ message: '登录凭证未找到。' }); + return; + } + + res.status(200).json({ message: '登录凭证更新成功。', credential }); + } catch (error: any) { + console.error(`Controller: 更新登录凭证 ${req.params.id} 失败:`, error); + if (error.message.includes('必须提供') || error.message.includes('需要提供') || error.message.includes('无效')) { + res.status(400).json({ message: error.message }); + return; + } + res.status(500).json({ message: error.message || '更新登录凭证时发生内部服务器错误。' }); + } +}; + +export const deleteLoginCredential = async (req: Request, res: Response): Promise => { + try { + const credentialId = parseInt(req.params.id, 10); + if (isNaN(credentialId)) { + res.status(400).json({ message: '无效的登录凭证 ID。' }); + return; + } + + const deleted = await LoginCredentialService.deleteLoginCredential(credentialId); + if (!deleted) { + res.status(404).json({ message: '登录凭证未找到。' }); + return; + } + + res.status(200).json({ message: '登录凭证删除成功。' }); + } catch (error: any) { + console.error(`Controller: 删除登录凭证 ${req.params.id} 失败:`, error); + res.status(500).json({ message: error.message || '删除登录凭证时发生内部服务器错误。' }); + } +}; diff --git a/packages/backend/src/login-credentials/login-credentials.routes.ts b/packages/backend/src/login-credentials/login-credentials.routes.ts new file mode 100644 index 0000000..dc38bce --- /dev/null +++ b/packages/backend/src/login-credentials/login-credentials.routes.ts @@ -0,0 +1,21 @@ +import { Router } from 'express'; +import { isAuthenticated } from '../auth/auth.middleware'; +import { + getLoginCredentials, + createLoginCredential, + getDecryptedLoginCredential, + updateLoginCredential, + deleteLoginCredential, +} from './login-credentials.controller'; + +const router = Router(); + +router.use(isAuthenticated); + +router.get('/', getLoginCredentials); +router.post('/', createLoginCredential); +router.get('/:id/details', getDecryptedLoginCredential); +router.put('/:id', updateLoginCredential); +router.delete('/:id', deleteLoginCredential); + +export default router; diff --git a/packages/backend/src/services/ssh.service.ts b/packages/backend/src/services/ssh.service.ts index 431bcd0..49183f5 100644 --- a/packages/backend/src/services/ssh.service.ts +++ b/packages/backend/src/services/ssh.service.ts @@ -57,6 +57,7 @@ export interface DecryptedConnectionDetails { } | null; jump_chain?: JumpHostDetail[]; connection_proxy_setting?: 'proxy' | 'jump' | null; + login_credential_id?: number | null; } /** @@ -82,32 +83,38 @@ export const getConnectionDetails = async (connectionId: number): Promise { throw new Error(`Connection ID ${connectionId} has null name.`); })(), host: typedRawConnInfo.host ?? (() => { throw new Error(`Connection ID ${connectionId} has null host.`); })(), port: typedRawConnInfo.port ?? (() => { throw new Error(`Connection ID ${connectionId} has null port.`); })(), - username: typedRawConnInfo.username ?? (() => { throw new Error(`Connection ID ${connectionId} has null username.`); })(), - auth_method: typedRawConnInfo.auth_method ?? (() => { throw new Error(`Connection ID ${connectionId} has null auth_method.`); })(), + username: typedRawConnInfo.login_credential_username ?? typedRawConnInfo.username ?? (() => { throw new Error(`Connection ID ${connectionId} has null username.`); })(), + auth_method: typedRawConnInfo.login_credential_auth_method ?? typedRawConnInfo.auth_method ?? (() => { throw new Error(`Connection ID ${connectionId} has null auth_method.`); })(), password: undefined, privateKey: undefined, passphrase: undefined, proxy: null, jump_chain: undefined, connection_proxy_setting: typedRawConnInfo.proxy_type ?? null, + login_credential_id: typedRawConnInfo.login_credential_id ?? null, }; - if (fullConnInfo.auth_method === 'password' && rawConnInfo.encrypted_password) { - fullConnInfo.password = decrypt(rawConnInfo.encrypted_password); + const encryptedPassword = typedRawConnInfo.login_credential_encrypted_password ?? rawConnInfo.encrypted_password; + const encryptedPrivateKey = typedRawConnInfo.login_credential_encrypted_private_key ?? typedRawConnInfo.encrypted_private_key; + const encryptedPassphrase = typedRawConnInfo.login_credential_encrypted_passphrase ?? typedRawConnInfo.encrypted_passphrase; + const sshKeyId = typedRawConnInfo.login_credential_ssh_key_id ?? typedRawConnInfo.ssh_key_id; + + if (fullConnInfo.auth_method === 'password' && encryptedPassword) { + fullConnInfo.password = decrypt(encryptedPassword); } else if (fullConnInfo.auth_method === 'key') { - if (typedRawConnInfo.ssh_key_id) { - const storedKeyDetails = await SshKeyService.getDecryptedSshKeyById(typedRawConnInfo.ssh_key_id); + if (sshKeyId) { + const storedKeyDetails = await SshKeyService.getDecryptedSshKeyById(sshKeyId); if (!storedKeyDetails) { - console.error(`SshService: Error: Connection ${connectionId} references non-existent SSH key ID ${typedRawConnInfo.ssh_key_id}`); - throw new Error(`关联的 SSH 密钥 (ID: ${typedRawConnInfo.ssh_key_id}) 未找到。`); + console.error(`SshService: Error: Connection ${connectionId} references non-existent SSH key ID ${sshKeyId}`); + throw new Error(`关联的 SSH 密钥 (ID: ${sshKeyId}) 未找到。`); } fullConnInfo.privateKey = storedKeyDetails.privateKey; fullConnInfo.passphrase = storedKeyDetails.passphrase; - } else if (typedRawConnInfo.encrypted_private_key) { - fullConnInfo.privateKey = decrypt(typedRawConnInfo.encrypted_private_key); - if (typedRawConnInfo.encrypted_passphrase) { - fullConnInfo.passphrase = decrypt(typedRawConnInfo.encrypted_passphrase); + } else if (encryptedPrivateKey) { + fullConnInfo.privateKey = decrypt(encryptedPrivateKey); + if (encryptedPassphrase) { + fullConnInfo.passphrase = decrypt(encryptedPassphrase); } } else { console.warn(`SshService: Connection ${connectionId} uses key auth but has neither ssh_key_id nor encrypted_private_key.`); @@ -670,6 +677,7 @@ export const testUnsavedConnection = async (connectionConfig: { private_key?: string; passphrase?: string; ssh_key_id?: number | null; + login_credential_id?: number | null; proxy_id?: number | null; }): Promise<{ latency: number }> => { console.log(`SshService: 测试未保存的连接到 ${connectionConfig.host}:${connectionConfig.port}...`); @@ -757,4 +765,4 @@ export const testUnsavedConnection = async (connectionConfig: { console.log(`SshService: 测试未保存连接的客户端已关闭。`); } } -}; \ No newline at end of file +}; diff --git a/packages/backend/src/types/connection.types.ts b/packages/backend/src/types/connection.types.ts index 5a740af..85e53e2 100644 --- a/packages/backend/src/types/connection.types.ts +++ b/packages/backend/src/types/connection.types.ts @@ -6,6 +6,7 @@ export interface ConnectionBase { port: number; username: string; auth_method: 'password' | 'key'; + login_credential_id?: number | null; proxy_id: number | null; proxy_type?: 'proxy' | 'jump' | null; created_at: number; @@ -25,12 +26,13 @@ export interface CreateConnectionInput { type: 'SSH' | 'RDP' | 'VNC'; host: string; port?: number; - username: string; - auth_method: 'password' | 'key'; + username?: string; + auth_method?: 'password' | 'key'; password?: string; private_key?: string; passphrase?: string; ssh_key_id?: number | null; + login_credential_id?: number | null; proxy_id?: number | null; proxy_type?: 'proxy' | 'jump' | null; tag_ids?: number[]; @@ -50,6 +52,7 @@ export interface UpdateConnectionInput { private_key?: string; passphrase?: string; ssh_key_id?: number | null; + login_credential_id?: number | null; proxy_id?: number | null; proxy_type?: 'proxy' | 'jump' | null; notes?: string | null; @@ -70,6 +73,7 @@ export interface FullConnectionData { encrypted_private_key: string | null; encrypted_passphrase: string | null; ssh_key_id?: number | null; + login_credential_id?: number | null; proxy_id: number | null; proxy_type?: 'proxy' | 'jump' | null; created_at: number; @@ -83,4 +87,4 @@ export interface DecryptedConnectionCredentials { decryptedPassword?: string; decryptedPrivateKey?: string; decryptedPassphrase?: string; -} \ No newline at end of file +} diff --git a/packages/backend/src/types/login-credential.types.ts b/packages/backend/src/types/login-credential.types.ts new file mode 100644 index 0000000..386aeec --- /dev/null +++ b/packages/backend/src/types/login-credential.types.ts @@ -0,0 +1,47 @@ +export interface LoginCredentialBase { + id: number; + name: string; + type: 'SSH' | 'RDP' | 'VNC'; + username: string; + auth_method: 'password' | 'key'; + ssh_key_id?: number | null; + notes?: string | null; + created_at: number; + updated_at: number; +} + +export interface CreateLoginCredentialInput { + name: string; + type: 'SSH' | 'RDP' | 'VNC'; + username: string; + auth_method?: 'password' | 'key'; + password?: string; + private_key?: string; + passphrase?: string; + ssh_key_id?: number | null; + notes?: string | null; +} + +export interface UpdateLoginCredentialInput { + name?: string; + type?: 'SSH' | 'RDP' | 'VNC'; + username?: string; + auth_method?: 'password' | 'key'; + password?: string; + private_key?: string; + passphrase?: string; + ssh_key_id?: number | null; + notes?: string | null; +} + +export interface FullLoginCredentialData extends LoginCredentialBase { + encrypted_password?: string | null; + encrypted_private_key?: string | null; + encrypted_passphrase?: string | null; +} + +export interface DecryptedLoginCredentialDetails extends LoginCredentialBase { + password?: string; + privateKey?: string; + passphrase?: string; +} diff --git a/packages/frontend/src/components/AddConnectionFormAuth.vue b/packages/frontend/src/components/AddConnectionFormAuth.vue index 677cf63..aa7a7eb 100644 --- a/packages/frontend/src/components/AddConnectionFormAuth.vue +++ b/packages/frontend/src/components/AddConnectionFormAuth.vue @@ -1,11 +1,14 @@ + + diff --git a/packages/frontend/src/components/LoginCredentialManagementModal.vue b/packages/frontend/src/components/LoginCredentialManagementModal.vue new file mode 100644 index 0000000..bcdbe48 --- /dev/null +++ b/packages/frontend/src/components/LoginCredentialManagementModal.vue @@ -0,0 +1,337 @@ + + + diff --git a/packages/frontend/src/components/LoginCredentialSelector.vue b/packages/frontend/src/components/LoginCredentialSelector.vue new file mode 100644 index 0000000..27d1c04 --- /dev/null +++ b/packages/frontend/src/components/LoginCredentialSelector.vue @@ -0,0 +1,92 @@ + + + diff --git a/packages/frontend/src/composables/useAddConnectionForm.ts b/packages/frontend/src/composables/useAddConnectionForm.ts index f770fa2..0253500 100644 --- a/packages/frontend/src/composables/useAddConnectionForm.ts +++ b/packages/frontend/src/composables/useAddConnectionForm.ts @@ -6,6 +6,7 @@ import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store import { useProxiesStore } from '../stores/proxies.store'; import { useTagsStore } from '../stores/tags.store'; import { useSshKeysStore } from '../stores/sshKeys.store'; +import { useLoginCredentialsStore } from '../stores/loginCredentials.store'; import { useUiNotificationsStore } from '../stores/uiNotifications.store'; import { useConfirmDialog } from './useConfirmDialog'; import { useAlertDialog } from './useAlertDialog'; @@ -33,12 +34,14 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon const proxiesStore = useProxiesStore(); const tagsStore = useTagsStore(); const sshKeysStore = useSshKeysStore(); + const loginCredentialsStore = useLoginCredentialsStore(); const uiNotificationsStore = useUiNotificationsStore(); const { isLoading: isConnLoading, error: connStoreError, connections } = storeToRefs(connectionsStore); const { proxies, isLoading: isProxyLoading, error: proxyStoreError } = storeToRefs(proxiesStore); const { tags, isLoading: isTagLoading, error: tagStoreError } = storeToRefs(tagsStore); const { sshKeys, isLoading: isSshKeyLoading, error: sshKeyStoreError } = storeToRefs(sshKeysStore); + const { loginCredentials, isLoading: isLoginCredentialLoading, error: loginCredentialStoreError } = storeToRefs(loginCredentialsStore); // 表单数据模型 const initialFormData = { @@ -46,6 +49,8 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon name: '', host: '', port: 22, + credential_source: 'direct' as 'direct' | 'saved', + login_credential_id: null as number | null, username: '', auth_method: 'password' as 'password' | 'key', password: '', @@ -65,8 +70,8 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon const advancedConnectionMode = ref<'proxy' | 'jump'>('proxy'); // 合并所有 store 的加载和错误状态 - const isLoading = computed(() => isConnLoading.value || isProxyLoading.value || isTagLoading.value || isSshKeyLoading.value); // +++ Include SSH Key loading +++ - const storeError = computed(() => connStoreError.value || proxyStoreError.value || tagStoreError.value || sshKeyStoreError.value); // +++ Include SSH Key error +++ + const isLoading = computed(() => isConnLoading.value || isProxyLoading.value || isTagLoading.value || isSshKeyLoading.value || isLoginCredentialLoading.value); + const storeError = computed(() => connStoreError.value || proxyStoreError.value || tagStoreError.value || sshKeyStoreError.value || loginCredentialStoreError.value); // 测试连接状态 const testStatus = ref<'idle' | 'testing' | 'success' | 'error'>('idle'); @@ -108,6 +113,8 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon formData.name = newVal.name; formData.host = newVal.host; formData.port = newVal.port; + formData.credential_source = newVal.login_credential_id ? 'saved' : 'direct'; + formData.login_credential_id = newVal.login_credential_id ?? null; formData.username = newVal.username; formData.auth_method = newVal.auth_method; formData.proxy_id = newVal.proxy_id ?? null; @@ -145,6 +152,8 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon } } else { Object.assign(formData, initialFormData); + formData.credential_source = 'direct'; + formData.login_credential_id = null; formData.tag_ids = []; formData.selected_ssh_key_id = null; formData.notes = ''; @@ -161,6 +170,7 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon proxiesStore.fetchProxies(); tagsStore.fetchTags(); sshKeysStore.fetchSshKeys(); + loginCredentialsStore.fetchLoginCredentials(); }); // 监听连接类型变化,动态调整默认端口 @@ -176,6 +186,30 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon formData.auth_method = 'password'; formData.selected_ssh_key_id = null; } + + if (formData.login_credential_id) { + const selectedCredential = loginCredentials.value.find(credential => credential.id === formData.login_credential_id); + if (!selectedCredential || selectedCredential.type !== newType) { + formData.login_credential_id = null; + } + } + }); + + watch(() => formData.login_credential_id, (newCredentialId) => { + if (!newCredentialId) { + return; + } + + const selectedCredential = loginCredentials.value.find((credential) => credential.id === newCredentialId); + if (!selectedCredential) { + return; + } + + formData.username = selectedCredential.username; + formData.auth_method = selectedCredential.auth_method; + if (selectedCredential.auth_method === 'key') { + formData.selected_ssh_key_id = selectedCredential.ssh_key_id ?? null; + } }); @@ -934,4 +968,4 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon removeJumpHost, connections, }; -} \ No newline at end of file +} diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 8834d26..b0fb2bb 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -1476,7 +1476,39 @@ }, "noConnectionsWithTag": "No connections found with this tag", "noConnectionsMatchSearch": "No connections match your search.", - "searchConnectionsPlaceholder": "Search connections..." + "searchConnectionsPlaceholder": "Search connections...", + "summaryLoadFailed": "Failed to load dashboard summary", + "emptyChart": "No statistics available yet", + "listShowing": "Showing the first {count} items", + "topConnections": "Top Active Connections", + "topConnectionsHint": "Ranked by SSH-related audit activity in the last 7 days", + "activityCount": "{count} events", + "lastSeen": "Last seen: {time}", + "summaryCards": { + "connections": "Total Connections", + "activeConnections7d": "Active in 7 Days", + "taggedConnections": "Tagged Connections", + "auditLogs": "Total Audit Logs", + "sshSuccess24h": "SSH Success in 24h", + "sshFailure24h": "SSH Failures in 24h" + }, + "summaryHints": { + "connections": "Your homepage now highlights connection assets, activity, and risk signals at a glance.", + "activeConnections7d": "Connections used at least once in the last 7 days", + "taggedConnections": "Connections already organized with tags", + "auditLogs": "All retained audit events in the current database", + "sshSuccess24h": "Successful SSH connection events over the last 24 hours", + "sshFailure24h": "Failed SSH or shell-open events over the last 24 hours" + }, + "charts": { + "activityTrend7d": "Activity Trend (7 Days)", + "activityTrendHint": "Daily audit event volume for the latest week", + "connectionTypes": "Connection Type Distribution", + "connectionTypesHint": "Current asset composition by protocol", + "actionBreakdown7d": "Event Breakdown (7 Days)", + "actionBreakdownHint": "Most frequent audit event types in the latest week", + "eventCount": "Event Count" + } }, "terminalTabBar": { "selectServerTitle": "Select server to connect", diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index 2312321..90c5197 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -320,7 +320,39 @@ "noConnectionsMatchSearch": "検索条件に一致する接続はありません。", "searchConnectionsPlaceholder": "接続を検索...", "viewAllConnections": "すべての接続を表示", - "viewFullAuditLog": "完全な監査ログを表示" + "viewFullAuditLog": "完全な監査ログを表示", + "summaryLoadFailed": "ダッシュボード統計の読み込みに失敗しました", + "emptyChart": "利用できる統計データがありません", + "listShowing": "先頭 {count} 件を表示中", + "topConnections": "最近もっとも活発な接続", + "topConnectionsHint": "直近 7 日間の SSH 関連監査イベント数で集計", + "activityCount": "{count} 件", + "lastSeen": "最終確認: {time}", + "summaryCards": { + "connections": "接続総数", + "activeConnections7d": "7日以内のアクティブ接続", + "taggedConnections": "タグ付き接続", + "auditLogs": "監査ログ総数", + "sshSuccess24h": "24時間の SSH 成功", + "sshFailure24h": "24時間の SSH 失敗" + }, + "summaryHints": { + "connections": "ホーム画面で接続資産、活動状況、リスクの兆候をまとめて確認できます。", + "activeConnections7d": "直近 7 日で少なくとも 1 回利用された接続数", + "taggedConnections": "タグで整理済みの接続数", + "auditLogs": "現在のデータベースに保存されている監査イベント総数", + "sshSuccess24h": "過去 24 時間に成功した SSH 接続イベント数", + "sshFailure24h": "過去 24 時間に失敗した SSH 接続または Shell 起動イベント数" + }, + "charts": { + "activityTrend7d": "直近 7 日のアクティビティ推移", + "activityTrendHint": "直近 1 週間の監査イベント数を日別に表示", + "connectionTypes": "接続タイプ分布", + "connectionTypesHint": "現在の接続資産をプロトコル別に可視化", + "actionBreakdown7d": "直近 7 日のイベント分布", + "actionBreakdownHint": "最近 1 週間で発生頻度が高い監査イベント種別", + "eventCount": "イベント数" + } }, "dockerManager": { "action": { diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index ce56972..29a9e6f 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -1480,7 +1480,39 @@ }, "noConnectionsWithTag": "该标签下没有连接记录", "noConnectionsMatchSearch": "没有连接匹配搜索条件", - "searchConnectionsPlaceholder": "搜索连接..." + "searchConnectionsPlaceholder": "搜索连接...", + "summaryLoadFailed": "仪表盘统计加载失败", + "emptyChart": "暂无统计数据", + "listShowing": "显示前 {count} 项", + "topConnections": "近期最活跃连接", + "topConnectionsHint": "按最近 7 天 SSH 相关审计事件聚合排序", + "activityCount": "{count} 次", + "lastSeen": "最近出现: {time}", + "summaryCards": { + "connections": "连接总数", + "activeConnections7d": "近 7 天活跃连接", + "taggedConnections": "已打标签连接", + "auditLogs": "审计日志总数", + "sshSuccess24h": "24 小时 SSH 成功", + "sshFailure24h": "24 小时 SSH 失败" + }, + "summaryHints": { + "connections": "首页现在会直接展示连接资产、活动趋势和风险信号。", + "activeConnections7d": "最近 7 天内至少连接过一次的连接数", + "taggedConnections": "已经通过标签完成归类的连接数", + "auditLogs": "当前数据库中保留的全部审计事件数量", + "sshSuccess24h": "最近 24 小时内成功建立的 SSH 连接事件", + "sshFailure24h": "最近 24 小时内 SSH 连接或 Shell 打开失败事件" + }, + "charts": { + "activityTrend7d": "近 7 天活动趋势", + "activityTrendHint": "按天统计最近一周的审计事件量", + "connectionTypes": "连接类型分布", + "connectionTypesHint": "当前连接资产按协议的占比情况", + "actionBreakdown7d": "近 7 天事件分布", + "actionBreakdownHint": "最近一周出现频率最高的审计事件类型", + "eventCount": "事件数" + } }, "terminalTabBar": { "selectServerTitle": "选择要连接的服务器", diff --git a/packages/frontend/src/stores/dashboard.store.ts b/packages/frontend/src/stores/dashboard.store.ts new file mode 100644 index 0000000..036a1db --- /dev/null +++ b/packages/frontend/src/stores/dashboard.store.ts @@ -0,0 +1,59 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import apiClient from '../utils/apiClient'; +import type { DashboardSummary } from '../types/server.types'; + +export const useDashboardStore = defineStore('dashboard', () => { + const summary = ref(null); + const isLoading = ref(false); + const error = ref(null); + + async function fetchSummary(): Promise { + const cacheKey = 'dashboardSummaryCache'; + error.value = null; + + try { + const cachedData = localStorage.getItem(cacheKey); + if (cachedData) { + summary.value = JSON.parse(cachedData) as DashboardSummary; + isLoading.value = false; + } else { + isLoading.value = true; + } + } catch (cacheError) { + console.error('[DashboardStore] Failed to load dashboard cache:', cacheError); + localStorage.removeItem(cacheKey); + isLoading.value = true; + } + + isLoading.value = true; + + try { + const response = await apiClient.get('/dashboard/summary'); + const freshData = response.data; + const freshDataString = JSON.stringify(freshData); + const currentDataString = JSON.stringify(summary.value); + + if (freshDataString !== currentDataString) { + summary.value = freshData; + localStorage.setItem(cacheKey, freshDataString); + } + + error.value = null; + return true; + } catch (err: any) { + console.error('[DashboardStore] Failed to fetch dashboard summary:', err); + error.value = err.response?.data?.message || err.message || '获取仪表盘统计失败'; + return false; + } finally { + isLoading.value = false; + } + } + + return { + summary, + isLoading, + error, + fetchSummary, + }; +}); diff --git a/packages/frontend/src/stores/loginCredentials.store.ts b/packages/frontend/src/stores/loginCredentials.store.ts new file mode 100644 index 0000000..ad7a21d --- /dev/null +++ b/packages/frontend/src/stores/loginCredentials.store.ts @@ -0,0 +1,130 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import apiClient from '../utils/apiClient'; + +export interface LoginCredentialBasicInfo { + id: number; + name: string; + type: 'SSH' | 'RDP' | 'VNC'; + username: string; + auth_method: 'password' | 'key'; + ssh_key_id?: number | null; + notes?: string | null; +} + +export interface LoginCredentialDetails extends LoginCredentialBasicInfo { + password?: string; + privateKey?: string; + passphrase?: string; +} + +export interface LoginCredentialInput { + name: string; + type: 'SSH' | 'RDP' | 'VNC'; + username: string; + auth_method?: 'password' | 'key'; + password?: string; + private_key?: string; + passphrase?: string; + ssh_key_id?: number | null; + notes?: string | null; +} + +export const useLoginCredentialsStore = defineStore('loginCredentials', () => { + const loginCredentials = ref([]); + const isLoading = ref(false); + const error = ref(null); + + async function fetchLoginCredentials() { + isLoading.value = true; + error.value = null; + try { + const response = await apiClient.get('/login-credentials'); + loginCredentials.value = response.data; + } catch (err: any) { + console.error('Failed to fetch login credentials:', err); + error.value = err.response?.data?.message || err.message || '获取登录凭证列表失败。'; + } finally { + isLoading.value = false; + } + } + + async function addLoginCredential(payload: LoginCredentialInput): Promise { + isLoading.value = true; + error.value = null; + try { + const response = await apiClient.post<{ message: string; credential: LoginCredentialBasicInfo }>('/login-credentials', payload); + loginCredentials.value.unshift(response.data.credential); + loginCredentials.value.sort((left, right) => left.name.localeCompare(right.name)); + return true; + } catch (err: any) { + console.error('Failed to add login credential:', err); + error.value = err.response?.data?.message || err.message || '创建登录凭证失败。'; + return false; + } finally { + isLoading.value = false; + } + } + + async function fetchLoginCredentialDetails(id: number): Promise { + isLoading.value = true; + error.value = null; + try { + const response = await apiClient.get(`/login-credentials/${id}/details`); + return response.data; + } catch (err: any) { + console.error(`Failed to fetch login credential ${id}:`, err); + error.value = err.response?.data?.message || err.message || '获取登录凭证详情失败。'; + return null; + } finally { + isLoading.value = false; + } + } + + async function updateLoginCredential(id: number, payload: Partial): Promise { + isLoading.value = true; + error.value = null; + try { + const response = await apiClient.put<{ message: string; credential: LoginCredentialBasicInfo }>(`/login-credentials/${id}`, payload); + const index = loginCredentials.value.findIndex((credential) => credential.id === id); + if (index !== -1) { + loginCredentials.value[index] = { ...loginCredentials.value[index], ...response.data.credential }; + loginCredentials.value.sort((left, right) => left.name.localeCompare(right.name)); + } + return true; + } catch (err: any) { + console.error(`Failed to update login credential ${id}:`, err); + error.value = err.response?.data?.message || err.message || '更新登录凭证失败。'; + return false; + } finally { + isLoading.value = false; + } + } + + async function deleteLoginCredential(id: number): Promise { + isLoading.value = true; + error.value = null; + try { + await apiClient.delete(`/login-credentials/${id}`); + loginCredentials.value = loginCredentials.value.filter((credential) => credential.id !== id); + return true; + } catch (err: any) { + console.error(`Failed to delete login credential ${id}:`, err); + error.value = err.response?.data?.message || err.message || '删除登录凭证失败。'; + return false; + } finally { + isLoading.value = false; + } + } + + return { + loginCredentials, + isLoading, + error, + fetchLoginCredentials, + addLoginCredential, + fetchLoginCredentialDetails, + updateLoginCredential, + deleteLoginCredential, + }; +}); diff --git a/packages/frontend/src/types/server.types.ts b/packages/frontend/src/types/server.types.ts index 260a6dc..ec416cd 100644 --- a/packages/frontend/src/types/server.types.ts +++ b/packages/frontend/src/types/server.types.ts @@ -92,10 +92,17 @@ export type NotificationSettingData = Omit -import { ref, computed, onMounted, watch } from 'vue'; -import AddConnectionForm from '../components/AddConnectionForm.vue'; -import { useConnectionsStore } from '../stores/connections.store'; -import { useAuditLogStore } from '../stores/audit.store'; -import { useSessionStore } from '../stores/session.store'; -import { useTagsStore } from '../stores/tags.store'; -import type { TagInfo } from '../stores/tags.store'; - -import type { SortField, SortOrder } from '../stores/settings.store'; +import { computed, onMounted, ref, watch } from 'vue'; +import { storeToRefs } from 'pinia'; import { useI18n } from 'vue-i18n'; -import { useRouter } from 'vue-router'; -import type { ConnectionInfo } from '../stores/connections.store'; -import { storeToRefs } from 'pinia'; import { formatDistanceToNow } from 'date-fns'; -import { zhCN, enUS, ja } from 'date-fns/locale'; +import { enUS, ja, zhCN } from 'date-fns/locale'; import type { Locale } from 'date-fns'; +import AddConnectionForm from '../components/AddConnectionForm.vue'; +import DashboardOverviewPanel from '../components/DashboardOverviewPanel.vue'; +import { useAuditLogStore } from '../stores/audit.store'; +import { useConnectionsStore, type ConnectionInfo } from '../stores/connections.store'; +import { useDashboardStore } from '../stores/dashboard.store'; +import { useSessionStore } from '../stores/session.store'; +import { useTagsStore, type TagInfo } from '../stores/tags.store'; +import type { AuditLogEntry } from '../types/server.types'; +import type { SortField, SortOrder } from '../stores/settings.store'; const { t, locale } = useI18n(); -const router = useRouter(); + const connectionsStore = useConnectionsStore(); const auditLogStore = useAuditLogStore(); +const dashboardStore = useDashboardStore(); const sessionStore = useSessionStore(); -const tagsStore = useTagsStore(); - +const tagsStore = useTagsStore(); const { connections, isLoading: isLoadingConnections } = storeToRefs(connectionsStore); -const { logs: auditLogs, isLoading: isLoadingLogs, totalLogs } = storeToRefs(auditLogStore); -const { tags, isLoading: isLoadingTags } = storeToRefs(tagsStore); - - +const { logs: auditLogs, isLoading: isLoadingLogs } = storeToRefs(auditLogStore); +const { summary, isLoading: isLoadingSummary, error: dashboardError } = storeToRefs(dashboardStore); +const { tags, isLoading: isLoadingTags } = storeToRefs(tagsStore); const LS_SORT_BY_KEY = 'dashboard_connections_sort_by'; const LS_SORT_ORDER_KEY = 'dashboard_connections_sort_order'; -const LS_FILTER_TAG_KEY = 'dashboard_connections_filter_tag'; +const LS_FILTER_TAG_KEY = 'dashboard_connections_filter_tag'; +const MAX_RECENT_LOGS = 5; +const MAX_VISIBLE_CONNECTIONS = 8; -// Initialize with localStorage values or defaults -const localSortBy = ref(localStorage.getItem(LS_SORT_BY_KEY) as SortField || 'last_connected_at'); -const localSortOrder = ref(localStorage.getItem(LS_SORT_ORDER_KEY) as SortOrder || 'desc'); -// +++ 初始化标签筛选状态,从 localStorage 读取,注意类型转换 (修正 ref 初始化) +++ -const getInitialSelectedTagId = (): number | null => { - const storedValue = localStorage.getItem(LS_FILTER_TAG_KEY); - // 如果存储的值是 'null' 字符串或空,则返回 null,否则解析为数字 - return storedValue && storedValue !== 'null' ? parseInt(storedValue, 10) : null; -}; +const localSortBy = ref((localStorage.getItem(LS_SORT_BY_KEY) as SortField) || 'last_connected_at'); +const localSortOrder = ref((localStorage.getItem(LS_SORT_ORDER_KEY) as SortOrder) || 'desc'); const selectedTagId = ref(getInitialSelectedTagId()); const searchQuery = ref(''); - -// +++ 控制添加/编辑表单的显示状态 +++ const showAddEditConnectionForm = ref(false); const connectionToEdit = ref(null); -const maxRecentLogs = 5; - +const dateFnsLocales: Record = { + 'en-US': enUS, + 'zh-CN': zhCN, + 'ja-JP': ja, + en: enUS, + zh: zhCN, + ja, +}; + const sortOptions: { value: SortField; labelKey: string }[] = [ { value: 'last_connected_at', labelKey: 'dashboard.sortOptions.lastConnected' }, { value: 'name', labelKey: 'dashboard.sortOptions.name' }, @@ -60,113 +58,185 @@ const sortOptions: { value: SortField; labelKey: string }[] = [ { value: 'created_at', labelKey: 'dashboard.sortOptions.created' }, ]; -// +++ 修改计算属性,先筛选再排序 +++ +function getInitialSelectedTagId(): number | null { + const storedValue = localStorage.getItem(LS_FILTER_TAG_KEY); + return storedValue && storedValue !== 'null' ? parseInt(storedValue, 10) : null; +} + +const recentAuditLogs = computed(() => auditLogs.value.slice(0, MAX_RECENT_LOGS)); + const filteredAndSortedConnections = computed(() => { - const sortBy = localSortBy.value; - const sortOrderVal = localSortOrder.value; - const factor = sortOrderVal === 'desc' ? -1 : 1; + const factor = localSortOrder.value === 'desc' ? -1 : 1; + const query = searchQuery.value.toLowerCase().trim(); const filterTagId = selectedTagId.value; - const query = searchQuery.value.toLowerCase().trim(); // +++ 获取搜索查询 +++ - - // 1. Filter by selected tag - let filteredByTag = filterTagId === null - ? [...connections.value] // No tag selected, show all - : connections.value.filter(conn => conn.tag_ids?.includes(filterTagId)); - - // 2. Filter by search query - let searchedConnections = filteredByTag; + + let filtered = filterTagId === null + ? [...connections.value] + : connections.value.filter((connection) => connection.tag_ids?.includes(filterTagId)); + if (query) { - searchedConnections = filteredByTag.filter(conn => { - const nameMatch = conn.name?.toLowerCase().includes(query); - const usernameMatch = conn.username?.toLowerCase().includes(query); - const hostMatch = conn.host?.toLowerCase().includes(query); - const portMatch = conn.port?.toString().includes(query); + filtered = filtered.filter((connection) => { + const nameMatch = connection.name?.toLowerCase().includes(query); + const usernameMatch = connection.username?.toLowerCase().includes(query); + const hostMatch = connection.host?.toLowerCase().includes(query); + const portMatch = connection.port?.toString().includes(query); return nameMatch || usernameMatch || hostMatch || portMatch; }); } - - // 3. Sort the searched connections - return searchedConnections.sort((a, b) => { - let valA: any; - let valB: any; - switch (sortBy) { + return filtered.sort((left, right) => { + switch (localSortBy.value) { case 'name': - valA = a.name || ''; - valB = b.name || ''; - return valA.localeCompare(valB) * factor; + return (left.name || '').localeCompare(right.name || '') * factor; case 'type': - valA = a.type || ''; - valB = b.type || ''; - return valA.localeCompare(valB) * factor; + return (left.type || '').localeCompare(right.type || '') * factor; case 'created_at': - valA = a.created_at ?? 0; - valB = b.created_at ?? 0; - return (valA - valB) * factor; + return ((left.created_at ?? 0) - (right.created_at ?? 0)) * factor; case 'updated_at': - valA = a.updated_at ?? 0; - valB = b.updated_at ?? 0; - return (valA - valB) * factor; - case 'last_connected_at': - valA = a.last_connected_at ?? (sortOrderVal === 'desc' ? -Infinity : Infinity); - valB = b.last_connected_at ?? (sortOrderVal === 'desc' ? -Infinity : Infinity); - if (valA === valB) return 0; - if (valA < valB) return -1 * factor; - return 1 * factor; + return ((left.updated_at ?? 0) - (right.updated_at ?? 0)) * factor; + case 'last_connected_at': { + const leftValue = left.last_connected_at ?? (localSortOrder.value === 'desc' ? -Infinity : Infinity); + const rightValue = right.last_connected_at ?? (localSortOrder.value === 'desc' ? -Infinity : Infinity); + if (leftValue === rightValue) { + return 0; + } + return leftValue < rightValue ? -1 * factor : 1 * factor; + } default: return 0; } }); }); -const recentAuditLogs = computed(() => { - return auditLogs.value.slice(0, maxRecentLogs); +const visibleConnections = computed(() => filteredAndSortedConnections.value.slice(0, MAX_VISIBLE_CONNECTIONS)); +const isAscending = computed(() => localSortOrder.value === 'asc'); + +const connectionsById = computed(() => { + const map = new Map(); + for (const connection of connections.value) { + map.set(connection.id, connection); + } + return map; }); -onMounted(async () => { - // Load saved preferences from localStorage (already done during ref initialization) - - // Fetch connections if not already loaded - if (connections.value.length === 0) { - try { - await connectionsStore.fetchConnections(); - } catch (error) { - console.error("加载连接列表失败:", error); - } +const topConnections = computed(() => { + if (!summary.value) { + return [] as Array<{ connectionId: number; connectionName: string; host: string; count: number; lastSeenAt: number; connection: ConnectionInfo | null }>; + } + + return summary.value.topConnections.map((item) => ({ + ...item, + connection: connectionsById.value.get(item.connectionId) ?? null, + })); +}); + +function resolveDateFnsLocale(): Locale { + return dateFnsLocales[locale.value] || dateFnsLocales[locale.value.split('-')[0]] || enUS; +} + +function formatRelativeTime(timestampInSeconds: number | null | undefined): string { + if (!timestampInSeconds) { + return t('connections.status.never'); } - // Fetch recent audit logs try { - await auditLogStore.fetchLogs({ - page: 1, - limit: maxRecentLogs, - sortOrder: 'desc', - isDashboardRequest: true + return formatDistanceToNow(new Date(timestampInSeconds * 1000), { + addSuffix: true, + locale: resolveDateFnsLocale(), }); } catch (error) { - console.error("加载审计日志失败:", error); + console.error('[Dashboard] Failed to format relative time:', error); + return String(timestampInSeconds); + } +} + +function formatNumber(value: number): string { + return new Intl.NumberFormat(locale.value).format(value); +} + +function getActionTranslation(actionType: string): string { + const key = `auditLog.actions.${actionType}`; + const translated = t(key); + return translated === key ? actionType : translated; +} + +function isFailedAction(actionType: string): boolean { + const lowerCaseAction = actionType.toLowerCase(); + return lowerCaseAction.includes('fail') || lowerCaseAction.includes('error') || lowerCaseAction.includes('denied'); +} + +function getTagNames(tagIds: number[] | undefined): string[] { + if (!tagIds || tagIds.length === 0) { + return []; } - // +++ Fetch tags for filtering +++ - try { - await tagsStore.fetchTags(); - } catch (error) { - console.error("加载标签列表失败:", error); + const allTags = tags.value as TagInfo[]; + return tagIds + .map((id) => allTags.find((tag) => tag.id === id)?.name) + .filter((name): name is string => !!name); +} + +function formatAuditDetails(details: AuditLogEntry['details']): string { + if (!details) { + return '-'; } -}); -const connectTo = (connection: ConnectionInfo) => { - sessionStore.handleConnectRequest(connection); -}; + if ('raw' in details) { + return details.raw; + } -const toggleSortOrder = () => { - // Only update the local sort order state + const formattedEntries = Object.entries(details) + .filter(([key]) => !['userId', 'sessionId'].includes(key)) + .slice(0, 4) + .map(([key, value]) => `${key}: ${formatAuditDetailValue(value)}`); + + return formattedEntries.length > 0 ? formattedEntries.join(' | ') : JSON.stringify(details); +} + +function formatAuditDetailValue(value: unknown): string { + if (value === null || value === undefined) { + return '-'; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return JSON.stringify(value); +} + +function connectTo(connection: ConnectionInfo | null | undefined): void { + if (connection) { + sessionStore.handleConnectRequest(connection); + } +} + +function toggleSortOrder(): void { localSortOrder.value = localSortOrder.value === 'asc' ? 'desc' : 'asc'; -}; +} -const isAscending = computed(() => localSortOrder.value === 'asc'); // Use local state +function openAddConnectionForm(): void { + connectionToEdit.value = null; + showAddEditConnectionForm.value = true; +} + +function openEditConnectionForm(connection: ConnectionInfo): void { + connectionToEdit.value = connection; + showAddEditConnectionForm.value = true; +} + +function handleFormClose(): void { + showAddEditConnectionForm.value = false; + connectionToEdit.value = null; +} + +async function handleConnectionModified(): Promise { + showAddEditConnectionForm.value = false; + connectionToEdit.value = null; + await connectionsStore.fetchConnections(); + await dashboardStore.fetchSummary(); +} -// Watch for changes in local sort state and save to localStorage watch(localSortBy, (newValue) => { localStorage.setItem(LS_SORT_BY_KEY, newValue); }); @@ -175,249 +245,211 @@ watch(localSortOrder, (newValue) => { localStorage.setItem(LS_SORT_ORDER_KEY, newValue); }); -// +++ Watch for changes in selected tag and save to localStorage +++ watch(selectedTagId, (newValue) => { - // Store 'null' as a string or the number localStorage.setItem(LS_FILTER_TAG_KEY, newValue === null ? 'null' : String(newValue)); }); -const dateFnsLocales: Record = { - 'en-US': enUS, - 'zh-CN': zhCN, - 'ja-JP': ja, - // 主语言回退 - 'en': enUS, - 'zh': zhCN, - 'ja': ja, -}; - -// 修正函数签名,接受 number | null | undefined -const formatRelativeTime = (timestampInSeconds: number | null | undefined): string => { - if (!timestampInSeconds) return t('connections.status.never'); - try { - // 将秒级时间戳转换为毫秒级 - const timestampInMs = timestampInSeconds * 1000; - // 检查转换后的值是否有效 - if (isNaN(timestampInMs)) { - console.warn(`[Dashboard] Invalid timestamp received: ${timestampInSeconds}`); - return String(timestampInSeconds); // 返回原始值或错误提示 - } - const date = new Date(timestampInMs); - - const currentI18nLocale = locale.value; // 获取 vue-i18n 当前 locale (e.g., 'zh-CN') - const langPart = currentI18nLocale.split('-')[0]; // 获取主语言部分 (e.g., 'zh') - - // 1. 尝试精确匹配 (e.g., 'zh-CN' -> zhCN) - let targetDateFnsLocale = dateFnsLocales[currentI18nLocale]; - - // 2. 如果无精确匹配,尝试匹配主语言 (e.g., 'zh' -> zhCN) - if (!targetDateFnsLocale) { - targetDateFnsLocale = dateFnsLocales[langPart]; - } - - // 3. 如果仍然找不到,回退到默认 enUS - if (!targetDateFnsLocale) { - console.warn(`[Dashboard] date-fns locale not found for ${currentI18nLocale} or ${langPart}. Falling back to en-US.`); - targetDateFnsLocale = enUS; // 默认回退到 enUS - } - - return formatDistanceToNow(date, { addSuffix: true, locale: targetDateFnsLocale }); - } catch (e) { - console.error("格式化日期失败:", e); - return String(timestampInSeconds); // 出错时返回原始字符串 - } -}; - -const getActionTranslation = (actionType: string): string => { - // 尝试从 i18n 获取翻译,如果找不到则返回原始 actionType - const key = `auditLog.actions.${actionType}`; - const translated = t(key); - // 如果翻译结果等于 key 本身,说明没有找到翻译 - return translated === key ? actionType : translated; -}; - -// 辅助函数:判断活动类型是否表示失败 -const isFailedAction = (actionType: string): boolean => { - const lowerCaseAction = actionType.toLowerCase(); - // 检查常见的失败关键词 - return lowerCaseAction.includes('fail') || lowerCaseAction.includes('error') || lowerCaseAction.includes('denied'); -}; - -// +++ 恢复:根据 tag_ids 获取标签名称数组 +++ -const getTagNames = (tagIds: number[] | undefined): string[] => { - if (!tagIds || tagIds.length === 0) { - return []; - } - const allTags = tags.value as TagInfo[]; - return tagIds - .map(id => allTags.find(tag => tag.id === id)?.name) - .filter((name): name is string => !!name); // 过滤掉未找到的标签并确保类型为 string -}; - -// +++ 打开添加表单 +++ -const openAddConnectionForm = () => { - connectionToEdit.value = null; - showAddEditConnectionForm.value = true; -}; - -// +++ 打开编辑表单 +++ -const openEditConnectionForm = (conn: ConnectionInfo) => { - connectionToEdit.value = conn; - showAddEditConnectionForm.value = true; -}; - -// +++ 处理表单关闭事件 +++ -const handleFormClose = () => { - showAddEditConnectionForm.value = false; - connectionToEdit.value = null; // 清除编辑状态 -}; - -// +++ 处理连接添加/更新成功事件 +++ -const handleConnectionModified = async () => { - showAddEditConnectionForm.value = false; - connectionToEdit.value = null; - await connectionsStore.fetchConnections(); // 重新加载连接列表 -}; - -// --- 移除 selectTagFilter 函数 --- - +onMounted(async () => { + await Promise.allSettled([ + connectionsStore.fetchConnections(), + auditLogStore.fetchLogs({ + page: 1, + limit: MAX_RECENT_LOGS, + sortOrder: 'desc', + isDashboardRequest: true, + }), + tagsStore.fetchTags(), + dashboardStore.fetchSummary(), + ]); +});