From de272c5b8f88d969183033b6bffcf4780f116e00 Mon Sep 17 00:00:00 2001 From: Adsryen Date: Sat, 27 Jun 2026 17:18:58 +0800 Subject: [PATCH 001/167] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=AE=A1=E8=AE=A1=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 Vitest 并覆盖前端核心纯函数与状态模块 - 新增配置归一化和审计矩阵计算逻辑 - 拆分 Nacos client 结构并补充后端单元测试 - 完善 todo 阶段规划与后续 UI 承接事项 --- docs/todo/todo.md | 907 ++++++++++++++++++++++++++++------ internal/nacos/auth.go | 75 +++ internal/nacos/client.go | 623 ----------------------- internal/nacos/client_test.go | 255 +++++++++- internal/nacos/config.go | 151 ++++++ internal/nacos/decode.go | 109 ++++ internal/nacos/history.go | 81 +++ internal/nacos/namespace.go | 39 ++ internal/nacos/request.go | 134 +++++ internal/nacos/types.go | 60 +++ internal/nacos/version.go | 18 + internal/ssh/manager_test.go | 59 +++ internal/ssh/tunnel_test.go | 101 ++++ package.json | 5 +- pnpm-lock.yaml | 283 +++++++++++ src/lib/audit.test.ts | 118 +++++ src/lib/audit.ts | 156 ++++++ src/lib/diff.test.ts | 67 +++ src/lib/format.test.ts | 38 ++ src/lib/highlight.test.ts | 25 + src/lib/highlight.ts | Bin 2191 -> 2196 bytes src/lib/keys.test.ts | 58 +++ src/lib/normalize.test.ts | 145 ++++++ src/lib/normalize.ts | 160 ++++++ src/lib/toast.test.ts | 45 ++ src/lib/validate.test.ts | 46 ++ src/lib/validate.ts | Bin 1927 -> 2663 bytes src/store/connections.test.ts | 112 +++++ 28 files changed, 3106 insertions(+), 764 deletions(-) create mode 100644 internal/nacos/auth.go create mode 100644 internal/nacos/config.go create mode 100644 internal/nacos/decode.go create mode 100644 internal/nacos/history.go create mode 100644 internal/nacos/namespace.go create mode 100644 internal/nacos/request.go create mode 100644 internal/nacos/types.go create mode 100644 internal/nacos/version.go create mode 100644 internal/ssh/manager_test.go create mode 100644 internal/ssh/tunnel_test.go create mode 100644 src/lib/audit.test.ts create mode 100644 src/lib/audit.ts create mode 100644 src/lib/diff.test.ts create mode 100644 src/lib/format.test.ts create mode 100644 src/lib/highlight.test.ts create mode 100644 src/lib/keys.test.ts create mode 100644 src/lib/normalize.test.ts create mode 100644 src/lib/normalize.ts create mode 100644 src/lib/toast.test.ts create mode 100644 src/lib/validate.test.ts create mode 100644 src/store/connections.test.ts diff --git a/docs/todo/todo.md b/docs/todo/todo.md index 3013e3a..b4b9b7e 100644 --- a/docs/todo/todo.md +++ b/docs/todo/todo.md @@ -1,197 +1,826 @@ -# ConfScope 开发待办事项 +# ConfScope 开发待办规划 -**最后更新**: 2026-06-25 +**最后更新**: 2026-06-27 +**规划参考**: `/mnt/c/Users/adsry/Desktop/fsdownload/ConfigCenterComparer` +**当前定位**: Go + Wails 桌面端配置中心管理、对比、审计工具。MVP 已支持 Nacos,后续应从“单配置浏览与 diff”升级为“多环境配置治理”。 --- -## 🔴 高优先级 +## 0. 参考项目结论与取舍 -### CI/CD 与发布 -- [ ] **修复 macOS ARM64 构建** - Wails CLI 在 macOS ARM64 上出现 `dyld: missing LC_UUID load command` 错误 - - **问题**: `wails build` 在 macOS ARM64 (M1/M2) 上崩溃 - - **错误**: `Abort trap: 6` - - **临时方案**: 已移除 macOS ARM64 构建目标 - - **待查**: 可能需要更新 Wails CLI 版本或调整构建配置 - - **参考**: https://github.com/wailsapp/wails/issues +`ConfigCenterComparer` 值得参考的是产品模型,不是代码实现。它通过数据库读取 Apollo/Nacos 配置,把不同环境的数据整理成统一矩阵,再做一致性判断、忽略、过滤、导出和重复配置检查。 -### 功能完善 -- [ ] 添加配置导入导出功能 -- [ ] 实现配置模板管理 -- [ ] 支持批量操作(批量删除、批量对比) +### 值得吸收 ---- +- [ ] **多环境一致性矩阵**:把 `配置中心 / 环境 / 命名空间 / group / dataId / key / value / 更新时间` 规整成统一行模型,再计算一致性状态。 +- [ ] **key 级配置拆解**:将 YAML、JSON、Properties 等内容解析为 `key -> value`,而不是只做整文件文本 diff。 +- [ ] **忽略列表**:允许用户把预期不同的配置项加入忽略规则,减少巡检噪音。 +- [ ] **名称归一化**:支持 dataId、namespace、service/app 名称的前缀裁剪、后缀裁剪、精确替换,用于对齐不同环境命名。 +- [ ] **重复配置检查**:在同一环境内找出多个服务重复配置了相同 `key + value` 的场景。 +- [ ] **导出审计结果**:支持把一致性矩阵、异常项、重复项导出为 CSV/JSON。 -## 🟡 中优先级 +### 不建议照搬 -### 多配置中心支持 -- [ ] **Apollo 配置中心适配** - - 设计 Apollo 客户端接口 - - 实现 Apollo OpenAPI 集成 - - 支持 Apollo 配置浏览和历史查看 - -- [ ] **Consul 配置中心适配** - - 设计 Consul KV 客户端接口 - - 实现 Consul HTTP API 集成 - - 支持 Consul 配置浏览 +- [ ] **不直接复制 GPL-3.0 代码**:参考项目是 GPL-3.0,ConfScope 是 MIT,只能借鉴产品思路并重写实现。 +- [ ] **不优先走数据库直连**:参考项目通过 MySQL 表读取 Apollo/Nacos,依赖内部表结构。ConfScope 应优先走配置中心 OpenAPI,保留权限、历史、发布语义。 +- [ ] **不固定 4 个环境**:参考项目偏 `PRO/PRE/TEST/DEV`,ConfScope 应支持任意数量环境,并允许用户指定基准环境。 +- [ ] **不只支持 YAML**:ConfScope 当前已有多格式识别能力,后续应支持 YAML、JSON、Properties、TOML;XML/TEXT 可做降级处理。 +- [ ] **不把比较规则写死**:一致性状态、忽略规则、生产环境确认规则都应配置化。 -### UI/UX 改进 -- [ ] 添加配置内容语法验证(JSON、YAML 格式检查) -- [ ] 实现配置内容搜索(全文搜索) -- [ ] 优化大文件加载性能 -- [ ] 添加配置收藏功能 -- [ ] 支持配置标签分类 +--- -### 性能优化 -- [ ] 实现配置内容懒加载 -- [ ] 添加本地缓存机制 -- [ ] 优化 diff 算法性能(大文件场景) +## 1. 总体架构方向 + +当前项目已经有 `internal/nacos`、`src/api/nacos.ts`、`DiffView`、`ConfigBrowser`。下一阶段应先抽象“配置中心通用模型”,否则 Apollo、Consul、备份、本地模式、审计矩阵都会继续绑死在 Nacos 上。 + +### 1.1 配置中心 Provider 抽象 + +- [ ] **设计 Go 后端通用接口** + - `ListNamespaces` + - `ListConfigs` + - `GetConfig` + - `PublishConfig` + - `DeleteConfig` + - `ListHistory` + - `GetHistoryDetail` + - `TestConnection` +- [ ] **定义统一领域模型** + - `ProviderType`: `nacos | apollo | consul | local` + - `ConnectionProfile`: 连接、认证、代理、SSH、环境标签、安全等级 + - `ConfigRef`: provider、connectionId、namespace、group、dataId、key + - `ConfigDocument`: 原始内容、格式、更新时间、版本、来源 + - `ConfigEntry`: 拆解后的 key/value/path/type +- [ ] **保留 Nacos 现有能力,但迁移到 provider 模型** + - 先做适配层,不重写所有业务。 + - 前端仍可通过 `src/api/nacos.ts` 兼容过渡,新增通用 `src/api/configCenter.ts`。 + +### 1.2 前端模块边界 + +- [ ] `ConfigBrowser` 保持负责浏览和编辑单个配置。 +- [ ] `DiffView` 保持负责两个来源或批量 dataId 的文本级/key 级 diff。 +- [ ] 新增 `AuditView`,负责多环境一致性矩阵、过滤、忽略、导出。 +- [ ] 新增 `BackupView`,负责云端/本地/WebDAV 备份恢复。 +- [ ] 新增 `SettingsView`,集中管理代理、安全确认、名称归一化、忽略规则。 +- [ ] 顶层导航从“顶部双模式按钮”升级为“左侧主导航 + 顶部上下文工具栏”。 + - 左侧主导航承载:浏览、对比、审计、备份、任务、设置。 + - 顶部工具栏只展示当前页面相关的连接、命名空间、筛选、刷新等操作。 + - 保持深色、紧凑、工程工具风格,不做营销式大卡片布局。 + +### 1.3 UI/交互架构方向 + +- [ ] **浏览类页面采用双栏结构** + - 左侧:连接、命名空间、配置列表、搜索。 + - 右侧:内容、历史、编辑、diff。 +- [ ] **治理类页面采用矩阵结构** + - 顶部:审计范围、基准环境、过滤条件。 + - 中部:状态摘要与矩阵表格。 + - 右侧或底部:选中行详情、diff、忽略原因。 +- [ ] **设置类页面采用分组表单** + - 基础信息。 + - 认证。 + - 网络。 + - 安全。 + - 高级。 +- [ ] **长任务采用任务中心** + - 批量审计、备份、恢复、导出、WebDAV 上传下载都进入任务中心。 + - 展示进度、成功数、失败数、跳过数、可取消状态和错误详情。 +- [ ] **危险操作采用统一确认组件** + - 普通确认。 + - 高风险二次确认。 + - 生产环境输入指定文字确认。 + - dry-run 预览后确认执行。 +- [ ] **状态色体系统一** + - 一致。 + - 部分一致。 + - 不一致。 + - 缺失。 + - 无法解析。 + - 已忽略。 + - 危险环境。 + +### 1.4 Go 与前端职责取舍 + +- [ ] **Go 后端负责** + - OpenAPI 调用、SSH 隧道、代理、文件读写、WebDAV、敏感信息处理。 + - 大批量拉取时的并发控制、取消、超时、错误聚合。 +- [ ] **前端负责** + - 交互状态、表格过滤、diff 展示、用户确认流程。 + - 小规模解析和展示可以留在前端。 +- [ ] **共享算法优先放 TypeScript** + - 配置格式识别、key/value 归一化、矩阵计算先放前端,迭代快。 + - 如果后续出现大数据性能瓶颈,再迁移到 Go。 --- -## 🟢 低优先级 +## 2. 高优先级:审计矩阵与批量治理 + +这是最应该从 `ConfigCenterComparer` 吸收的核心能力。目标是让 ConfScope 从“打开两个配置看 diff”升级为“批量发现环境差异”。 + +### 2.1 配置内容标准化 + +- [x] **实现 `normalizeConfig(content, format)`** + - YAML:使用现有 `yaml` 依赖解析,输出点路径 key。 + - JSON:递归展开对象;数组默认作为整体值,后续再考虑数组下标策略。 + - Properties:按 `key=value` / `key: value` 解析。 + - TOML:可先按文本降级,后续再引入解析。 + - XML/TEXT:暂不拆 key,只保留整文档 hash 和文本 diff。 +- [x] **输出统一 `ConfigEntry`** + - `key` + - `value` + - `valueType` + - `sourcePath` + - `parseStatus` + - `parseError` +- [x] **补测试** + - YAML 嵌套对象。 + - JSON 嵌套对象。 + - Properties 注释与空行。 + - 解析失败降级。 + +### 2.2 多环境一致性矩阵 + +- [ ] **新增 AuditView** + - 选择多个连接作为环境。 + - 每个环境可选择 namespace、group、dataId 过滤条件。 + - 支持同名 dataId 批量匹配。 + - 支持按基准环境比较。 +- [ ] **AuditView UI 布局** + - 顶部条件栏:环境选择、namespace/group/dataId 范围、基准环境、比较模式。 + - 状态摘要条:总数、一致、不一致、缺失、无法解析、已忽略。 + - 主矩阵表格:`dataId / key / 状态 / 各环境值 / 更新时间`。 + - 详情面板:展示当前行在各环境的原始值、差异、来源链接。 +- [x] **生成矩阵行** + - 主键:`normalizedName + namespace + group + dataId + key` + - 列:每个环境的 value、更新时间、存在状态。 + - 状态:`一致 / 部分一致 / 不一致 / 缺失 / 无法解析 / 已忽略` +- [ ] **矩阵结果接入 UI** + - 当前已完成 `buildAuditMatrix` 纯函数,AuditView 还未接入。 + - UI 接入时复用现有状态枚举,不在组件里重复写判断逻辑。 + - 状态摘要、过滤、导出都应基于同一份矩阵结果派生。 +- [ ] **过滤与排序** + - 仅看不一致。 + - 仅看缺失。 + - 仅看无法解析。 + - 隐藏已忽略。 + - 按 dataId、key、状态、环境排序。 +- [ ] **矩阵行跳转** + - 点击某行可打开对应 dataId 的详细 diff。 + - 可从矩阵直接复制 key/value。 +- [ ] **矩阵大数据交互** + - 使用虚拟滚动,避免上万行卡顿。 + - 支持列冻结:固定 `dataId/key/status`。 + - 支持列显隐:环境列、更新时间列、原始名称列。 + - 支持保存过滤条件。 + +### 2.3 忽略规则 + +- [ ] **新增本地忽略规则存储** + - 初期存 localStorage。 + - 后续迁移到 Wails 应用数据目录,便于备份和跨设备同步。 +- [ ] **规则字段** + - providerType + - namespace pattern + - group pattern + - dataId pattern + - key pattern + - reason + - createdAt +- [ ] **匹配策略** + - 精确匹配优先。 + - 支持 `*` 通配。 + - 暂不支持复杂正则,避免误伤。 +- [x] **忽略规则纯函数匹配** + - 已支持 namespace、group、dataId、key、providerType 的 `*` 通配匹配。 + - 已支持命中后把矩阵行状态标记为 `ignored` 并保留 reason。 +- [ ] **UI 行为** + - 矩阵行右键加入忽略。 + - 忽略规则管理列表。 + - 可临时显示已忽略项。 + +### 2.4 名称归一化 + +- [ ] **新增对比前名称处理规则** + - 前缀裁剪。 + - 后缀裁剪。 + - 精确替换。 + - 大小写是否敏感。 +- [ ] **应用范围** + - dataId。 + - namespace 展示名。 + - Apollo appId / cluster / namespace。 + - 本地备份文件路径。 +- [ ] **配置方式** + - 按连接组保存。 + - 在 AuditView 里可临时启用/禁用。 +- [ ] **安全约束** + - 默认不自动归一化,用户显式开启。 + - 归一化后需要展示原始名称,避免误判。 +- [x] **矩阵计算支持名称归一化回调** + - 已支持对 dataId 做归一化后再分组。 + - 已保留各环境的 originalDataIds,供 UI 展示原始名称。 + +### 2.5 重复配置检查 + +- [ ] **同环境重复检查** + - 在同一个环境内,按 `key + normalizedValue` 分组。 + - 只展示出现次数 >= 2 的项。 + - 过滤空值、明显默认值可配置。 +- [ ] **跨环境重复检查** + - 同一 key/value 是否在多个环境重复出现。 + - 用于发现“环境差异本应存在但被复制一致”的情况。 +- [ ] **输出** + - 重复 key。 + - 重复 value。 + - 出现在哪些 dataId / namespace / group。 + - 可导出 CSV。 -### 增强功能 -- [ ] 添加配置变更通知(WebSocket 实时推送) -- [ ] 实现配置版本对比可视化(图形化 diff) -- [ ] 支持配置回滚操作 -- [ ] 添加配置审计日志 -- [ ] 实现配置权限管理 +--- -### 平台支持 -- [ ] 添加 Linux ARM64 支持 -- [ ] 添加 Windows ARM64 支持 -- [ ] 添加 macOS ARM64 支持(待修复 Wails CLI 问题) +## 3. 高优先级:连接、安全与运行环境 + +这些能力直接影响生产可用性,优先级高于新增更多配置中心。 + +### 3.1 SSH 隧道 + +- [x] 已有基础 SSH 隧道能力。 +- [ ] 补齐连接管理 UI 的状态展示:已连接、本地端口、失败原因。 +- [ ] SSH 配置在连接管理中独立成“网络”分组。 +- [ ] SSH 状态在连接列表与顶部上下文栏显示。 +- [ ] 支持手动重连和断开。 +- [ ] 连接删除或修改时自动关闭旧隧道。 +- [ ] 批量审计时复用隧道,避免重复创建。 +- [ ] 补 Go 单元测试或集成测试边界:端口占用、认证失败、StopAll。 + +### 3.2 全局代理 + +- [ ] 新增全局代理设置。 + - HTTP proxy。 + - HTTPS proxy。 + - no_proxy。 +- [ ] Go HTTP client 支持读取代理配置。 +- [ ] 每个连接允许覆盖全局代理。 +- [ ] UI 标识当前连接是否走代理。 +- [ ] 代理配置放入 SettingsView 的“网络”分组。 + +### 3.3 生产/沙箱安全确认 + +- [ ] 连接增加安全等级。 + - 普通。 + - 测试。 + - 预发。 + - 生产。 + - 沙箱。 +- [ ] 高风险操作二次确认。 + - 发布配置。 + - 删除配置。 + - 回滚配置。 + - 批量操作。 +- [ ] 生产环境要求人工输入确认文本。 + - 例如输入 `确认发布到生产`。 + - 确认文案可在连接配置中设置。 +- [ ] UI 中对生产/沙箱连接做持续可见标识。 + - 顶部连接选择器显示安全等级。 + - 发布、删除、恢复按钮使用危险态样式。 + - 批量操作预览页标出将影响的生产项数量。 +- [ ] 沙箱环境策略。 + - 可配置为禁止发布。 + - 可配置为发布后不立即生效,如果目标配置中心支持该语义。 + - Nacos 不天然支持“发布但不生效”,需要在 UI 上明确限制,不假装支持。 + +### 3.4 敏感信息处理 + +- [ ] 当前连接密码存在 localStorage,需规划迁移。 +- [ ] Go 侧优先使用系统 keychain/credential store;做不到时至少应用数据目录加密存储。 +- [ ] 导出配置时默认不导出密码、token、私钥。 +- [ ] 日志中屏蔽 password、accessKey、secretKey、token。 -### 文档与社区 -- [ ] 编写详细的用户文档 -- [ ] 添加 API 文档 -- [ ] 创建贡献指南 -- [ ] 添加使用示例和教程 +--- -### 开发工具 -- [ ] 添加单元测试(Go 后端) -- [ ] 添加单元测试(React 前端) -- [ ] 实现 CI 自动化测试 -- [ ] 添加代码覆盖率检查 -- [ ] 配置 ESLint 和 Prettier +## 4. 高优先级:导入、导出、备份、恢复 + +这是用户已有备注里的重点,也能承接审计矩阵输出。 + +### 4.1 导出 + +- [ ] 单配置导出。 + - 原始内容。 + - 元信息 JSON。 +- [ ] 审计矩阵导出。 + - CSV:适合发给团队审阅。 + - JSON:适合后续导入或自动化处理。 +- [ ] 差异导出。 + - 文本 diff。 + - key/value 差异 JSON。 +- [ ] 重复配置检查结果导出。 + +### 4.2 本地备份模式 + +- [ ] 定义本地备份目录结构。 + - provider。 + - connection name。 + - namespace。 + - group。 + - dataId。 + - metadata.json。 +- [ ] 支持从云端拉取一次快照保存到本地。 +- [ ] 支持本地快照作为一个 `local` provider 参与浏览和对比。 +- [ ] 支持同环境多配置位置对比。 + - 云端 vs 本地备份。 + - 本地备份 A vs 本地备份 B。 + - 同一连接不同 namespace/group。 +- [ ] 新增 BackupView。 + - 快照列表。 + - 快照详情。 + - 云端/本地/WebDAV 来源标识。 + - 快照与当前云端的差异入口。 + +### 4.3 WebDAV 备份与恢复 + +- [ ] 新增 WebDAV 连接配置。 + - URL。 + - 用户名。 + - 密码/token。 + - 根目录。 +- [ ] 支持上传本地备份到 WebDAV。 +- [ ] 支持从 WebDAV 下载备份到本地。 +- [ ] 恢复前必须预览差异。 +- [ ] WebDAV 上传、下载、恢复进入任务中心。 +- [ ] 恢复到生产环境必须走生产确认流程。 + +### 4.4 导入/恢复策略 + +- [ ] 单配置恢复。 +- [ ] 批量恢复。 +- [ ] 只新增缺失项。 +- [ ] 覆盖已有项。 +- [ ] 跳过已存在项。 +- [ ] dry-run 模式:只生成计划,不执行发布。 --- -## ✅ 已完成 +## 5. 中优先级:Apollo 配置中心 + +当前 ConfScope 还不支持 Apollo。参考项目支持 Apollo,但它走数据库;ConfScope 应优先走 Apollo OpenAPI。 + +### 5.1 是否做 + +- [ ] **做 Apollo OpenAPI 适配**:符合 ConfScope 多配置中心定位。 +- [ ] **暂不做 Apollo 数据库直连**:除非 OpenAPI 无法满足历史或权限场景。 +- [ ] **暂不追求 Apollo 全量管理后台能力**:先支持浏览、获取、diff、审计、导出。 + +### 5.2 Apollo 概念映射 + +- [ ] Nacos `namespace` 对应 Apollo 的 `appId + cluster + namespaceName` 组合。 +- [ ] Nacos `group` 在 Apollo 中没有直接等价物,可显示为 cluster 或固定为空。 +- [ ] Nacos `dataId` 可映射为 Apollo namespaceName 或单个配置集合。 +- [ ] Apollo item 的 `key/value` 天然适合审计矩阵。 + +### 5.3 第一阶段能力 + +- [ ] Apollo 连接管理。 + - Portal/OpenAPI 地址。 + - token。 + - appId。 + - cluster。 + - namespace。 +- [ ] Apollo 连接 UI 使用 provider 专属表单。 + - 不强行展示 Nacos 的 group/dataId 字段。 + - 表单中明确 Apollo 的 appId、cluster、namespaceName。 + - 权限不足时提供手动输入 appId/cluster/namespaceName 的模式。 +- [ ] Apollo 配置浏览。 + - App 列表如果 API 权限允许则自动获取。 + - 权限不足时允许手动输入 appId。 +- [ ] Apollo 浏览 UI 使用 Apollo 概念命名。 + - App。 + - Cluster。 + - Namespace。 + - Item key/value。 +- [ ] Apollo namespace 配置项列表。 +- [ ] Apollo 配置详情。 +- [ ] Apollo 与 Nacos、本地备份参与统一 diff 和 AuditView。 +- [ ] AuditView 中统一展示通用列,同时保留 provider 原始字段。 + - 通用列:环境、namespace、group、dataId、key、value。 + - 原始字段:Apollo appId/cluster/namespaceName,Nacos tenant/group/dataId。 + +### 5.4 第二阶段能力 + +- [ ] Apollo 历史版本。 +- [ ] Apollo 发布/回滚。 +- [ ] Apollo 灰度配置支持。 +- [ ] Apollo namespace 创建/删除。 +- [ ] Apollo 配置导入导出。 + +### 5.5 风险 + +- [ ] Apollo OpenAPI 权限和部署差异较大,需要清晰错误提示。 +- [ ] Apollo 概念与 Nacos 不一致,UI 不能强行套 `group/dataId`。 +- [ ] 发布 Apollo 配置通常涉及修改 item + release,应单独设计确认流程。 + +--- -### 核心功能(v1.0.0) -- [x] Nacos v1/v3 双版本支持 -- [x] 配置浏览与搜索 -- [x] 历史版本查看 -- [x] 智能配置对比(行级 diff) -- [x] 多连接管理 -- [x] 自动认证与 Token 管理 -- [x] 深色 VSCode 风格 UI +## 6. 中优先级:Consul 与其他配置中心 -### 技术栈(v1.0.0) -- [x] Wails 2 + Go 后端 -- [x] React 18 + TypeScript 前端 -- [x] Vite 5 构建 -- [x] GitHub Actions CI/CD +### 6.1 Consul -### 平台支持(v1.0.0) -- [x] Windows (amd64) -- [x] macOS (amd64) -- [x] Linux (amd64) +- [ ] 支持 Consul KV 浏览。 +- [ ] 支持 token 认证。 +- [ ] 支持 datacenter 选择。 +- [ ] 支持 key prefix 过滤。 +- [ ] 支持 Consul KV 与 Nacos/Apollo/local 的 diff。 +- [ ] 暂不优先做 Consul session、watch、service discovery 管理。 -### 文档(v1.0.0) -- [x] README.md -- [x] CHANGELOG.md -- [x] LICENSE -- [x] CREDITS.md +### 6.2 后续候选 + +- [ ] Etcd。 +- [ ] ZooKeeper。 +- [ ] Kubernetes ConfigMap/Secret。 +- [ ] Spring Cloud Config。 + +### 6.3 取舍 + +- [ ] 只有当 provider 抽象稳定后再新增第三个配置中心。 +- [ ] 每个 provider 先满足浏览、读取、diff、审计,再考虑写入。 +- [ ] 写入能力必须接入安全确认机制。 --- -## 📋 技术债务 +## 7. 中优先级:阿里云 MSE / Nacos 企业场景 -### 代码质量 -- [ ] 移除未使用的代码 -- [ ] 统一代码风格 -- [ ] 添加 TypeScript 严格模式检查 -- [ ] 优化 import 路径 +- [ ] 支持阿里云 MSE Nacos AccessKey 模式。 +- [ ] 连接配置增加认证类型。 + - username/password。 + - accessKey/secretKey。 + - token。 +- [ ] Go 后端封装签名逻辑,前端不直接处理 secret。 +- [ ] 日志脱敏。 +- [ ] 连接测试需要区分认证失败、网络失败、权限不足。 +- [ ] 文档中说明 MSE 与自建 Nacos 的能力差异。 -### 依赖管理 -- [ ] 定期更新 Go 依赖 -- [ ] 定期更新 npm 依赖 -- [ ] 检查安全漏洞 +--- -### 构建优化 -- [ ] 优化构建产物大小 -- [ ] 添加构建缓存 -- [ ] 减少构建时间 +## 8. 中优先级:UI/UX 与大数据体验 + +### 8.0 主布局演进 + +- [ ] 从顶部双模式切换升级为左侧主导航。 +- [ ] 主导航支持折叠,只显示图标时仍有 tooltip。 +- [ ] 顶部栏改为当前工作区上下文操作区。 +- [ ] 保留当前 VSCode Dark 风格,但统一按钮、表格、状态标签、危险态样式。 +- [ ] 不使用卡片堆叠承载核心工作流,优先使用分栏、表格、工具栏和详情面板。 + +### 8.1 表格体验 + +- [ ] AuditView 使用虚拟列表或表格虚拟滚动。 +- [ ] 支持列显示/隐藏。 +- [ ] 支持列宽调整。 +- [ ] 支持复制单元格、复制整行、复制异常摘要。 +- [ ] 支持保存过滤条件。 +- [ ] 状态列使用固定视觉语言。 + - 一致:绿色。 + - 部分一致:黄色。 + - 不一致:红色。 + - 缺失:橙色或红色描边。 + - 无法解析:灰色加错误标识。 + - 已忽略:低对比度。 + +### 8.1.1 连接管理体验 + +- [ ] 连接管理从单页长表单升级为分组表单或标签页。 +- [ ] 支持 provider 类型选择。 +- [ ] 根据 provider 类型切换字段。 +- [ ] 基础信息、认证、网络、安全、高级分区。 +- [ ] 连接列表显示 provider、环境标签、安全等级、SSH/代理状态。 +- [ ] 测试连接结果展示更细:网络可达、认证成功、权限范围、版本信息。 + +### 8.2 搜索 + +- [ ] 配置列表 dataId 搜索已支持,继续优化。 +- [ ] 支持配置内容全文搜索。 +- [ ] 支持 key 搜索。 +- [ ] 支持 value 搜索。 +- [ ] 支持跨连接搜索。 + +### 8.3 Diff + +- [ ] 当前行级 diff 保留。 +- [ ] DiffView 保持深度对比定位,不继续承接审计矩阵职责。 +- [ ] key/value diff 与审计矩阵打通。 +- [ ] 支持忽略空白、忽略顺序、忽略注释。 +- [ ] 大文件 diff 增加性能保护:超过阈值提示用户选择模式。 +- [ ] 批量 diff 结果增加汇总区。 + - 总数。 + - 一致数量。 + - 有差异数量。 + - 失败数量。 + - 可快速跳到下一个差异项。 + +### 8.3.1 任务中心 + +- [ ] 新增任务中心入口。 +- [ ] 批量审计、批量导出、备份、恢复、WebDAV 上传下载统一进入任务中心。 +- [ ] 每个任务展示名称、范围、进度、状态、开始时间、耗时。 +- [ ] 失败任务支持展开错误详情和复制错误。 +- [ ] 可取消的任务显示取消按钮。 +- [ ] 任务完成后可跳转到结果页。 + +### 8.3.2 危险操作体验 + +- [ ] 统一危险操作确认弹窗。 +- [ ] 生产环境操作要求输入确认文本。 +- [ ] 批量操作先展示影响范围,再允许执行。 +- [ ] 恢复操作必须先展示 dry-run 差异计划。 +- [ ] 危险操作按钮文案必须具体,例如“发布到生产”“删除 12 个配置”,避免只写“确定”。 + +### 8.4 多语言 + +- [x] 已有多语言基础。 +- [ ] 补齐新增页面文案。 +- [ ] 检查所有硬编码中文/英文。 +- [ ] 语言切换不应重置当前工作区状态。 --- -## 🔮 未来展望 +## 9. 中优先级:日志、错误处理与可观测性 + +- [ ] 增加应用日志查看页面。 +- [ ] 日志页面接入主导航的“任务/日志”区域。 +- [ ] Go 后端统一错误类型。 + - 网络错误。 + - 认证错误。 + - 权限错误。 + - 解析错误。 + - 写入失败。 +- [ ] 批量任务展示进度。 +- [ ] 批量任务支持取消。 +- [ ] 批量任务结束后展示成功、失败、跳过数量。 +- [ ] 错误详情可复制。 +- [ ] 日志默认脱敏。 -### v2.0.0 规划 -- [ ] 支持更多配置中心(Etcd、ZooKeeper) -- [ ] 实现配置中心聚合视图(跨配置中心对比) -- [ ] 添加配置中心监控告警 -- [ ] 支持配置中心集群管理 -- [ ] 实现配置版本控制(Git 集成) +--- -### v3.0.0 规划 -- [ ] 添加 Web 版本(浏览器访问) -- [ ] 实现团队协作功能 -- [ ] 支持配置中心迁移工具 -- [ ] 添加配置中心性能分析 +## 10. 测试与工程质量 + +当前自动化测试已经补上第一层基线:Go 后端覆盖 Nacos client 与 SSH manager/tunnel,前端已引入 Vitest 并覆盖 diff、format、keys、validate、normalize、audit、highlight、toast、connection store 等纯逻辑/状态模块。下一阶段应优先补 React 组件测试与 Playwright Web E2E,否则 AuditView、BackupView、连接管理升级后仍会依赖大量人工回归。 + +### 10.1 Go 测试 + +- [x] 已有 `internal/nacos/client_test.go`。 +- [x] Nacos client 基础行为测试。 +- [x] SSH manager 测试。 +- [x] SSH tunnel 基础测试。 +- [ ] provider 抽象加单元测试。 +- [ ] Nacos v1/v3 响应兼容测试。 +- [ ] App Wails 绑定层加薄测试。 + - 参数透传。 + - 错误返回。 + - provider 分发。 +- [ ] 代理配置测试。 +- [ ] WebDAV 客户端测试。 +- [ ] Apollo client 使用 `httptest.Server` 模拟 Portal/OpenAPI。 +- [ ] 批量任务调度测试。 + - 并发限制。 + - 取消。 + - 部分失败聚合。 + - 超时。 + +### 10.2 前端测试 + +- [x] 引入 Vitest。 +- [x] 测试 `normalizeConfig`。 +- [x] 测试一致性矩阵状态计算。 +- [x] 测试忽略规则匹配。 +- [x] 测试名称归一化。 +- [x] 测试 diff 统计。 +- [x] 测试格式识别。 +- [x] 测试 key 提取。 +- [x] 测试发布前格式校验。 +- [x] 测试语法高亮兜底与逐行缓存。 +- [x] 测试 toast 发布订阅与自动移除。 +- [x] 测试连接本地存储的读取、新增、更新、删除、SSH 配置保存。 +- [ ] 测试导出数据生成。 + - CSV 转义。 + - JSON 字段完整性。 + - 敏感字段默认剔除。 +- [ ] 测试危险操作确认逻辑。 + - 普通环境。 + - 生产环境输入确认文本。 + - dry-run 未完成时禁止执行。 + +### 10.2.1 React 组件测试 + +- [ ] 引入 React Testing Library。 +- [ ] `ConfigBrowser` 测试。 + - 搜索。 + - 翻页。 + - 打开配置。 + - 编辑脏状态拦截。 +- [ ] `DiffPanel` 测试。 + - 统计显示。 + - 仅显示变更。 + - 无差异状态。 +- [ ] `ConnectionManager` 测试。 + - provider 切换字段。 + - SSH/代理/安全分组。 + - 测试连接结果展示。 +- [ ] `AuditView` 测试。 + - 过滤状态。 + - 行选择。 + - 忽略规则。 + - 跳转 diff。 + +### 10.2.2 端到端测试 + +- [ ] 引入 Playwright,先覆盖 Web 模式。 + - 只测前端路由、交互、mock API,不直接启动 Wails 桌面壳。 +- [ ] 使用 mock 的 Wails API。 + - 模拟连接列表。 + - 模拟 Nacos namespace/config/history。 + - 模拟错误和超时。 +- [ ] 覆盖核心用户流程。 + - 新增连接。 + - 浏览配置。 + - 查看历史。 + - 两配置 diff。 + - 审计矩阵过滤。 + - 导出审计结果。 +- [ ] 桌面壳冒烟测试单独评估。 + - 构建成功。 + - 应用启动。 + - 首页无白屏。 + - 不把所有业务流程都压到桌面 E2E。 + +### 10.2.3 测试数据与 Mock 策略 + +- [ ] 建立 `src/test/fixtures`。 + - Nacos v1 配置列表响应。 + - Nacos v3 配置列表响应。 + - YAML/JSON/Properties 样例。 + - Apollo app/cluster/namespace/item 样例。 +- [ ] 建立 mock API 层。 + - 前端组件测试不访问真实 Wails 绑定。 + - E2E 测试不依赖真实 Nacos/Apollo 服务。 +- [ ] 建立回归样例。 + - 空配置。 + - 解析失败。 + - 超大配置。 + - 缺失环境。 + - 值相同但顺序不同。 + - 生产环境危险操作。 + +### 10.3 代码质量 + +- [ ] 配置 ESLint。 +- [ ] 配置 Prettier。 +- [ ] TypeScript strict 模式评估并逐步开启。 +- [ ] 移除未使用代码。 +- [ ] 统一 import 路径。 + +### 10.4 CI/CD + +- [ ] CI 增加 Go test。 +- [ ] CI 增加前端 test。 +- [ ] CI 增加 Playwright Web E2E。 +- [ ] CI 增加 lint。 +- [ ] CI 增加 `npm run build:web`,确保前端可构建。 +- [ ] Windows/Linux 构建继续保持。 +- [ ] macOS 构建问题单独跟踪。 --- -## 📝 备注 +## 11. 平台与发布 + +### 11.1 当前支持 + +- [x] Windows amd64。 +- [x] Linux amd64。 +- [ ] macOS amd64 暂停。 +- [ ] macOS arm64 暂停。 + +### 11.2 macOS 问题 -### macOS ARM64 问题详情 **错误信息**: -``` + +```text dyld[1744]: missing LC_UUID load command in /Users/runner/go/bin/wails dyld[1744]: missing LC_UUID load command Abort trap: 6 ``` -**可能原因**: -1. Wails CLI v2.12.0 在 macOS ARM64 上的兼容性问题 -2. Go 交叉编译配置问题 -3. macOS 签名问题 +- [ ] 跟踪 Wails CLI v2.12.0 在 macOS GitHub Actions runner 上的问题。 +- [ ] 尝试固定 Go 版本。 +- [ ] 尝试固定或降级 Wails CLI。 +- [ ] 尝试 macOS 本机 runner。 +- [ ] 恢复 macOS 前先保证 CI 可重复。 -**解决方案尝试**: -1. ✅ 已尝试:使用 Ubuntu 22.04(解决 Linux webkit2gtk 问题) -2. ✅ 已尝试:更新 Wails 版本到 v2.12.0 -3. ⏳ 待尝试:降级 Wails 版本 -4. ⏳ 待尝试:使用不同的 Go 版本 -5. ⏳ 待尝试:调整 macOS 构建配置 +### 11.3 未来平台 -**相关 Issue**: -- https://github.com/wailsapp/wails/issues/3153 -- https://github.com/wailsapp/wails/issues/3201 +- [ ] Linux ARM64。 +- [ ] Windows ARM64。 +- [ ] macOS ARM64。 --- -**维护者**: Adsryen -**项目**: https://github.com/Adsryen/ConfScope +## 12. 分阶段里程碑 + +### v1.1:Nacos 审计增强 + +- [ ] 主布局支持左侧主导航骨架。 +- [x] 配置内容标准化。 +- [ ] AuditView 多环境一致性矩阵。 +- [ ] AuditView 顶部条件栏、状态摘要、虚拟矩阵表格、详情面板。 +- [x] 忽略规则纯函数匹配。 +- [x] 名称归一化参与矩阵分组。 +- [ ] 审计结果导出 CSV/JSON。 +- [ ] DiffView 与 AuditView 职责拆分,批量治理入口迁移到 AuditView。 +- [x] 引入 Vitest,覆盖标准化、矩阵状态、忽略规则、名称归一化。 +- [x] 前端核心算法测试第一批。 +- [ ] AuditView 接入现有 `normalizeConfig` / `buildAuditMatrix`。 +- [ ] 前端核心算法测试补齐导出、危险确认与过滤派生逻辑。 + +### v1.2:备份与安全 + +- [ ] 连接管理升级为 provider 感知的分组表单。 +- [ ] 本地备份 provider。 +- [ ] BackupView 快照列表、详情、差异入口。 +- [ ] 云端到本地快照。 +- [ ] 本地快照浏览与 diff。 +- [ ] 生产/沙箱安全确认。 +- [ ] 危险操作统一确认组件。 +- [ ] 全局代理。 +- [ ] SSH 隧道状态完善。 +- [ ] 任务中心基础能力。 +- [ ] React 组件测试覆盖连接管理、DiffPanel、BackupView 基础交互。 + +### v1.3:Apollo 第一阶段 + +- [ ] provider 抽象落地。 +- [ ] Apollo OpenAPI 连接。 +- [ ] Apollo provider 专属连接表单。 +- [ ] Apollo 浏览与读取。 +- [ ] Apollo 浏览页使用 appId、cluster、namespace、item key/value 概念。 +- [ ] Apollo 参与 diff 和 AuditView。 +- [ ] AuditView 同时展示通用模型字段与 Apollo 原始字段。 +- [ ] Apollo 审计导出。 +- [ ] Apollo Go client 使用 `httptest.Server` 覆盖连接、浏览、读取、错误处理。 + +### v1.4:WebDAV 与恢复 + +- [ ] WebDAV 备份上传/下载。 +- [ ] WebDAV 任务接入任务中心。 +- [ ] 恢复预览。 +- [ ] 恢复 dry-run 计划 UI。 +- [ ] dry-run 恢复计划。 +- [ ] 单配置恢复。 +- [ ] 批量恢复。 +- [ ] Playwright 覆盖备份、恢复预览、dry-run 禁止误执行。 + +### v2.0:多配置中心治理 + +- [ ] Consul KV。 +- [ ] 多 provider 聚合视图。 +- [ ] 跨配置中心聚合视图。 +- [ ] 重复配置检查增强。 +- [ ] 配置治理报告。 +- [ ] 治理报告页面支持摘要、异常趋势、导出。 +- [ ] Git 集成评估。 -### 更新:macOS 支持状态 -**2026-06-25**: macOS 支持暂时完全移除 -- ❌ macOS ARM64 (darwin-arm64) - Wails CLI dyld 错误 -- ❌ macOS AMD64 (darwin-amd64) - 同样的 Wails CLI 错误(exit code 134) -- **原因**: Wails CLI v2.12.0 在 macOS GitHub Actions 运行器上有兼容性问题 -- **临时方案**: 仅支持 Linux 和 Windows,macOS 待 Wails 修复后再添加 +--- +## 13. 已完成 + +- [x] Nacos v1/v3 双版本支持。 +- [x] 配置浏览与搜索。 +- [x] 历史版本查看。 +- [x] 智能配置对比:行级 diff。 +- [x] 任意两个配置来源对比。 +- [x] 批量同名 dataId 对比基础能力。 +- [x] 多连接管理。 +- [x] 自动认证与 Token 管理。 +- [x] SSH 隧道基础能力。 +- [x] React 18 + TypeScript + Vite 5。 +- [x] Wails 2 + Go 后端。 +- [x] README、CHANGELOG、LICENSE、CREDITS。 +--- +## 14. 近期建议执行顺序 +1. [x] 先实现 `normalizeConfig` 与测试。 +2. [x] 再实现矩阵状态计算与忽略规则匹配。 +3. [x] 引入 Vitest,把核心纯函数测试跑进 `npm test`。 +4. [ ] 先落左侧主导航骨架,给 AuditView、BackupView、SettingsView、任务中心预留入口。 +5. [ ] 新增 AuditView,只支持 Nacos,不引入 Apollo。 +6. [ ] AuditView 接入 `normalizeConfig` / `buildAuditMatrix`,先跑通多环境只读审计。 +7. [ ] 做审计导出。 +8. [ ] 引入 React Testing Library,覆盖 AuditView/连接管理/DiffPanel 的关键交互。 +9. [ ] 引入 Playwright Web E2E,用 mock Wails API 覆盖浏览、diff、审计主流程。 +10. [ ] 抽 provider 接口,把 Nacos 迁移进去。 +11. [ ] 连接管理升级为 provider 感知分组表单。 +12. [ ] 开始 Apollo OpenAPI 第一阶段。 +这个顺序的原因:先把参考项目最有价值的“审计矩阵”在现有 Nacos 能力上跑通,再抽象 provider。否则一开始就抽象 Apollo/Consul,容易为了未知需求过度设计。 ------------ -- 单个环境需要支持云模式,本地模式,本地就是某一次从云上拉下来的备份,需要支持同环境的多配置位置对比 -- 环境需要支持ssh隧道(进行中) -- 全局需要支持代理模式 -- 多语言的支持(进行中) -- webdav的备份与恢复,以及本地模式的备份与恢复 -- 支持阿里云mse微服务的access key的模式 -- 沙箱环境,不能修改后立刻生效,需要严肃的二次确认(特殊环境下,需要人工文字确认,所以需要环境支持标识,例如生产环境需要人工输入文字确认) \ No newline at end of file +--- + +**维护者**: Adsryen +**项目**: https://github.com/Adsryen/ConfScope diff --git a/internal/nacos/auth.go b/internal/nacos/auth.go new file mode 100644 index 0000000..0f6c18c --- /dev/null +++ b/internal/nacos/auth.go @@ -0,0 +1,75 @@ +package nacos + +import ( + "fmt" + "net/http" + "net/url" + "strings" +) + +// DetectVersion 探测 Nacos API 版本。 +// +// v3 的命名空间接口存在时,Nacos 可能返回 200、400 或 403;只有明确 404 时才认为是 v1。 +func (c *Client) DetectVersion(baseURL string) (string, error) { + resp, err := c.http.Get(base(baseURL) + "/v3/console/core/namespace/list") + if err != nil { + return "", fmt.Errorf("无法连接到服务器: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return string(apiV1), nil + } + return string(apiV3), nil +} + +// Login 登录 Nacos 并归一化 token 响应。 +// +// v1/v3 登录响应多数是裸对象,部分版本会包在 data 字段中,这里做兼容处理。 +func (c *Client) Login(baseURL, username, password, apiVersion string) (LoginResult, error) { + version := parseAPI(apiVersion) + path := "/v1/auth/login" + if version == apiV3 { + path = "/v3/auth/user/login" + } + + form := url.Values{} + form.Set("username", username) + form.Set("password", password) + req, err := http.NewRequest(http.MethodPost, base(baseURL)+path, strings.NewReader(form.Encode())) + if err != nil { + return LoginResult{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.http.Do(req) + if err != nil { + return LoginResult{}, fmt.Errorf("登录请求失败: %w", err) + } + defer resp.Body.Close() + text, err := readBody(resp) + if err != nil { + return LoginResult{}, fmt.Errorf("读取登录响应失败: %w", err) + } + if resp.StatusCode == http.StatusForbidden { + return LoginResult{}, fmt.Errorf("账号或密码错误(Nacos 返回 403)") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return LoginResult{}, fmt.Errorf("登录失败 %d: %s", resp.StatusCode, truncate(text)) + } + + v, err := decodeJSON(text) + if err != nil { + return LoginResult{}, fmt.Errorf("解析登录响应失败: %w —— %s", err, truncate(text)) + } + body := v + if _, ok := v["accessToken"]; !ok { + if data := asObject(v["data"]); data != nil { + body = data + } + } + return LoginResult{ + AccessToken: s(body, "accessToken"), + TokenTtl: i(body, "tokenTtl"), + GlobalAdmin: boolValue(body, "globalAdmin"), + }, nil +} diff --git a/internal/nacos/client.go b/internal/nacos/client.go index 972612b..1e4fb80 100644 --- a/internal/nacos/client.go +++ b/internal/nacos/client.go @@ -6,27 +6,13 @@ package nacos import ( - "bytes" "crypto/tls" - "encoding/json" - "fmt" - "io" "net/http" - "net/url" - "strconv" - "strings" "time" ) const timeout = 15 * time.Second -type apiVersion string - -const ( - apiV1 apiVersion = "v1" - apiV3 apiVersion = "v3" -) - // Client 是 Nacos HTTP 客户端。 // // Client 不保存登录态;accessToken 的缓存和刷新由前端 API 层负责,这里只根据传入 @@ -35,65 +21,6 @@ type Client struct { http *http.Client } -// LoginResult 是 Nacos 登录接口归一化后的返回值。 -type LoginResult struct { - AccessToken string `json:"accessToken"` - TokenTtl int64 `json:"tokenTtl"` - GlobalAdmin bool `json:"globalAdmin"` -} - -// Namespace 是 Nacos 命名空间的前端展示模型。 -type Namespace struct { - Namespace string `json:"namespace"` - NamespaceShowName string `json:"namespaceShowName"` - ConfigCount int64 `json:"configCount"` - Kind int64 `json:"kind"` -} - -// ConfigItem 是配置列表中的单条配置摘要。 -type ConfigItem struct { - DataId string `json:"dataId"` - Group string `json:"group"` - Content string `json:"content"` - ConfigType string `json:"configType"` -} - -// ConfigPage 是配置列表分页结果。 -type ConfigPage struct { - TotalCount int64 `json:"totalCount"` - PageNumber int64 `json:"pageNumber"` - PagesAvailable int64 `json:"pagesAvailable"` - PageItems []ConfigItem `json:"pageItems"` -} - -// HistoryItem 是配置历史列表中的单条历史摘要。 -type HistoryItem struct { - Id string `json:"id"` - DataId string `json:"dataId"` - Group string `json:"group"` - OpType string `json:"opType"` - LastModifiedTime string `json:"lastModifiedTime"` -} - -// HistoryPage 是配置历史分页结果。 -type HistoryPage struct { - TotalCount int64 `json:"totalCount"` - PageNumber int64 `json:"pageNumber"` - PagesAvailable int64 `json:"pagesAvailable"` - PageItems []HistoryItem `json:"pageItems"` -} - -// HistoryDetail 是配置历史详情。 -type HistoryDetail struct { - Id string `json:"id"` - DataId string `json:"dataId"` - Group string `json:"group"` - Content string `json:"content"` - OpType string `json:"opType"` - CreatedTime string `json:"createdTime"` - LastModifiedTime string `json:"lastModifiedTime"` -} - // NewClient 创建 Nacos HTTP 客户端。 // // 为了兼容内网自签名证书,这里保持与原 Rust 版本一致的策略:允许无效 TLS 证书。 @@ -107,553 +34,3 @@ func NewClient() *Client { }, } } - -// parseAPI 将前端传入的版本字符串规整为内部枚举,无法识别时按 v1 处理。 -func parseAPI(version string) apiVersion { - if strings.EqualFold(version, string(apiV3)) { - return apiV3 - } - return apiV1 -} - -// base 规整 Nacos base URL,避免拼接路径时出现重复斜杠。 -func base(baseURL string) string { - return strings.TrimRight(baseURL, "/") -} - -// truncate 截断过长的 Nacos 响应文本,用于错误信息展示。 -func truncate(text string) string { - text = strings.TrimSpace(text) - if len([]rune(text)) <= 300 { - return text - } - runes := []rune(text) - return string(runes[:300]) + "..." -} - -// stringValue 将 Nacos 宽松 JSON 字段转换为字符串。 -// -// Nacos 不同版本可能把 id、时间等字段返回为字符串或数字,统一转为字符串后交给前端展示。 -func stringValue(v any) string { - switch x := v.(type) { - case string: - return x - case float64: - if x == float64(int64(x)) { - return strconv.FormatInt(int64(x), 10) - } - return strconv.FormatFloat(x, 'f', -1, 64) - case bool: - return strconv.FormatBool(x) - case json.Number: - return x.String() - default: - return "" - } -} - -// s 读取对象中的字符串字段,缺失时返回空字符串。 -func s(obj map[string]any, key string) string { - if obj == nil { - return "" - } - return stringValue(obj[key]) -} - -// sAny 按顺序读取多个候选字段,返回第一个非空值。 -// -// 该函数主要用于兼容 v1/v3 字段名差异,例如 group/groupName、tenant/namespaceId。 -func sAny(obj map[string]any, keys ...string) string { - for _, key := range keys { - if val := s(obj, key); val != "" { - return val - } - } - return "" -} - -// i 读取对象中的整数字段,缺失或解析失败时返回 0。 -func i(obj map[string]any, key string) int64 { - if obj == nil { - return 0 - } - switch x := obj[key].(type) { - case float64: - return int64(x) - case json.Number: - n, _ := x.Int64() - return n - case string: - n, _ := strconv.ParseInt(x, 10, 64) - return n - default: - return 0 - } -} - -// boolValue 读取对象中的布尔字段,缺失或类型不匹配时返回 false。 -func boolValue(obj map[string]any, key string) bool { - if obj == nil { - return false - } - v, _ := obj[key].(bool) - return v -} - -// asObject 将任意 JSON 值转换为对象,类型不匹配时返回 nil。 -func asObject(v any) map[string]any { - obj, _ := v.(map[string]any) - return obj -} - -// asArray 将任意 JSON 值转换为数组,类型不匹配时返回 nil。 -func asArray(v any) []any { - arr, _ := v.([]any) - return arr -} - -// decodeJSON 将响应文本解析为 JSON 对象,并保留数字精度。 -func decodeJSON(text string) (map[string]any, error) { - var v map[string]any - dec := json.NewDecoder(strings.NewReader(text)) - dec.UseNumber() - if err := dec.Decode(&v); err != nil { - return nil, err - } - return v, nil -} - -// DetectVersion 探测 Nacos API 版本。 -// -// v3 的命名空间接口存在时,Nacos 可能返回 200、400 或 403;只有明确 404 时才认为是 v1。 -func (c *Client) DetectVersion(baseURL string) (string, error) { - resp, err := c.http.Get(base(baseURL) + "/v3/console/core/namespace/list") - if err != nil { - return "", fmt.Errorf("无法连接到服务器: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return string(apiV1), nil - } - return string(apiV3), nil -} - -// Login 登录 Nacos 并归一化 token 响应。 -// -// v1/v3 登录响应多数是裸对象,部分版本会包在 data 字段中,这里做兼容处理。 -func (c *Client) Login(baseURL, username, password, apiVersion string) (LoginResult, error) { - version := parseAPI(apiVersion) - path := "/v1/auth/login" - if version == apiV3 { - path = "/v3/auth/user/login" - } - - form := url.Values{} - form.Set("username", username) - form.Set("password", password) - req, err := http.NewRequest(http.MethodPost, base(baseURL)+path, strings.NewReader(form.Encode())) - if err != nil { - return LoginResult{}, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := c.http.Do(req) - if err != nil { - return LoginResult{}, fmt.Errorf("登录请求失败: %w", err) - } - defer resp.Body.Close() - text, err := readBody(resp) - if err != nil { - return LoginResult{}, fmt.Errorf("读取登录响应失败: %w", err) - } - if resp.StatusCode == http.StatusForbidden { - return LoginResult{}, fmt.Errorf("账号或密码错误(Nacos 返回 403)") - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return LoginResult{}, fmt.Errorf("登录失败 %d: %s", resp.StatusCode, truncate(text)) - } - - v, err := decodeJSON(text) - if err != nil { - return LoginResult{}, fmt.Errorf("解析登录响应失败: %w —— %s", err, truncate(text)) - } - body := v - if _, ok := v["accessToken"]; !ok { - if data := asObject(v["data"]); data != nil { - body = data - } - } - return LoginResult{ - AccessToken: s(body, "accessToken"), - TokenTtl: i(body, "tokenTtl"), - GlobalAdmin: boolValue(body, "globalAdmin"), - }, nil -} - -// Namespaces 查询命名空间列表并归一化字段名。 -func (c *Client) Namespaces(baseURL, accessToken, apiVersion string) ([]Namespace, error) { - version := parseAPI(apiVersion) - path := "/v1/console/namespaces" - if version == apiV3 { - path = "/v3/console/core/namespace/list" - } - data, err := c.getJSON(baseURL, path, url.Values{}, accessToken, version) - if err != nil { - return nil, err - } - - items := asArray(data) - if items == nil { - items = asArray(asObject(data)["data"]) - } - if items == nil { - return nil, fmt.Errorf("命名空间响应缺少 data 数组") - } - - namespaces := make([]Namespace, 0, len(items)) - for _, item := range items { - n := asObject(item) - namespaces = append(namespaces, Namespace{ - Namespace: sAny(n, "namespace", "namespaceId"), - NamespaceShowName: s(n, "namespaceShowName"), - ConfigCount: i(n, "configCount"), - Kind: i(n, "type"), - }) - } - return namespaces, nil -} - -// ListConfigs 按条件分页查询配置列表。 -// -// v1 使用 /v1/cs/configs,v3 使用 /v3/console/cs/config/list;返回值统一为 ConfigPage。 -func (c *Client) ListConfigs(baseURL, accessToken, apiVersion, namespace, dataID, group string, pageNo, pageSize int64) (ConfigPage, error) { - version := parseAPI(apiVersion) - query := url.Values{} - query.Set("search", "blur") - query.Set("dataId", dataID) - query.Set("pageNo", strconv.FormatInt(pageNo, 10)) - query.Set("pageSize", strconv.FormatInt(pageSize, 10)) - - path := "/v1/cs/configs" - if version == apiV3 { - path = "/v3/console/cs/config/list" - query.Set("groupName", group) - query.Set("namespaceId", namespace) - } else { - query.Set("group", group) - query.Set("tenant", namespace) - } - - data, err := c.getJSON(baseURL, path, query, accessToken, version) - if err != nil { - return ConfigPage{}, err - } - page := ConfigPage{ - TotalCount: i(asObject(data), "totalCount"), - PageNumber: i(asObject(data), "pageNumber"), - PagesAvailable: i(asObject(data), "pagesAvailable"), - PageItems: []ConfigItem{}, - } - for _, item := range asArray(asObject(data)["pageItems"]) { - c := asObject(item) - page.PageItems = append(page.PageItems, ConfigItem{ - DataId: s(c, "dataId"), - Group: sAny(c, "group", "groupName"), - Content: s(c, "content"), - ConfigType: s(c, "type"), - }) - } - return page, nil -} - -// GetConfig 获取指定配置内容。 -// -// v1 直接返回纯文本内容,v3 返回 JSON 信封并把内容放在 data.content。 -func (c *Client) GetConfig(baseURL, accessToken, apiVersion, namespace, dataID, group string) (string, error) { - version := parseAPI(apiVersion) - query := url.Values{} - query.Set("dataId", dataID) - if version == apiV3 { - query.Set("groupName", group) - query.Set("namespaceId", namespace) - data, err := c.getJSON(baseURL, "/v3/console/cs/config", query, accessToken, version) - if err != nil { - return "", err - } - return s(asObject(data), "content"), nil - } - query.Set("group", group) - query.Set("tenant", namespace) - return c.getText(baseURL, "/v1/cs/configs", query, accessToken, version) -} - -// HistoryList 分页查询指定配置的历史版本。 -func (c *Client) HistoryList(baseURL, accessToken, apiVersion, namespace, dataID, group string, pageNo, pageSize int64) (HistoryPage, error) { - version := parseAPI(apiVersion) - query := url.Values{} - query.Set("dataId", dataID) - query.Set("pageNo", strconv.FormatInt(pageNo, 10)) - query.Set("pageSize", strconv.FormatInt(pageSize, 10)) - - path := "/v1/cs/history" - if version == apiV3 { - path = "/v3/console/cs/history/list" - query.Set("groupName", group) - query.Set("namespaceId", namespace) - } else { - query.Set("search", "accurate") - query.Set("group", group) - query.Set("tenant", namespace) - } - - data, err := c.getJSON(baseURL, path, query, accessToken, version) - if err != nil { - return HistoryPage{}, err - } - page := HistoryPage{ - TotalCount: i(asObject(data), "totalCount"), - PageNumber: i(asObject(data), "pageNumber"), - PagesAvailable: i(asObject(data), "pagesAvailable"), - PageItems: []HistoryItem{}, - } - for _, item := range asArray(asObject(data)["pageItems"]) { - h := asObject(item) - page.PageItems = append(page.PageItems, HistoryItem{ - Id: s(h, "id"), - DataId: s(h, "dataId"), - Group: sAny(h, "group", "groupName"), - OpType: s(h, "opType"), - LastModifiedTime: sAny(h, "lastModifiedTime", "modifyTime"), - }) - } - return page, nil -} - -// HistoryDetail 获取指定历史版本详情。 -func (c *Client) HistoryDetail(baseURL, accessToken, apiVersion, namespace, dataID, group, nid string) (HistoryDetail, error) { - version := parseAPI(apiVersion) - query := url.Values{} - query.Set("dataId", dataID) - query.Set("nid", nid) - - path := "/v1/cs/history" - if version == apiV3 { - path = "/v3/console/cs/history" - query.Set("groupName", group) - query.Set("namespaceId", namespace) - } else { - query.Set("group", group) - query.Set("tenant", namespace) - } - - data, err := c.getJSON(baseURL, path, query, accessToken, version) - if err != nil { - return HistoryDetail{}, err - } - h := asObject(data) - return HistoryDetail{ - Id: s(h, "id"), - DataId: s(h, "dataId"), - Group: sAny(h, "group", "groupName"), - Content: s(h, "content"), - OpType: s(h, "opType"), - CreatedTime: sAny(h, "createdTime", "createTime"), - LastModifiedTime: sAny(h, "lastModifiedTime", "modifyTime"), - }, nil -} - -// PublishConfig 发布或更新配置。 -// -// v1 成功时返回文本 true,v3 成功时返回 {code:0,data:true}。 -func (c *Client) PublishConfig(baseURL, accessToken, apiVersion, namespace, dataID, group, content, configType string) error { - version := parseAPI(apiVersion) - form := url.Values{} - form.Set("dataId", dataID) - form.Set("content", content) - form.Set("type", configType) - - path := "/v1/cs/configs" - if version == apiV3 { - path = "/v3/console/cs/config" - form.Set("groupName", group) - form.Set("namespaceId", namespace) - } else { - form.Set("group", group) - form.Set("tenant", namespace) - } - - text, err := c.sendForm(http.MethodPost, baseURL, path, url.Values{}, form, accessToken, version) - if err != nil { - return err - } - if version == apiV1 { - if strings.TrimSpace(text) != "true" { - return fmt.Errorf("发布失败: %s", truncate(text)) - } - return nil - } - v, err := decodeJSON(text) - if err != nil { - return fmt.Errorf("解析响应失败: %w —— %s", err, truncate(text)) - } - if i(v, "code") != 0 || v["data"] != true { - return fmt.Errorf("发布失败: %s", s(v, "message")) - } - return nil -} - -// DeleteConfig 删除指定配置。 -// -// v1 成功时返回文本 true,v3 成功时返回 code 为 0 的 JSON 信封。 -func (c *Client) DeleteConfig(baseURL, accessToken, apiVersion, namespace, dataID, group string) error { - version := parseAPI(apiVersion) - query := url.Values{} - query.Set("dataId", dataID) - - path := "/v1/cs/configs" - if version == apiV3 { - path = "/v3/console/cs/config" - query.Set("groupName", group) - query.Set("namespaceId", namespace) - } else { - query.Set("group", group) - query.Set("tenant", namespace) - } - - text, err := c.sendForm(http.MethodDelete, baseURL, path, query, nil, accessToken, version) - if err != nil { - return err - } - if version == apiV1 { - if strings.TrimSpace(text) != "true" { - return fmt.Errorf("删除失败: %s", truncate(text)) - } - return nil - } - v, err := decodeJSON(text) - if err != nil { - return fmt.Errorf("解析响应失败: %w —— %s", err, truncate(text)) - } - if i(v, "code") != 0 { - return fmt.Errorf("删除失败: %s", s(v, "message")) - } - return nil -} - -// getText 发起 GET 请求并返回响应文本。 -// -// accessToken 的传递方式由 API 版本决定:v1 放 query,v3 放 header。 -func (c *Client) getText(baseURL, path string, query url.Values, accessToken string, version apiVersion) (string, error) { - if version == apiV1 && accessToken != "" { - query.Set("accessToken", accessToken) - } - reqURL := base(baseURL) + path - if encoded := query.Encode(); encoded != "" { - reqURL += "?" + encoded - } - req, err := http.NewRequest(http.MethodGet, reqURL, nil) - if err != nil { - return "", err - } - if version == apiV3 && accessToken != "" { - req.Header.Set("accessToken", accessToken) - } - resp, err := c.http.Do(req) - if err != nil { - return "", fmt.Errorf("请求失败: %w", err) - } - defer resp.Body.Close() - text, err := readBody(resp) - if err != nil { - return "", fmt.Errorf("读取响应失败: %w", err) - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("Nacos 返回 %d: %s", resp.StatusCode, truncate(text)) - } - return text, nil -} - -// getJSON 发起 GET 请求并解析 JSON。 -// -// 对 v3 响应会自动解开 {code,message,data} 信封,并在 code 非 0 时返回业务错误。 -func (c *Client) getJSON(baseURL, path string, query url.Values, accessToken string, version apiVersion) (any, error) { - text, err := c.getText(baseURL, path, query, accessToken, version) - if err != nil { - return nil, err - } - dec := json.NewDecoder(strings.NewReader(text)) - dec.UseNumber() - var v any - if err := dec.Decode(&v); err != nil { - return nil, fmt.Errorf("解析响应 JSON 失败: %w —— %s", err, truncate(text)) - } - if version == apiV3 { - obj := asObject(v) - if obj != nil { - if _, ok := obj["code"]; ok { - if i(obj, "code") != 0 { - return nil, fmt.Errorf("Nacos 返回 code=%d: %s", i(obj, "code"), s(obj, "message")) - } - return obj["data"], nil - } - } - } - return v, nil -} - -// sendForm 发起带表单或 query 参数的变更请求。 -// -// 当前用于发布和删除配置;不同 HTTP 方法的网络错误会转换为更贴近用户操作的中文提示。 -func (c *Client) sendForm(method, baseURL, path string, query url.Values, form url.Values, accessToken string, version apiVersion) (string, error) { - if version == apiV1 && accessToken != "" { - query.Set("accessToken", accessToken) - } - reqURL := base(baseURL) + path - if encoded := query.Encode(); encoded != "" { - reqURL += "?" + encoded - } - - var body io.Reader - if form != nil { - body = bytes.NewBufferString(form.Encode()) - } - req, err := http.NewRequest(method, reqURL, body) - if err != nil { - return "", err - } - if form != nil { - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } - if version == apiV3 && accessToken != "" { - req.Header.Set("accessToken", accessToken) - } - resp, err := c.http.Do(req) - if err != nil { - switch method { - case http.MethodPost: - return "", fmt.Errorf("发布请求失败: %w", err) - case http.MethodDelete: - return "", fmt.Errorf("删除请求失败: %w", err) - default: - return "", fmt.Errorf("请求失败: %w", err) - } - } - defer resp.Body.Close() - text, err := readBody(resp) - if err != nil { - return "", fmt.Errorf("读取响应失败: %w", err) - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("Nacos 返回 %d: %s", resp.StatusCode, truncate(text)) - } - return text, nil -} - -// readBody 读取响应体并转换为字符串。 -func readBody(resp *http.Response) (string, error) { - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - return string(body), nil -} diff --git a/internal/nacos/client_test.go b/internal/nacos/client_test.go index 50a792b..47fffa4 100644 --- a/internal/nacos/client_test.go +++ b/internal/nacos/client_test.go @@ -1,9 +1,10 @@ package nacos import ( + "net" "net/http" "net/http/httptest" - "net" + "strings" "testing" ) @@ -75,6 +76,45 @@ func TestLoginParsesWrappedV3Response(t *testing.T) { } } +func TestLoginParsesBareV1Response(t *testing.T) { + server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/auth/login" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if r.Form.Get("username") != "nacos" || r.Form.Get("password") != "secret" { + t.Fatalf("unexpected form: %v", r.Form) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"accessToken":"v1-token","tokenTtl":18000,"globalAdmin":false}`)) + })) + + result, err := NewClient().Login(server.URL, "nacos", "secret", "v1") + if err != nil { + t.Fatalf("Login returned error: %v", err) + } + if result.AccessToken != "v1-token" || result.TokenTtl != 18000 || result.GlobalAdmin { + t.Fatalf("unexpected login result: %+v", result) + } +} + +func TestLoginReturnsForbiddenAsCredentialError(t *testing.T) { + server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`forbidden`)) + })) + + _, err := NewClient().Login(server.URL, "nacos", "bad", "v1") + if err == nil { + t.Fatal("Login returned nil error") + } + if !strings.Contains(err.Error(), "账号或密码错误") { + t.Fatalf("error = %q, want credential hint", err.Error()) + } +} + func TestListConfigsMapsV1Response(t *testing.T) { server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/cs/configs" { @@ -101,6 +141,60 @@ func TestListConfigsMapsV1Response(t *testing.T) { } } +func TestNamespacesMapsV3Envelope(t *testing.T) { + server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/console/core/namespace/list" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("accessToken") != "token" { + t.Fatalf("missing accessToken header") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"data":[{"namespaceId":"ns-a","namespaceShowName":"Namespace A","configCount":7,"type":2}]}`)) + })) + + namespaces, err := NewClient().Namespaces(server.URL, "token", "v3") + if err != nil { + t.Fatalf("Namespaces returned error: %v", err) + } + if len(namespaces) != 1 { + t.Fatalf("len(namespaces) = %d, want 1", len(namespaces)) + } + got := namespaces[0] + if got.Namespace != "ns-a" || got.NamespaceShowName != "Namespace A" || got.ConfigCount != 7 || got.Kind != 2 { + t.Fatalf("unexpected namespace: %+v", got) + } +} + +func TestListConfigsMapsV3Response(t *testing.T) { + server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/console/cs/config/list" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("accessToken") != "token" { + t.Fatalf("missing accessToken header") + } + q := r.URL.Query() + if q.Get("namespaceId") != "ns" || q.Get("groupName") != "GROUP" || q.Get("dataId") != "app" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"data":{"totalCount":1,"pageNumber":2,"pagesAvailable":3,"pageItems":[{"dataId":"app.yaml","groupName":"GROUP","content":"a: 1","type":"yaml"}]}}`)) + })) + + page, err := NewClient().ListConfigs(server.URL, "token", "v3", "ns", "app", "GROUP", 2, 50) + if err != nil { + t.Fatalf("ListConfigs returned error: %v", err) + } + if page.TotalCount != 1 || page.PageNumber != 2 || page.PagesAvailable != 3 || len(page.PageItems) != 1 { + t.Fatalf("unexpected page: %+v", page) + } + item := page.PageItems[0] + if item.DataId != "app.yaml" || item.Group != "GROUP" || item.Content != "a: 1" || item.ConfigType != "yaml" { + t.Fatalf("unexpected item: %+v", item) + } +} + func TestGetConfigUsesV3EnvelopeAndTokenHeader(t *testing.T) { server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v3/console/cs/config" { @@ -125,3 +219,162 @@ func TestGetConfigUsesV3EnvelopeAndTokenHeader(t *testing.T) { t.Fatalf("content = %q", content) } } + +func TestGetConfigUsesV1TextResponseAndTokenQuery(t *testing.T) { + server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/cs/configs" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + q := r.URL.Query() + if q.Get("tenant") != "public" || q.Get("group") != "DEFAULT_GROUP" || q.Get("dataId") != "app.yaml" || q.Get("accessToken") != "token" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte("plain: true")) + })) + + content, err := NewClient().GetConfig(server.URL, "token", "v1", "public", "app.yaml", "DEFAULT_GROUP") + if err != nil { + t.Fatalf("GetConfig returned error: %v", err) + } + if content != "plain: true" { + t.Fatalf("content = %q", content) + } +} + +func TestHistoryListMapsV3Response(t *testing.T) { + server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/console/cs/history/list" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("accessToken") != "token" { + t.Fatalf("missing accessToken header") + } + q := r.URL.Query() + if q.Get("namespaceId") != "ns" || q.Get("groupName") != "GROUP" || q.Get("dataId") != "app.yaml" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"data":{"totalCount":1,"pageNumber":1,"pagesAvailable":1,"pageItems":[{"id":123,"dataId":"app.yaml","groupName":"GROUP","opType":"U","modifyTime":1710000000000}]}}`)) + })) + + page, err := NewClient().HistoryList(server.URL, "token", "v3", "ns", "app.yaml", "GROUP", 1, 20) + if err != nil { + t.Fatalf("HistoryList returned error: %v", err) + } + if page.TotalCount != 1 || len(page.PageItems) != 1 { + t.Fatalf("unexpected page: %+v", page) + } + item := page.PageItems[0] + if item.Id != "123" || item.DataId != "app.yaml" || item.Group != "GROUP" || item.OpType != "U" || item.LastModifiedTime != "1710000000000" { + t.Fatalf("unexpected history item: %+v", item) + } +} + +func TestHistoryDetailMapsV1Response(t *testing.T) { + server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/cs/history" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + q := r.URL.Query() + if q.Get("tenant") != "public" || q.Get("group") != "DEFAULT_GROUP" || q.Get("dataId") != "app.yaml" || q.Get("nid") != "42" || q.Get("accessToken") != "token" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"42","dataId":"app.yaml","group":"DEFAULT_GROUP","content":"a: 1","opType":"I","createdTime":"2024-01-01","lastModifiedTime":"2024-01-02"}`)) + })) + + detail, err := NewClient().HistoryDetail(server.URL, "token", "v1", "public", "app.yaml", "DEFAULT_GROUP", "42") + if err != nil { + t.Fatalf("HistoryDetail returned error: %v", err) + } + if detail.Id != "42" || detail.Content != "a: 1" || detail.CreatedTime != "2024-01-01" || detail.LastModifiedTime != "2024-01-02" { + t.Fatalf("unexpected detail: %+v", detail) + } +} + +func TestPublishConfigUsesV1FormAndRequiresTrueResponse(t *testing.T) { + server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/v1/cs/configs" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("accessToken") != "token" { + t.Fatalf("missing accessToken query") + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if r.Form.Get("tenant") != "public" || r.Form.Get("group") != "DEFAULT_GROUP" || r.Form.Get("dataId") != "app.yaml" || r.Form.Get("content") != "a: 1" || r.Form.Get("type") != "yaml" { + t.Fatalf("unexpected form: %v", r.Form) + } + _, _ = w.Write([]byte("true")) + })) + + err := NewClient().PublishConfig(server.URL, "token", "v1", "public", "app.yaml", "DEFAULT_GROUP", "a: 1", "yaml") + if err != nil { + t.Fatalf("PublishConfig returned error: %v", err) + } +} + +func TestPublishConfigReturnsV3BusinessError(t *testing.T) { + server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/console/cs/config" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("accessToken") != "token" { + t.Fatalf("missing accessToken header") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":100,"message":"denied","data":false}`)) + })) + + err := NewClient().PublishConfig(server.URL, "token", "v3", "ns", "app.yaml", "GROUP", "a: 1", "yaml") + if err == nil { + t.Fatal("PublishConfig returned nil error") + } + if !strings.Contains(err.Error(), "denied") { + t.Fatalf("error = %q, want denied", err.Error()) + } +} + +func TestDeleteConfigUsesV3QueryAndTokenHeader(t *testing.T) { + server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Fatalf("method = %s, want DELETE", r.Method) + } + if r.URL.Path != "/v3/console/cs/config" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("accessToken") != "token" { + t.Fatalf("missing accessToken header") + } + q := r.URL.Query() + if q.Get("namespaceId") != "ns" || q.Get("groupName") != "GROUP" || q.Get("dataId") != "app.yaml" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"message":"success","data":true}`)) + })) + + err := NewClient().DeleteConfig(server.URL, "token", "v3", "ns", "app.yaml", "GROUP") + if err != nil { + t.Fatalf("DeleteConfig returned error: %v", err) + } +} + +func TestV3EnvelopeBusinessErrorIsReturned(t *testing.T) { + server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":403,"message":"no permission","data":null}`)) + })) + + _, err := NewClient().Namespaces(server.URL, "token", "v3") + if err == nil { + t.Fatal("Namespaces returned nil error") + } + if !strings.Contains(err.Error(), "code=403") || !strings.Contains(err.Error(), "no permission") { + t.Fatalf("error = %q, want v3 business error", err.Error()) + } +} diff --git a/internal/nacos/config.go b/internal/nacos/config.go new file mode 100644 index 0000000..27decf9 --- /dev/null +++ b/internal/nacos/config.go @@ -0,0 +1,151 @@ +package nacos + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) + +// ListConfigs 按条件分页查询配置列表。 +// +// v1 使用 /v1/cs/configs,v3 使用 /v3/console/cs/config/list;返回值统一为 ConfigPage。 +func (c *Client) ListConfigs(baseURL, accessToken, apiVersion, namespace, dataID, group string, pageNo, pageSize int64) (ConfigPage, error) { + version := parseAPI(apiVersion) + query := url.Values{} + query.Set("search", "blur") + query.Set("dataId", dataID) + query.Set("pageNo", strconv.FormatInt(pageNo, 10)) + query.Set("pageSize", strconv.FormatInt(pageSize, 10)) + + path := "/v1/cs/configs" + if version == apiV3 { + path = "/v3/console/cs/config/list" + query.Set("groupName", group) + query.Set("namespaceId", namespace) + } else { + query.Set("group", group) + query.Set("tenant", namespace) + } + + data, err := c.getJSON(baseURL, path, query, accessToken, version) + if err != nil { + return ConfigPage{}, err + } + page := ConfigPage{ + TotalCount: i(asObject(data), "totalCount"), + PageNumber: i(asObject(data), "pageNumber"), + PagesAvailable: i(asObject(data), "pagesAvailable"), + PageItems: []ConfigItem{}, + } + for _, item := range asArray(asObject(data)["pageItems"]) { + c := asObject(item) + page.PageItems = append(page.PageItems, ConfigItem{ + DataId: s(c, "dataId"), + Group: sAny(c, "group", "groupName"), + Content: s(c, "content"), + ConfigType: s(c, "type"), + }) + } + return page, nil +} + +// GetConfig 获取指定配置内容。 +// +// v1 直接返回纯文本内容,v3 返回 JSON 信封并把内容放在 data.content。 +func (c *Client) GetConfig(baseURL, accessToken, apiVersion, namespace, dataID, group string) (string, error) { + version := parseAPI(apiVersion) + query := url.Values{} + query.Set("dataId", dataID) + if version == apiV3 { + query.Set("groupName", group) + query.Set("namespaceId", namespace) + data, err := c.getJSON(baseURL, "/v3/console/cs/config", query, accessToken, version) + if err != nil { + return "", err + } + return s(asObject(data), "content"), nil + } + query.Set("group", group) + query.Set("tenant", namespace) + return c.getText(baseURL, "/v1/cs/configs", query, accessToken, version) +} + +// PublishConfig 发布或更新配置。 +// +// v1 成功时返回文本 true,v3 成功时返回 {code:0,data:true}。 +func (c *Client) PublishConfig(baseURL, accessToken, apiVersion, namespace, dataID, group, content, configType string) error { + version := parseAPI(apiVersion) + form := url.Values{} + form.Set("dataId", dataID) + form.Set("content", content) + form.Set("type", configType) + + path := "/v1/cs/configs" + if version == apiV3 { + path = "/v3/console/cs/config" + form.Set("groupName", group) + form.Set("namespaceId", namespace) + } else { + form.Set("group", group) + form.Set("tenant", namespace) + } + + text, err := c.sendForm(http.MethodPost, baseURL, path, url.Values{}, form, accessToken, version) + if err != nil { + return err + } + if version == apiV1 { + if strings.TrimSpace(text) != "true" { + return fmt.Errorf("发布失败: %s", truncate(text)) + } + return nil + } + v, err := decodeJSON(text) + if err != nil { + return fmt.Errorf("解析响应失败: %w —— %s", err, truncate(text)) + } + if i(v, "code") != 0 || v["data"] != true { + return fmt.Errorf("发布失败: %s", s(v, "message")) + } + return nil +} + +// DeleteConfig 删除指定配置。 +// +// v1 成功时返回文本 true,v3 成功时返回 code 为 0 的 JSON 信封。 +func (c *Client) DeleteConfig(baseURL, accessToken, apiVersion, namespace, dataID, group string) error { + version := parseAPI(apiVersion) + query := url.Values{} + query.Set("dataId", dataID) + + path := "/v1/cs/configs" + if version == apiV3 { + path = "/v3/console/cs/config" + query.Set("groupName", group) + query.Set("namespaceId", namespace) + } else { + query.Set("group", group) + query.Set("tenant", namespace) + } + + text, err := c.sendForm(http.MethodDelete, baseURL, path, query, nil, accessToken, version) + if err != nil { + return err + } + if version == apiV1 { + if strings.TrimSpace(text) != "true" { + return fmt.Errorf("删除失败: %s", truncate(text)) + } + return nil + } + v, err := decodeJSON(text) + if err != nil { + return fmt.Errorf("解析响应失败: %w —— %s", err, truncate(text)) + } + if i(v, "code") != 0 { + return fmt.Errorf("删除失败: %s", s(v, "message")) + } + return nil +} diff --git a/internal/nacos/decode.go b/internal/nacos/decode.go new file mode 100644 index 0000000..3c978fe --- /dev/null +++ b/internal/nacos/decode.go @@ -0,0 +1,109 @@ +package nacos + +import ( + "encoding/json" + "strconv" + "strings" +) + +// truncate 截断过长的 Nacos 响应文本,用于错误信息展示。 +func truncate(text string) string { + text = strings.TrimSpace(text) + if len([]rune(text)) <= 300 { + return text + } + runes := []rune(text) + return string(runes[:300]) + "..." +} + +// stringValue 将 Nacos 宽松 JSON 字段转换为字符串。 +// +// Nacos 不同版本可能把 id、时间等字段返回为字符串或数字,统一转为字符串后交给前端展示。 +func stringValue(v any) string { + switch x := v.(type) { + case string: + return x + case float64: + if x == float64(int64(x)) { + return strconv.FormatInt(int64(x), 10) + } + return strconv.FormatFloat(x, 'f', -1, 64) + case bool: + return strconv.FormatBool(x) + case json.Number: + return x.String() + default: + return "" + } +} + +// s 读取对象中的字符串字段,缺失时返回空字符串。 +func s(obj map[string]any, key string) string { + if obj == nil { + return "" + } + return stringValue(obj[key]) +} + +// sAny 按顺序读取多个候选字段,返回第一个非空值。 +// +// 该函数主要用于兼容 v1/v3 字段名差异,例如 group/groupName、tenant/namespaceId。 +func sAny(obj map[string]any, keys ...string) string { + for _, key := range keys { + if val := s(obj, key); val != "" { + return val + } + } + return "" +} + +// i 读取对象中的整数字段,缺失或解析失败时返回 0。 +func i(obj map[string]any, key string) int64 { + if obj == nil { + return 0 + } + switch x := obj[key].(type) { + case float64: + return int64(x) + case json.Number: + n, _ := x.Int64() + return n + case string: + n, _ := strconv.ParseInt(x, 10, 64) + return n + default: + return 0 + } +} + +// boolValue 读取对象中的布尔字段,缺失或类型不匹配时返回 false。 +func boolValue(obj map[string]any, key string) bool { + if obj == nil { + return false + } + v, _ := obj[key].(bool) + return v +} + +// asObject 将任意 JSON 值转换为对象,类型不匹配时返回 nil。 +func asObject(v any) map[string]any { + obj, _ := v.(map[string]any) + return obj +} + +// asArray 将任意 JSON 值转换为数组,类型不匹配时返回 nil。 +func asArray(v any) []any { + arr, _ := v.([]any) + return arr +} + +// decodeJSON 将响应文本解析为 JSON 对象,并保留数字精度。 +func decodeJSON(text string) (map[string]any, error) { + var v map[string]any + dec := json.NewDecoder(strings.NewReader(text)) + dec.UseNumber() + if err := dec.Decode(&v); err != nil { + return nil, err + } + return v, nil +} diff --git a/internal/nacos/history.go b/internal/nacos/history.go new file mode 100644 index 0000000..458aae5 --- /dev/null +++ b/internal/nacos/history.go @@ -0,0 +1,81 @@ +package nacos + +import ( + "net/url" + "strconv" +) + +// HistoryList 分页查询指定配置的历史版本。 +func (c *Client) HistoryList(baseURL, accessToken, apiVersion, namespace, dataID, group string, pageNo, pageSize int64) (HistoryPage, error) { + version := parseAPI(apiVersion) + query := url.Values{} + query.Set("dataId", dataID) + query.Set("pageNo", strconv.FormatInt(pageNo, 10)) + query.Set("pageSize", strconv.FormatInt(pageSize, 10)) + + path := "/v1/cs/history" + if version == apiV3 { + path = "/v3/console/cs/history/list" + query.Set("groupName", group) + query.Set("namespaceId", namespace) + } else { + query.Set("search", "accurate") + query.Set("group", group) + query.Set("tenant", namespace) + } + + data, err := c.getJSON(baseURL, path, query, accessToken, version) + if err != nil { + return HistoryPage{}, err + } + page := HistoryPage{ + TotalCount: i(asObject(data), "totalCount"), + PageNumber: i(asObject(data), "pageNumber"), + PagesAvailable: i(asObject(data), "pagesAvailable"), + PageItems: []HistoryItem{}, + } + for _, item := range asArray(asObject(data)["pageItems"]) { + h := asObject(item) + page.PageItems = append(page.PageItems, HistoryItem{ + Id: s(h, "id"), + DataId: s(h, "dataId"), + Group: sAny(h, "group", "groupName"), + OpType: s(h, "opType"), + LastModifiedTime: sAny(h, "lastModifiedTime", "modifyTime"), + }) + } + return page, nil +} + +// HistoryDetail 获取指定历史版本详情。 +func (c *Client) HistoryDetail(baseURL, accessToken, apiVersion, namespace, dataID, group, nid string) (HistoryDetail, error) { + version := parseAPI(apiVersion) + query := url.Values{} + query.Set("dataId", dataID) + query.Set("nid", nid) + + path := "/v1/cs/history" + if version == apiV3 { + path = "/v3/console/cs/history" + query.Set("groupName", group) + query.Set("namespaceId", namespace) + } else { + query.Set("group", group) + query.Set("tenant", namespace) + } + + data, err := c.getJSON(baseURL, path, query, accessToken, version) + if err != nil { + return HistoryDetail{}, err + } + h := asObject(data) + return HistoryDetail{ + Id: s(h, "id"), + DataId: s(h, "dataId"), + Group: sAny(h, "group", "groupName"), + Content: s(h, "content"), + OpType: s(h, "opType"), + CreatedTime: sAny(h, "createdTime", "createTime"), + LastModifiedTime: sAny(h, "lastModifiedTime", "modifyTime"), + }, nil +} diff --git a/internal/nacos/namespace.go b/internal/nacos/namespace.go new file mode 100644 index 0000000..91f27ce --- /dev/null +++ b/internal/nacos/namespace.go @@ -0,0 +1,39 @@ +package nacos + +import ( + "fmt" + "net/url" +) + +// Namespaces 查询命名空间列表并归一化字段名。 +func (c *Client) Namespaces(baseURL, accessToken, apiVersion string) ([]Namespace, error) { + version := parseAPI(apiVersion) + path := "/v1/console/namespaces" + if version == apiV3 { + path = "/v3/console/core/namespace/list" + } + data, err := c.getJSON(baseURL, path, url.Values{}, accessToken, version) + if err != nil { + return nil, err + } + + items := asArray(data) + if items == nil { + items = asArray(asObject(data)["data"]) + } + if items == nil { + return nil, fmt.Errorf("命名空间响应缺少 data 数组") + } + + namespaces := make([]Namespace, 0, len(items)) + for _, item := range items { + n := asObject(item) + namespaces = append(namespaces, Namespace{ + Namespace: sAny(n, "namespace", "namespaceId"), + NamespaceShowName: s(n, "namespaceShowName"), + ConfigCount: i(n, "configCount"), + Kind: i(n, "type"), + }) + } + return namespaces, nil +} diff --git a/internal/nacos/request.go b/internal/nacos/request.go new file mode 100644 index 0000000..07a4b8b --- /dev/null +++ b/internal/nacos/request.go @@ -0,0 +1,134 @@ +package nacos + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// base 规整 Nacos base URL,避免拼接路径时出现重复斜杠。 +func base(baseURL string) string { + return strings.TrimRight(baseURL, "/") +} + +// getText 发起 GET 请求并返回响应文本。 +// +// accessToken 的传递方式由 API 版本决定:v1 放 query,v3 放 header。 +func (c *Client) getText(baseURL, path string, query url.Values, accessToken string, version apiVersion) (string, error) { + if version == apiV1 && accessToken != "" { + query.Set("accessToken", accessToken) + } + reqURL := base(baseURL) + path + if encoded := query.Encode(); encoded != "" { + reqURL += "?" + encoded + } + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return "", err + } + if version == apiV3 && accessToken != "" { + req.Header.Set("accessToken", accessToken) + } + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("请求失败: %w", err) + } + defer resp.Body.Close() + text, err := readBody(resp) + if err != nil { + return "", fmt.Errorf("读取响应失败: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("Nacos 返回 %d: %s", resp.StatusCode, truncate(text)) + } + return text, nil +} + +// getJSON 发起 GET 请求并解析 JSON。 +// +// 对 v3 响应会自动解开 {code,message,data} 信封,并在 code 非 0 时返回业务错误。 +func (c *Client) getJSON(baseURL, path string, query url.Values, accessToken string, version apiVersion) (any, error) { + text, err := c.getText(baseURL, path, query, accessToken, version) + if err != nil { + return nil, err + } + dec := json.NewDecoder(strings.NewReader(text)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, fmt.Errorf("解析响应 JSON 失败: %w —— %s", err, truncate(text)) + } + if version == apiV3 { + obj := asObject(v) + if obj != nil { + if _, ok := obj["code"]; ok { + if i(obj, "code") != 0 { + return nil, fmt.Errorf("Nacos 返回 code=%d: %s", i(obj, "code"), s(obj, "message")) + } + return obj["data"], nil + } + } + } + return v, nil +} + +// sendForm 发起带表单或 query 参数的变更请求。 +// +// 当前用于发布和删除配置;不同 HTTP 方法的网络错误会转换为更贴近用户操作的中文提示。 +func (c *Client) sendForm(method, baseURL, path string, query url.Values, form url.Values, accessToken string, version apiVersion) (string, error) { + if version == apiV1 && accessToken != "" { + query.Set("accessToken", accessToken) + } + reqURL := base(baseURL) + path + if encoded := query.Encode(); encoded != "" { + reqURL += "?" + encoded + } + + var body io.Reader + if form != nil { + body = bytes.NewBufferString(form.Encode()) + } + req, err := http.NewRequest(method, reqURL, body) + if err != nil { + return "", err + } + if form != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + if version == apiV3 && accessToken != "" { + req.Header.Set("accessToken", accessToken) + } + resp, err := c.http.Do(req) + if err != nil { + switch method { + case http.MethodPost: + return "", fmt.Errorf("发布请求失败: %w", err) + case http.MethodDelete: + return "", fmt.Errorf("删除请求失败: %w", err) + default: + return "", fmt.Errorf("请求失败: %w", err) + } + } + defer resp.Body.Close() + text, err := readBody(resp) + if err != nil { + return "", fmt.Errorf("读取响应失败: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("Nacos 返回 %d: %s", resp.StatusCode, truncate(text)) + } + return text, nil +} + +// readBody 读取响应体并转换为字符串。 +func readBody(resp *http.Response) (string, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(body), nil +} diff --git a/internal/nacos/types.go b/internal/nacos/types.go new file mode 100644 index 0000000..bb6b5ca --- /dev/null +++ b/internal/nacos/types.go @@ -0,0 +1,60 @@ +package nacos + +// LoginResult 是 Nacos 登录接口归一化后的返回值。 +type LoginResult struct { + AccessToken string `json:"accessToken"` + TokenTtl int64 `json:"tokenTtl"` + GlobalAdmin bool `json:"globalAdmin"` +} + +// Namespace 是 Nacos 命名空间的前端展示模型。 +type Namespace struct { + Namespace string `json:"namespace"` + NamespaceShowName string `json:"namespaceShowName"` + ConfigCount int64 `json:"configCount"` + Kind int64 `json:"kind"` +} + +// ConfigItem 是配置列表中的单条配置摘要。 +type ConfigItem struct { + DataId string `json:"dataId"` + Group string `json:"group"` + Content string `json:"content"` + ConfigType string `json:"configType"` +} + +// ConfigPage 是配置列表分页结果。 +type ConfigPage struct { + TotalCount int64 `json:"totalCount"` + PageNumber int64 `json:"pageNumber"` + PagesAvailable int64 `json:"pagesAvailable"` + PageItems []ConfigItem `json:"pageItems"` +} + +// HistoryItem 是配置历史列表中的单条历史摘要。 +type HistoryItem struct { + Id string `json:"id"` + DataId string `json:"dataId"` + Group string `json:"group"` + OpType string `json:"opType"` + LastModifiedTime string `json:"lastModifiedTime"` +} + +// HistoryPage 是配置历史分页结果。 +type HistoryPage struct { + TotalCount int64 `json:"totalCount"` + PageNumber int64 `json:"pageNumber"` + PagesAvailable int64 `json:"pagesAvailable"` + PageItems []HistoryItem `json:"pageItems"` +} + +// HistoryDetail 是配置历史详情。 +type HistoryDetail struct { + Id string `json:"id"` + DataId string `json:"dataId"` + Group string `json:"group"` + Content string `json:"content"` + OpType string `json:"opType"` + CreatedTime string `json:"createdTime"` + LastModifiedTime string `json:"lastModifiedTime"` +} diff --git a/internal/nacos/version.go b/internal/nacos/version.go new file mode 100644 index 0000000..b0b9d2d --- /dev/null +++ b/internal/nacos/version.go @@ -0,0 +1,18 @@ +package nacos + +import "strings" + +type apiVersion string + +const ( + apiV1 apiVersion = "v1" + apiV3 apiVersion = "v3" +) + +// parseAPI 将前端传入的版本字符串规整为内部枚举,无法识别时按 v1 处理。 +func parseAPI(version string) apiVersion { + if strings.EqualFold(version, string(apiV3)) { + return apiV3 + } + return apiV1 +} diff --git a/internal/ssh/manager_test.go b/internal/ssh/manager_test.go new file mode 100644 index 0000000..89a5e19 --- /dev/null +++ b/internal/ssh/manager_test.go @@ -0,0 +1,59 @@ +package ssh + +import ( + "strings" + "testing" +) + +func TestManagerReturnsErrorForMissingTunnelPort(t *testing.T) { + manager := NewManager() + + _, err := manager.GetLocalPort("missing") + if err == nil { + t.Fatal("GetLocalPort returned nil error") + } + if !strings.Contains(err.Error(), "no tunnel found") { + t.Fatalf("error = %q, want missing tunnel error", err.Error()) + } +} + +func TestManagerGetTunnelReturnsNilForMissingTunnel(t *testing.T) { + manager := NewManager() + + if got := manager.GetTunnel("missing"); got != nil { + t.Fatalf("GetTunnel returned %#v, want nil", got) + } +} + +func TestManagerStopTunnelIsIdempotent(t *testing.T) { + manager := NewManager() + + manager.StopTunnel("missing") + manager.StopTunnel("missing") +} + +func TestManagerStopAllIsIdempotent(t *testing.T) { + manager := NewManager() + + manager.StopAll() + manager.StopAll() +} + +func TestManagerDoesNotStoreTunnelWhenStartFails(t *testing.T) { + manager := NewManager() + + _, err := manager.CreateTunnel("conn", Config{ + Host: "127.0.0.1", + Port: 1, + Username: "root", + AuthType: "unsupported", + RemoteHost: "localhost", + RemotePort: 8848, + }) + if err == nil { + t.Fatal("CreateTunnel returned nil error") + } + if got := manager.GetTunnel("conn"); got != nil { + t.Fatalf("GetTunnel returned %#v after failed start, want nil", got) + } +} diff --git a/internal/ssh/tunnel_test.go b/internal/ssh/tunnel_test.go new file mode 100644 index 0000000..583030c --- /dev/null +++ b/internal/ssh/tunnel_test.go @@ -0,0 +1,101 @@ +package ssh + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "strings" + "testing" +) + +func testPrivateKey(t *testing.T) string { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + block := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + } + return string(pem.EncodeToMemory(block)) +} + +func TestCreateSSHConfigUsesPasswordAuth(t *testing.T) { + tunnel := NewTunnel(Config{ + Username: "root", + AuthType: "password", + Password: "secret", + }) + + config, err := tunnel.createSSHConfig() + if err != nil { + t.Fatalf("createSSHConfig returned error: %v", err) + } + if config.User != "root" { + t.Fatalf("User = %q, want root", config.User) + } + if len(config.Auth) != 1 { + t.Fatalf("len(Auth) = %d, want 1", len(config.Auth)) + } + if config.HostKeyCallback == nil { + t.Fatal("HostKeyCallback is nil") + } +} + +func TestCreateSSHConfigRejectsUnsupportedAuthType(t *testing.T) { + tunnel := NewTunnel(Config{ + Username: "root", + AuthType: "agent", + }) + + _, err := tunnel.createSSHConfig() + if err == nil { + t.Fatal("createSSHConfig returned nil error") + } + if !strings.Contains(err.Error(), "unsupported auth type") { + t.Fatalf("error = %q, want unsupported auth type", err.Error()) + } +} + +func TestParsePrivateKeyWithoutPassphrase(t *testing.T) { + tunnel := NewTunnel(Config{ + PrivateKey: testPrivateKey(t), + }) + + signer, err := tunnel.parsePrivateKey() + if err != nil { + t.Fatalf("parsePrivateKey returned error: %v", err) + } + if signer == nil { + t.Fatal("signer is nil") + } +} + +func TestParsePrivateKeyReturnsErrorForInvalidKey(t *testing.T) { + tunnel := NewTunnel(Config{ + PrivateKey: "not a private key", + }) + + _, err := tunnel.parsePrivateKey() + if err == nil { + t.Fatal("parsePrivateKey returned nil error") + } +} + +func TestStoppedTunnelCannotStart(t *testing.T) { + tunnel := NewTunnel(Config{ + AuthType: "password", + }) + tunnel.Stop() + + _, err := tunnel.Start() + if err == nil { + t.Fatal("Start returned nil error") + } + if !strings.Contains(err.Error(), "tunnel is closed") { + t.Fatalf("error = %q, want tunnel is closed", err.Error()) + } +} diff --git a/package.json b/package.json index 83a537e..50ee1ac 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "scripts": { "dev": "wails dev", "dev:web": "vite", + "test": "vitest run", + "test:watch": "vitest", "build": "wails build", "dist": "wails build", "build:win": "wails build -clean -nsis", @@ -26,6 +28,7 @@ "@vitejs/plugin-react": "^4", "esbuild": "^0.28.0", "typescript": "^5", - "vite": "^5" + "vite": "^5", + "vitest": "^2.1.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5841dac..2f7d439 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: vite: specifier: ^5 version: 5.4.21 + vitest: + specifier: ^2.1.9 + version: 2.1.9 packages: @@ -608,6 +611,39 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + baseline-browser-mapping@2.10.40: resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} engines: {node: '>=6.0.0'} @@ -618,9 +654,21 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -636,9 +684,16 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + electron-to-chromium@1.5.379: resolution: {integrity: sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -653,6 +708,13 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -683,9 +745,15 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -698,6 +766,13 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -730,10 +805,37 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -745,6 +847,11 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -776,6 +883,36 @@ packages: terser: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1187,6 +1324,48 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21)': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21 + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + assertion-error@2.0.1: {} + baseline-browser-mapping@2.10.40: {} browserslist@4.28.4: @@ -1197,8 +1376,20 @@ snapshots: node-releases: 2.0.50 update-browserslist-db: 1.2.3(browserslist@4.28.4) + cac@6.7.14: {} + caniuse-lite@1.0.30001799: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + convert-source-map@2.0.0: {} csstype@3.2.3: {} @@ -1207,8 +1398,12 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + electron-to-chromium@1.5.379: {} + es-module-lexer@1.7.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1266,6 +1461,12 @@ snapshots: escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fsevents@2.3.3: optional: true @@ -1283,16 +1484,26 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + ms@2.1.3: {} nanoid@3.3.15: {} node-releases@2.0.50: {} + pathe@1.1.2: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} postcss@8.5.15: @@ -1350,8 +1561,24 @@ snapshots: semver@6.3.1: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + typescript@5.9.3: {} update-browserslist-db@1.2.3(browserslist@4.28.4): @@ -1360,6 +1587,24 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + vite-node@2.1.9: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite@5.4.21: dependencies: esbuild: 0.21.5 @@ -1368,6 +1613,44 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + vitest@2.1.9: + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21 + vite-node: 2.1.9 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + yallist@3.1.1: {} yaml@2.9.0: {} diff --git a/src/lib/audit.test.ts b/src/lib/audit.test.ts new file mode 100644 index 0000000..43072b4 --- /dev/null +++ b/src/lib/audit.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { buildAuditMatrix, type AuditSource } from "./audit"; + +function source(env: string, entries: Record): AuditSource { + return { + envId: env, + label: env.toUpperCase(), + providerType: "nacos", + namespace: "public", + group: "DEFAULT_GROUP", + dataId: "app.yaml", + entries: Object.entries(entries).map(([key, value]) => ({ + key, + value, + valueType: "string", + sourcePath: key, + parseStatus: "ok", + })), + }; +} + +describe("buildAuditMatrix", () => { + it("marks rows consistent when all environments have the same value", () => { + const rows = buildAuditMatrix([source("dev", { "server.port": "8080" }), source("prod", { "server.port": "8080" })]); + + expect(rows).toEqual([ + expect.objectContaining({ + id: "public|DEFAULT_GROUP|app.yaml|server.port", + key: "server.port", + status: "consistent", + values: { + dev: expect.objectContaining({ value: "8080", exists: true }), + prod: expect.objectContaining({ value: "8080", exists: true }), + }, + }), + ]); + }); + + it("marks rows inconsistent when every environment exists but values differ", () => { + const rows = buildAuditMatrix([source("dev", { "server.port": "8080" }), source("prod", { "server.port": "9090" })]); + + expect(rows[0].status).toBe("inconsistent"); + }); + + it("marks rows partial when at least two values match and another differs", () => { + const rows = buildAuditMatrix([ + source("dev", { "server.port": "8080" }), + source("test", { "server.port": "8080" }), + source("prod", { "server.port": "9090" }), + ]); + + expect(rows[0].status).toBe("partial"); + }); + + it("marks rows missing when any selected environment lacks the key", () => { + const rows = buildAuditMatrix([source("dev", { "server.port": "8080" }), source("prod", {})]); + + expect(rows[0].status).toBe("missing"); + expect(rows[0].values.prod).toEqual({ exists: false }); + }); + + it("marks rows parse_error when a source entry failed to parse", () => { + const rows = buildAuditMatrix([ + { + ...source("dev", {}), + entries: [ + { + key: "__document", + value: "{", + valueType: "text", + sourcePath: "__document", + parseStatus: "error", + parseError: "JSON parse failed", + }, + ], + }, + source("prod", { "__document": "{}" }), + ]); + + expect(rows[0].status).toBe("parse_error"); + }); + + it("marks ignored rows when an ignore rule matches", () => { + const rows = buildAuditMatrix( + [source("dev", { "server.port": "8080" }), source("prod", { "server.port": "9090" })], + { + ignoreRules: [ + { + namespace: "public", + group: "DEFAULT_GROUP", + dataId: "app.*", + key: "server.*", + reason: "environment specific", + }, + ], + } + ); + + expect(rows[0].status).toBe("ignored"); + expect(rows[0].ignoreReason).toBe("environment specific"); + }); + + it("normalizes dataId before grouping rows", () => { + const rows = buildAuditMatrix( + [ + { ...source("dev", { "server.port": "8080" }), dataId: "dev-app.yaml" }, + { ...source("prod", { "server.port": "8080" }), dataId: "prod-app.yaml" }, + ], + { + normalizeName: (name) => name.replace(/^(dev|prod)-/, ""), + } + ); + + expect(rows).toHaveLength(1); + expect(rows[0].dataId).toBe("app.yaml"); + expect(rows[0].originalDataIds).toEqual({ dev: "dev-app.yaml", prod: "prod-app.yaml" }); + }); +}); diff --git a/src/lib/audit.ts b/src/lib/audit.ts new file mode 100644 index 0000000..5628de3 --- /dev/null +++ b/src/lib/audit.ts @@ -0,0 +1,156 @@ +import type { ConfigEntry, ParseStatus } from "./normalize"; + +export type AuditStatus = "consistent" | "partial" | "inconsistent" | "missing" | "parse_error" | "ignored"; + +export interface AuditSource { + envId: string; + label: string; + providerType: string; + namespace: string; + group: string; + dataId: string; + entries: ConfigEntry[]; + updatedAt?: string; +} + +export interface IgnoreRule { + providerType?: string; + namespace?: string; + group?: string; + dataId?: string; + key?: string; + reason: string; +} + +export interface AuditCell { + exists: boolean; + value?: string; + valueType?: ConfigEntry["valueType"]; + parseStatus?: ParseStatus; + parseError?: string; + updatedAt?: string; +} + +export interface AuditRow { + id: string; + providerType: string; + namespace: string; + group: string; + dataId: string; + key: string; + status: AuditStatus; + values: Record; + originalDataIds: Record; + ignoreReason?: string; +} + +export interface BuildAuditMatrixOptions { + ignoreRules?: IgnoreRule[]; + normalizeName?: (name: string) => string; +} + +interface RowBucket { + providerType: string; + namespace: string; + group: string; + dataId: string; + key: string; + values: Record; + originalDataIds: Record; +} + +function rowId(namespace: string, group: string, dataId: string, key: string): string { + return [namespace, group, dataId, key].join("|"); +} + +function wildcardMatch(pattern: string | undefined, value: string): boolean { + if (!pattern) return true; + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`).test(value); +} + +function matchingIgnoreRule(row: RowBucket, rules: IgnoreRule[]): IgnoreRule | undefined { + return rules.find( + (rule) => + wildcardMatch(rule.providerType, row.providerType) && + wildcardMatch(rule.namespace, row.namespace) && + wildcardMatch(rule.group, row.group) && + wildcardMatch(rule.dataId, row.dataId) && + wildcardMatch(rule.key, row.key) + ); +} + +function statusOf(row: RowBucket, envIds: string[], ignoreRule?: IgnoreRule): AuditStatus { + if (ignoreRule) return "ignored"; + + const cells = envIds.map((envId) => row.values[envId] ?? { exists: false }); + if (cells.some((cell) => cell.exists && cell.parseStatus === "error")) return "parse_error"; + if (cells.some((cell) => !cell.exists)) return "missing"; + + const counts = new Map(); + for (const cell of cells) { + counts.set(cell.value ?? "", (counts.get(cell.value ?? "") ?? 0) + 1); + } + if (counts.size <= 1) return "consistent"; + if (Array.from(counts.values()).some((count) => count > 1)) return "partial"; + return "inconsistent"; +} + +export function buildAuditMatrix( + sources: AuditSource[], + options: BuildAuditMatrixOptions = {} +): AuditRow[] { + const normalizeName = options.normalizeName ?? ((name: string) => name); + const envIds = Array.from(new Set(sources.map((source) => source.envId))); + const buckets = new Map(); + + for (const source of sources) { + const dataId = normalizeName(source.dataId); + for (const entry of source.entries) { + const id = rowId(source.namespace, source.group, dataId, entry.key); + const bucket = + buckets.get(id) ?? + ({ + providerType: source.providerType, + namespace: source.namespace, + group: source.group, + dataId, + key: entry.key, + values: {}, + originalDataIds: {}, + } satisfies RowBucket); + + bucket.values[source.envId] = { + exists: true, + value: entry.value, + valueType: entry.valueType, + parseStatus: entry.parseStatus, + parseError: entry.parseError, + updatedAt: source.updatedAt, + }; + bucket.originalDataIds[source.envId] = source.dataId; + buckets.set(id, bucket); + } + } + + return Array.from(buckets.values()) + .map((bucket) => { + const values: Record = {}; + for (const envId of envIds) values[envId] = bucket.values[envId] ?? { exists: false }; + + const ignoreRule = matchingIgnoreRule(bucket, options.ignoreRules ?? []); + return { + id: rowId(bucket.namespace, bucket.group, bucket.dataId, bucket.key), + providerType: bucket.providerType, + namespace: bucket.namespace, + group: bucket.group, + dataId: bucket.dataId, + key: bucket.key, + status: statusOf({ ...bucket, values }, envIds, ignoreRule), + values, + originalDataIds: bucket.originalDataIds, + ...(ignoreRule ? { ignoreReason: ignoreRule.reason } : {}), + }; + }) + .sort((a, b) => a.id.localeCompare(b.id)); +} diff --git a/src/lib/diff.test.ts b/src/lib/diff.test.ts new file mode 100644 index 0000000..18b70ee --- /dev/null +++ b/src/lib/diff.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { diffLines } from "./diff"; + +describe("diffLines", () => { + it("returns no rows for two empty documents", () => { + const result = diffLines("", ""); + + expect(result).toEqual({ + rows: [], + added: 0, + removed: 0, + modified: 0, + }); + }); + + it("keeps equal lines aligned with line numbers", () => { + const result = diffLines("a\nb", "a\nb"); + + expect(result.added).toBe(0); + expect(result.removed).toBe(0); + expect(result.modified).toBe(0); + expect(result.rows).toEqual([ + { type: "equal", leftNo: 1, rightNo: 1, left: "a", right: "a" }, + { type: "equal", leftNo: 2, rightNo: 2, left: "b", right: "b" }, + ]); + }); + + it("normalizes CRLF and CR line endings", () => { + const result = diffLines("a\r\nb\rc", "a\nb\nc"); + + expect(result.added).toBe(0); + expect(result.removed).toBe(0); + expect(result.modified).toBe(0); + expect(result.rows).toHaveLength(3); + }); + + it("pairs adjacent deleted and added lines as modifications", () => { + const result = diffLines("server.port=8080", "server.port=9090"); + + expect(result.added).toBe(0); + expect(result.removed).toBe(0); + expect(result.modified).toBe(1); + expect(result.rows).toEqual([ + { + type: "modify", + leftNo: 1, + rightNo: 1, + left: "server.port=8080", + right: "server.port=9090", + }, + ]); + }); + + it("tracks inserted and removed lines around shared context", () => { + const result = diffLines("a\nb\nc", "a\nx\nc\nd"); + + expect(result.added).toBe(1); + expect(result.removed).toBe(0); + expect(result.modified).toBe(1); + expect(result.rows).toEqual([ + { type: "equal", leftNo: 1, rightNo: 1, left: "a", right: "a" }, + { type: "modify", leftNo: 2, rightNo: 2, left: "b", right: "x" }, + { type: "equal", leftNo: 3, rightNo: 3, left: "c", right: "c" }, + { type: "add", leftNo: null, rightNo: 4, left: null, right: "d" }, + ]); + }); +}); diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts new file mode 100644 index 0000000..87b13c8 --- /dev/null +++ b/src/lib/format.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { detectFormat, nacosType } from "./format"; + +describe("detectFormat", () => { + it("prefers explicit Nacos type over file extension and content", () => { + expect(detectFormat("app.json", "yaml", '{"a":1}')).toBe("YAML"); + }); + + it("detects format from dataId extension", () => { + expect(detectFormat("app.yml", "", "")).toBe("YAML"); + expect(detectFormat("app.properties", "", "")).toBe("Properties"); + expect(detectFormat("app.toml", "", "")).toBe("TOML"); + }); + + it("detects JSON, HTML, and XML from content", () => { + expect(detectFormat("config", "", '{"server":{"port":8080}}')).toBe("JSON"); + expect(detectFormat("config", "", "")).toBe("HTML"); + expect(detectFormat("config", "", "")).toBe("XML"); + }); + + it("uses line heuristics for properties and YAML content", () => { + expect(detectFormat("config", "", "server.port=8080\nserver.host=localhost")).toBe("Properties"); + expect(detectFormat("config", "", "server:\n port: 8080\n host: localhost")).toBe("YAML"); + }); + + it("falls back to TEXT for empty or unknown content", () => { + expect(detectFormat("config", "", "")).toBe("TEXT"); + expect(detectFormat("config", "", "just some text")).toBe("TEXT"); + }); +}); + +describe("nacosType", () => { + it("maps internal format names to Nacos type values", () => { + expect(nacosType("Properties")).toBe("properties"); + expect(nacosType("YAML")).toBe("yaml"); + expect(nacosType("TEXT")).toBe("text"); + }); +}); diff --git a/src/lib/highlight.test.ts b/src/lib/highlight.test.ts new file mode 100644 index 0000000..980320d --- /dev/null +++ b/src/lib/highlight.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { highlightCode, highlightLine } from "./highlight"; + +describe("highlightCode", () => { + it("escapes HTML for plain text", () => { + expect(highlightCode("", "TEXT")).toBe("<script>&</script>"); + }); + + it("highlights known config formats", () => { + expect(highlightCode('{"server":8080}', "JSON")).toContain("hljs-"); + }); +}); + +describe("highlightLine", () => { + it("returns an empty string for empty lines", () => { + expect(highlightLine("", "JSON")).toBe(""); + }); + + it("returns stable cached output for the same line and format", () => { + const first = highlightLine("server.port=8080", "Properties"); + const second = highlightLine("server.port=8080", "Properties"); + + expect(second).toBe(first); + }); +}); diff --git a/src/lib/highlight.ts b/src/lib/highlight.ts index 978db739c6ae8ee0b1745ed7c7170c2ab94bda62..8a97d6d4d25c096362d8baedecbe03661d78d680 100644 GIT binary patch delta 18 YcmeAdoFcekJ3CuUsR0m7-obty06OmmbN~PV delta 13 UcmbOt*e|$YJ3Ax8 { + it("extracts sorted unique JSON object paths without array indexes", () => { + const keys = extractKeys( + JSON.stringify({ + server: { port: 8080 }, + spring: { profiles: ["prod"], datasource: { url: "jdbc" } }, + }), + "JSON" + ); + + expect(keys).toEqual([ + "server", + "server.port", + "spring", + "spring.datasource", + "spring.datasource.url", + "spring.profiles", + ]); + }); + + it("falls back to line parsing when JSON is invalid", () => { + expect(extractKeys("server:\n port: 8080", "JSON")).toEqual(["server", "server.port"]); + }); + + it("extracts YAML-like nested keys by indentation", () => { + expect( + extractKeys( + [ + "# ignored", + "server:", + " port: 8080", + "spring:", + " application:", + " name: demo", + ].join("\n"), + "YAML" + ) + ).toEqual(["server", "server.port", "spring", "spring.application", "spring.application.name"]); + }); + + it("extracts properties keys and removes duplicates", () => { + expect( + extractKeys( + ["server.port=8080", "server.host=localhost", "server.port=9090"].join("\n"), + "Properties" + ) + ).toEqual(["server.host", "server.port"]); + }); +}); + +describe("keysDoc", () => { + it("joins extracted keys with newlines for diff input", () => { + expect(keysDoc("b=1\na=2", "Properties")).toBe("a\nb"); + }); +}); diff --git a/src/lib/normalize.test.ts b/src/lib/normalize.test.ts new file mode 100644 index 0000000..c1d1fe5 --- /dev/null +++ b/src/lib/normalize.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { normalizeConfig } from "./normalize"; + +describe("normalizeConfig", () => { + it("flattens nested JSON objects into sorted config entries", () => { + const result = normalizeConfig( + JSON.stringify({ + server: { port: 8080, host: "localhost" }, + features: { enabled: true }, + optional: null, + tags: ["blue", "green"], + }), + "JSON" + ); + + expect(result.parseStatus).toBe("ok"); + expect(result.entries).toEqual([ + { + key: "features.enabled", + value: "true", + valueType: "boolean", + sourcePath: "features.enabled", + parseStatus: "ok", + }, + { + key: "optional", + value: "null", + valueType: "null", + sourcePath: "optional", + parseStatus: "ok", + }, + { + key: "server.host", + value: "localhost", + valueType: "string", + sourcePath: "server.host", + parseStatus: "ok", + }, + { + key: "server.port", + value: "8080", + valueType: "number", + sourcePath: "server.port", + parseStatus: "ok", + }, + { + key: "tags", + value: '["blue","green"]', + valueType: "array", + sourcePath: "tags", + parseStatus: "ok", + }, + ]); + }); + + it("flattens nested YAML objects into config entries", () => { + const result = normalizeConfig( + ["server:", " port: 8080", " host: localhost", "features:", " enabled: true"].join("\n"), + "YAML" + ); + + expect(result.parseStatus).toBe("ok"); + expect(result.entries.map((entry) => [entry.key, entry.value, entry.valueType])).toEqual([ + ["features.enabled", "true", "boolean"], + ["server.host", "localhost", "string"], + ["server.port", "8080", "number"], + ]); + }); + + it("keeps root arrays as a document-level entry", () => { + const result = normalizeConfig('["dev","prod"]', "JSON"); + + expect(result).toEqual({ + parseStatus: "ok", + entries: [ + { + key: "__document", + value: '["dev","prod"]', + valueType: "array", + sourcePath: "__document", + parseStatus: "ok", + }, + ], + }); + }); + + it("parses properties while ignoring comments and blank lines", () => { + const result = normalizeConfig( + ["# app config", "", "server.port=8080", "server.host: localhost", "empty.value="].join("\n"), + "Properties" + ); + + expect(result.parseStatus).toBe("ok"); + expect(result.entries.map((entry) => [entry.key, entry.value, entry.valueType])).toEqual([ + ["empty.value", "", "empty"], + ["server.host", "localhost", "string"], + ["server.port", "8080", "number"], + ]); + }); + + it("deduplicates properties by keeping the last value", () => { + const result = normalizeConfig("server.port=8080\nserver.port=9090", "Properties"); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0]).toMatchObject({ + key: "server.port", + value: "9090", + valueType: "number", + }); + }); + + it("returns a document-level fallback entry when parsing fails", () => { + const result = normalizeConfig('{"server":', "JSON"); + + expect(result.parseStatus).toBe("error"); + expect(result.parseError).toContain("JSON"); + expect(result.entries).toEqual([ + { + key: "__document", + value: '{"server":', + valueType: "text", + sourcePath: "__document", + parseStatus: "error", + parseError: result.parseError, + }, + ]); + }); + + it("keeps text-like formats as a document-level entry", () => { + const result = normalizeConfig("plain text", "TEXT"); + + expect(result).toEqual({ + parseStatus: "fallback", + entries: [ + { + key: "__document", + value: "plain text", + valueType: "text", + sourcePath: "__document", + parseStatus: "fallback", + }, + ], + }); + }); +}); diff --git a/src/lib/normalize.ts b/src/lib/normalize.ts new file mode 100644 index 0000000..b846525 --- /dev/null +++ b/src/lib/normalize.ts @@ -0,0 +1,160 @@ +import { parseDocument } from "yaml"; +import type { Format } from "./format"; + +export type ParseStatus = "ok" | "fallback" | "error"; +export type ConfigValueType = "string" | "number" | "boolean" | "array" | "object" | "null" | "empty" | "text"; + +export interface ConfigEntry { + key: string; + value: string; + valueType: ConfigValueType; + sourcePath: string; + parseStatus: ParseStatus; + parseError?: string; +} + +export interface NormalizeResult { + parseStatus: ParseStatus; + parseError?: string; + entries: ConfigEntry[]; +} + +const DOCUMENT_KEY = "__document"; + +function valueTypeOf(value: unknown): ConfigValueType { + if (value === "") return "empty"; + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + switch (typeof value) { + case "number": + return "number"; + case "boolean": + return "boolean"; + case "object": + return "object"; + case "string": + if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value.trim())) return "number"; + if (/^(true|false)$/i.test(value.trim())) return "boolean"; + return value.length ? "string" : "empty"; + default: + return "text"; + } +} + +function stringifyValue(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value); +} + +function documentEntry(content: string, parseStatus: ParseStatus, parseError?: string): ConfigEntry { + return { + key: DOCUMENT_KEY, + value: content, + valueType: "text", + sourcePath: DOCUMENT_KEY, + parseStatus, + ...(parseError ? { parseError } : {}), + }; +} + +function makeEntry(key: string, value: unknown, parseStatus: ParseStatus): ConfigEntry { + return { + key, + value: stringifyValue(value), + valueType: valueTypeOf(value), + sourcePath: key, + parseStatus, + }; +} + +function flattenValue(value: unknown, prefix = ""): ConfigEntry[] { + if ( + value && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value as Record).length > 0 + ) { + return Object.entries(value as Record).flatMap(([key, child]) => { + const path = prefix ? `${prefix}.${key}` : key; + return flattenValue(child, path); + }); + } + if (!prefix) return []; + return [makeEntry(prefix, value, "ok")]; +} + +function normalizeObject(value: unknown): ConfigEntry[] { + const entries = flattenValue(value).sort((a, b) => a.key.localeCompare(b.key)); + if (entries.length > 0) return entries; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return [makeEntry(DOCUMENT_KEY, value, "ok")]; + } + return entries; +} + +function normalizeJson(content: string): NormalizeResult { + try { + return { + parseStatus: "ok", + entries: normalizeObject(JSON.parse(content)), + }; + } catch (error) { + const parseError = `JSON:${(error as Error).message}`; + return { parseStatus: "error", parseError, entries: [documentEntry(content, "error", parseError)] }; + } +} + +function normalizeYaml(content: string): NormalizeResult { + const doc = parseDocument(content, { uniqueKeys: true }); + if (doc.errors.length) { + const parseError = `YAML:${doc.errors[0].message.split("\n")[0]}`; + return { parseStatus: "error", parseError, entries: [documentEntry(content, "error", parseError)] }; + } + return { + parseStatus: "ok", + entries: normalizeObject(doc.toJSON()), + }; +} + +function normalizeProperties(content: string): NormalizeResult { + const entries = new Map(); + for (const raw of content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith(";") || line.startsWith("!")) continue; + + const match = line.match(/^([^:=\s][^:=]*?)\s*[:=]\s*(.*)$/); + if (!match) continue; + + const key = match[1].trim(); + const value = match[2]; + entries.set(key, makeEntry(key, value, "ok")); + } + + return { + parseStatus: "ok", + entries: Array.from(entries.values()).sort((a, b) => a.key.localeCompare(b.key)), + }; +} + +export function normalizeConfig(content: string, format: Format): NormalizeResult { + switch (format) { + case "JSON": + return normalizeJson(content); + case "YAML": + return normalizeYaml(content); + case "Properties": + return normalizeProperties(content); + case "TOML": + case "XML": + case "HTML": + case "TEXT": + default: + return { + parseStatus: "fallback", + entries: [documentEntry(content, "fallback")], + }; + } +} diff --git a/src/lib/toast.test.ts b/src/lib/toast.test.ts new file mode 100644 index 0000000..a28ba62 --- /dev/null +++ b/src/lib/toast.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("toast store", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + }); + + it("notifies a subscriber with the current items immediately", async () => { + const { subscribe } = await import("./toast"); + const listener = vi.fn(); + + const unsubscribe = subscribe(listener); + + expect(listener).toHaveBeenCalledWith([]); + unsubscribe(); + }); + + it("adds toast items and removes them after the timeout", async () => { + const { subscribe, toast } = await import("./toast"); + const snapshots: unknown[] = []; + const unsubscribe = subscribe((items) => snapshots.push(items)); + + toast("saved", "success"); + + expect(snapshots[snapshots.length - 1]).toEqual([{ id: 1, text: "saved", type: "success" }]); + + vi.advanceTimersByTime(2600); + + expect(snapshots[snapshots.length - 1]).toEqual([]); + unsubscribe(); + }); + + it("does not notify unsubscribed listeners", async () => { + const { subscribe, toast } = await import("./toast"); + const listener = vi.fn(); + const unsubscribe = subscribe(listener); + listener.mockClear(); + + unsubscribe(); + toast("ignored", "info"); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/validate.test.ts b/src/lib/validate.test.ts new file mode 100644 index 0000000..ec576d1 --- /dev/null +++ b/src/lib/validate.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { validateConfig } from "./validate"; + +describe("validateConfig", () => { + it("allows empty content for every format", () => { + expect(validateConfig("", "JSON")).toEqual([]); + expect(validateConfig(" \n\t", "YAML")).toEqual([]); + }); + + it("reports JSON syntax errors", () => { + const issues = validateConfig('{"server":', "JSON"); + + expect(issues).toHaveLength(1); + expect(issues[0]).toContain("JSON 解析失败:"); + }); + + it("accepts valid JSON and YAML content", () => { + expect(validateConfig('{"server":{"port":8080}}', "JSON")).toEqual([]); + expect(validateConfig("server:\n port: 8080", "YAML")).toEqual([]); + }); + + it("reports duplicate YAML keys", () => { + const issues = validateConfig("server:\n port: 8080\n port: 9090", "YAML"); + + expect(issues.some((issue) => issue.startsWith("YAML:"))).toBe(true); + }); + + it("reports duplicate properties keys while separating sections", () => { + expect(validateConfig("server.port=8080\nserver.port=9090", "Properties")).toEqual([ + "重复的键:server.port", + ]); + expect(validateConfig("[dev]\nserver.port=8080\n[prod]\nserver.port=9090", "Properties")).toEqual( + [] + ); + }); + + it("accepts XML and reports parser failures", () => { + expect(validateConfig("", "XML")).toEqual([]); + expect(validateConfig("", "XML")[0]).toContain("XML 格式错误:"); + }); + + it("does not validate free-form text-like formats", () => { + expect(validateConfig("", "TEXT")).toEqual([]); + expect(validateConfig("
", "HTML")).toEqual([]); + }); +}); diff --git a/src/lib/validate.ts b/src/lib/validate.ts index 380d67013e78704b361a2ddc9f30257b67ca9bfb..4c28ac5747025ea0ec3821052f7ccbbbe171d10a 100644 GIT binary patch delta 741 zcmZ`%&ui2`6vh>DSOhP<)x+E1VN%$v)!Wu>DR{KBdTBA6Zii%&4JMN%nJC&WIY}=T zrRhaPDX4goDtb^T{x$A)3;qFm^UY+_3gSQ*=Kc8I_kAzBmrrIs{;Fx+>>8fpcP*ds zGVX{V=J0@qflCG3h{EE^1t5V3L_8X>*n?ZED{C}KSptnt2lR})%nLYkbpy8R#U<@d z(bMLOeQh%z*6RRH%u@lWpw9g^UP-`x%Z3hE_QF|KQ2#E&SnFmtog>}Wl0mHH<~`bc z+_SCj!?xM9=P%d^-LOs6 z9}%PMR-R(sWZX^H1JNf4Y{1SANJJAYOT;w3^q+V`M-@_Dz$@kwwd9@ql|ld+RXZ>T z@qid=b#jUU2@_eu!P?qbxeepP8)79jPu>Co`_^LKLeg5^s%KgiI8^urU^oV5#dvZgOTy#}oYMj2h77>~ diff --git a/src/store/connections.test.ts b/src/store/connections.test.ts new file mode 100644 index 0000000..648aae6 --- /dev/null +++ b/src/store/connections.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { deleteConnection, loadConnections, upsertConnection, type Connection } from "./connections"; + +class MemoryStorage { + private values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string) { + this.values.set(key, value); + } + + removeItem(key: string) { + this.values.delete(key); + } + + clear() { + this.values.clear(); + } +} + +describe("connection store", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-27T00:00:00Z")); + vi.spyOn(Math, "random").mockReturnValue(0.123456); + vi.stubGlobal("localStorage", new MemoryStorage()); + }); + + it("returns an empty list when storage is empty or malformed", () => { + expect(loadConnections()).toEqual([]); + + localStorage.setItem("cs.connections", "{bad json"); + + expect(loadConnections()).toEqual([]); + }); + + it("creates a connection with a generated id", () => { + const created = upsertConnection({ + name: "dev", + baseUrl: "http://localhost:8848/nacos", + username: "nacos", + password: "nacos", + defaultNamespace: "", + }); + + expect(created.id).toMatch(/^c_/); + expect(loadConnections()).toEqual([created]); + }); + + it("updates an existing connection in place", () => { + const created = upsertConnection({ + name: "dev", + baseUrl: "http://localhost:8848/nacos", + username: "nacos", + password: "nacos", + defaultNamespace: "", + }); + + const updated = upsertConnection({ ...created, name: "prod", defaultNamespace: "public" }); + + expect(updated.id).toBe(created.id); + expect(loadConnections()).toEqual([updated]); + }); + + it("deletes a connection by id", () => { + vi.mocked(Math.random).mockReturnValueOnce(0.123456).mockReturnValueOnce(0.654321); + const first = upsertConnection({ + name: "dev", + baseUrl: "http://localhost:8848/nacos", + username: "nacos", + password: "nacos", + defaultNamespace: "", + }); + const second = upsertConnection({ + name: "prod", + baseUrl: "http://prod:8848/nacos", + username: "", + password: "", + defaultNamespace: "public", + }); + + deleteConnection(first.id); + + expect(loadConnections()).toEqual([second]); + }); + + it("preserves SSH config when persisting", () => { + const conn: Omit = { + name: "ssh", + baseUrl: "http://nacos.internal:8848/nacos", + username: "", + password: "", + defaultNamespace: "", + sshConfig: { + host: "jump.example.com", + port: 22, + username: "ops", + authType: "password", + password: "secret", + remoteHost: "127.0.0.1", + remotePort: 8848, + }, + }; + + const created = upsertConnection(conn); + + expect(loadConnections()[0]).toEqual(created); + }); +}); From ece0abcd1f4fad96ac45fd1893741f7e27627db1 Mon Sep 17 00:00:00 2001 From: Adsryen Date: Sat, 27 Jun 2026 18:01:13 +0800 Subject: [PATCH 002/167] =?UTF-8?q?docs:=20=E8=A7=84=E5=88=92=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=BA=94=E7=94=A8=E4=B8=8E=E6=93=8D=E4=BD=9C=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补充环境间配置应用和晋级流程 - 增加操作历史与回退规划 - 调整 Apollo 和 WebDAV 后续里程碑顺序 --- docs/todo/todo.md | 105 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 6 deletions(-) diff --git a/docs/todo/todo.md b/docs/todo/todo.md index b4b9b7e..01c1df4 100644 --- a/docs/todo/todo.md +++ b/docs/todo/todo.md @@ -274,18 +274,23 @@ - 发布配置。 - 删除配置。 - 回滚配置。 + - 环境间配置应用。 - 批量操作。 - [ ] 生产环境要求人工输入确认文本。 - 例如输入 `确认发布到生产`。 - 确认文案可在连接配置中设置。 - [ ] UI 中对生产/沙箱连接做持续可见标识。 - 顶部连接选择器显示安全等级。 - - 发布、删除、恢复按钮使用危险态样式。 + - 发布、删除、恢复、应用到环境按钮使用危险态样式。 - 批量操作预览页标出将影响的生产项数量。 - [ ] 沙箱环境策略。 - 可配置为禁止发布。 - 可配置为发布后不立即生效,如果目标配置中心支持该语义。 - Nacos 不天然支持“发布但不生效”,需要在 UI 上明确限制,不假装支持。 +- [ ] 环境晋级策略。 + - 真实环境可配置为必须先经过指定沙箱环境。 + - 真实环境应用必须引用已执行成功的沙箱计划。 + - 如果源环境或目标环境在计划生成后发生变化,需要提示重新生成计划。 ### 3.4 敏感信息处理 @@ -356,6 +361,57 @@ - [ ] 跳过已存在项。 - [ ] dry-run 模式:只生成计划,不执行发布。 +### 4.5 环境间配置应用/晋级 + +这是审计矩阵之后必须补齐的闭环能力:用户发现 A 环境与 B 环境差异后,可以把 A 环境的配置应用到 B 沙箱环境,验证通过后再应用到真实环境。它不应被当成普通“复制粘贴发布”,必须带计划、确认、历史和回退点。 + +- [ ] **应用入口** + - 从 AuditView 的差异行发起。 + - 从 DiffView 的单配置差异发起。 + - 从 BackupView 的快照差异发起。 +- [ ] **应用范围** + - 单 key 应用:只同步某个拆解后的 key/value。 + - 单配置应用:同步整个 dataId 内容。 + - 批量应用:同步筛选后的多条差异。 +- [ ] **目标环境策略** + - 支持 `源环境 -> 沙箱环境 -> 真实环境` 的两阶段晋级。 + - 沙箱应用完成后生成验证状态,不自动继续推真实环境。 + - 真实环境应用必须基于沙箱验证过的计划版本,避免中途源配置变化导致误发。 +- [ ] **dry-run 应用计划** + - 展示源值、目标当前值、应用后值。 + - 展示新增、覆盖、删除、跳过、解析失败项。 + - 展示影响的 provider、connection、namespace、group、dataId、key。 + - 计划生成后保存计划快照,确认执行时使用计划快照而不是重新隐式计算。 +- [ ] **确认机制** + - 沙箱环境至少需要普通确认。 + - 生产/真实环境需要输入确认文本。 + - 批量应用需要先展示影响数量和失败风险。 + - 禁止跳过 dry-run 直接写入。 +- [ ] **执行与任务中心** + - 应用操作进入任务中心。 + - 展示每项执行状态:待执行、成功、失败、跳过、已回退。 + - 支持失败后继续/停止策略。 + - 支持复制执行报告。 +- [ ] **写入前备份** + - 每次应用前保存目标环境原始内容快照。 + - 快照与操作历史绑定,作为回退来源。 + - 如果备份失败,默认禁止继续写入。 +- [ ] **操作历史** + - 新增 `OperationHistory` 领域模型。 + - 记录操作类型:apply、promote、restore、rollback、delete、publish。 + - 记录操作者、本机时间、源环境、目标环境、计划摘要、执行结果、错误详情。 + - 记录目标写入前内容、写入后内容、对应配置中心历史版本。 + - 历史记录默认保存在 Wails 应用数据目录,后续可导出 JSON。 +- [ ] **回退操作** + - 基于写入前备份生成 rollback dry-run。 + - 回退也必须进入任务中心和操作历史。 + - 生产回退同样需要生产确认文本。 + - 支持从操作历史详情页一键发起回退计划。 +- [ ] **UI 页面** + - 新增 ApplyPlanView 或在任务中心中提供计划详情页。 + - OperationHistoryView 展示操作列表、筛选、详情、回退入口。 + - AuditView 行操作提供“应用到沙箱”“创建晋级计划”。 + --- ## 5. 中优先级:Apollo 配置中心 @@ -544,6 +600,10 @@ - [ ] 增加应用日志查看页面。 - [ ] 日志页面接入主导航的“任务/日志”区域。 +- [ ] 增加操作历史页面。 + - 展示环境应用、发布、恢复、回退、删除等操作。 + - 可按连接、环境、安全等级、操作类型、状态筛选。 + - 支持从历史详情复制执行报告。 - [ ] Go 后端统一错误类型。 - 网络错误。 - 认证错误。 @@ -555,6 +615,7 @@ - [ ] 批量任务结束后展示成功、失败、跳过数量。 - [ ] 错误详情可复制。 - [ ] 日志默认脱敏。 +- [ ] 操作历史默认脱敏。 --- @@ -605,6 +666,15 @@ - 普通环境。 - 生产环境输入确认文本。 - dry-run 未完成时禁止执行。 +- [ ] 测试环境应用计划生成。 + - 单 key 应用。 + - 单配置应用。 + - 批量应用。 + - 源/目标在计划后变化时要求重新生成。 +- [ ] 测试操作历史与回退计划。 + - 写入前备份失败禁止执行。 + - 应用成功后记录 before/after。 + - 回退计划使用 before 快照。 ### 10.2.1 React 组件测试 @@ -643,6 +713,9 @@ - 两配置 diff。 - 审计矩阵过滤。 - 导出审计结果。 + - 从差异创建应用计划。 + - 应用到沙箱后再晋级真实环境。 + - 从操作历史发起回退 dry-run。 - [ ] 桌面壳冒烟测试单独评估。 - 构建成功。 - 应用启动。 @@ -751,7 +824,22 @@ Abort trap: 6 - [ ] 任务中心基础能力。 - [ ] React 组件测试覆盖连接管理、DiffPanel、BackupView 基础交互。 -### v1.3:Apollo 第一阶段 +### v1.3:环境应用与操作历史 + +- [ ] ApplyPlan 领域模型。 +- [ ] OperationHistory 领域模型。 +- [ ] 写入前目标配置快照。 +- [ ] 从 DiffView/AuditView 创建应用计划。 +- [ ] 源环境到沙箱环境应用。 +- [ ] 沙箱验证通过后晋级真实环境。 +- [ ] 生产/真实环境确认文本。 +- [ ] 应用任务接入任务中心。 +- [ ] 操作历史列表与详情页。 +- [ ] 从操作历史生成 rollback dry-run。 +- [ ] 回退执行接入任务中心和操作历史。 +- [ ] Playwright 覆盖“差异 -> 沙箱应用 -> 真实环境晋级 -> 历史回退”主流程。 + +### v1.4:Apollo 第一阶段 - [ ] provider 抽象落地。 - [ ] Apollo OpenAPI 连接。 @@ -763,7 +851,7 @@ Abort trap: 6 - [ ] Apollo 审计导出。 - [ ] Apollo Go client 使用 `httptest.Server` 覆盖连接、浏览、读取、错误处理。 -### v1.4:WebDAV 与恢复 +### v1.5:WebDAV 与恢复 - [ ] WebDAV 备份上传/下载。 - [ ] WebDAV 任务接入任务中心。 @@ -814,9 +902,14 @@ Abort trap: 6 7. [ ] 做审计导出。 8. [ ] 引入 React Testing Library,覆盖 AuditView/连接管理/DiffPanel 的关键交互。 9. [ ] 引入 Playwright Web E2E,用 mock Wails API 覆盖浏览、diff、审计主流程。 -10. [ ] 抽 provider 接口,把 Nacos 迁移进去。 -11. [ ] 连接管理升级为 provider 感知分组表单。 -12. [ ] 开始 Apollo OpenAPI 第一阶段。 +10. [ ] 设计 ApplyPlan 与 OperationHistory,先不写入真实环境。 +11. [ ] 实现从 DiffView/AuditView 创建应用 dry-run 计划。 +12. [ ] 实现应用到沙箱环境,并记录操作历史和写入前快照。 +13. [ ] 实现沙箱计划晋级真实环境,接入生产确认文本。 +14. [ ] 实现基于操作历史的 rollback dry-run。 +15. [ ] 抽 provider 接口,把 Nacos 迁移进去。 +16. [ ] 连接管理升级为 provider 感知分组表单。 +17. [ ] 开始 Apollo OpenAPI 第一阶段。 这个顺序的原因:先把参考项目最有价值的“审计矩阵”在现有 Nacos 能力上跑通,再抽象 provider。否则一开始就抽象 Apollo/Consul,容易为了未知需求过度设计。 From 611f1cdf5777b7eb2ce7291bc919731bc7336ab1 Mon Sep 17 00:00:00 2001 From: Adsryen Date: Sat, 27 Jun 2026 18:28:42 +0800 Subject: [PATCH 003/167] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E7=BB=84=E4=BB=B6=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 React Testing Library 和 jsdom 测试依赖 - 新增 DiffPanel、Pager、Select、ConfirmModal、CopyButton 组件测试 - 提供组件测试专用 setup,避免影响纯函数测试环境 --- package.json | 3 + pnpm-lock.yaml | 503 ++++++++++++++++++++++++++- src/components/ConfirmModal.test.tsx | 58 +++ src/components/CopyButton.test.tsx | 40 +++ src/components/DiffPanel.test.tsx | 59 ++++ src/components/Pager.test.tsx | 37 ++ src/components/Select.test.tsx | 41 +++ src/test/react.ts | 12 + 8 files changed, 751 insertions(+), 2 deletions(-) create mode 100644 src/components/ConfirmModal.test.tsx create mode 100644 src/components/CopyButton.test.tsx create mode 100644 src/components/DiffPanel.test.tsx create mode 100644 src/components/Pager.test.tsx create mode 100644 src/components/Select.test.tsx create mode 100644 src/test/react.ts diff --git a/package.json b/package.json index 50ee1ac..4531fe4 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,13 @@ "yaml": "^2.9.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "^18", "@types/react-dom": "^18", "@vitejs/plugin-react": "^4", "esbuild": "^0.28.0", + "jsdom": "^29.1.1", "typescript": "^5", "vite": "^5", "vitest": "^2.1.9" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f7d439..7014aba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,12 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ^18 version: 18.3.31 @@ -33,6 +39,9 @@ importers: esbuild: specifier: ^0.28.0 version: 0.28.1 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 typescript: specifier: ^5 version: 5.9.3 @@ -41,10 +50,28 @@ importers: version: 5.4.21 vitest: specifier: ^2.1.9 - version: 2.1.9 + version: 2.1.9(jsdom@29.1.1) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -116,6 +143,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -128,6 +159,46 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -422,6 +493,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -579,6 +659,32 @@ packages: cpu: [x64] os: [win32] + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -640,6 +746,21 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -649,6 +770,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + browserslist@4.28.4: resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -672,9 +796,20 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -684,13 +819,30 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + electron-to-chromium@1.5.379: resolution: {integrity: sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -728,9 +880,29 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -748,12 +920,27 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -766,6 +953,9 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -780,11 +970,22 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: react: ^18.3.1 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -793,11 +994,23 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rollup@4.62.2: resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -818,6 +1031,13 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -836,11 +1056,30 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tldts-core@7.4.4: + resolution: {integrity: sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==} + + tldts@7.4.4: + resolution: {integrity: sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==} + hasBin: true + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -908,11 +1147,34 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -923,6 +1185,28 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -1012,6 +1296,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -1035,6 +1321,34 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1182,6 +1496,8 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@exodus/bytes@1.15.1': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1278,6 +1594,38 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -1364,10 +1712,24 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + assertion-error@2.0.1: {} baseline-browser-mapping@2.10.40: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + browserslist@4.28.4: dependencies: baseline-browser-mapping: 2.10.40 @@ -1392,16 +1754,40 @@ snapshots: convert-source-map@2.0.0: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + electron-to-chromium@1.5.379: {} + entities@8.0.0: {} + es-module-lexer@1.7.0: {} esbuild@0.21.5: @@ -1474,8 +1860,44 @@ snapshots: highlight.js@11.11.1: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + indent-string@4.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + js-tokens@4.0.0: {} + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json5@2.2.3: {} @@ -1486,20 +1908,32 @@ snapshots: loupe@3.2.1: {} + lru-cache@11.5.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mdn-data@2.27.1: {} + + min-indent@1.0.1: {} + ms@2.1.3: {} nanoid@3.3.15: {} node-releases@2.0.50: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + pathe@1.1.2: {} pathval@2.0.1: {} @@ -1512,18 +1946,35 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 react: 18.3.1 scheduler: 0.23.2 + react-is@17.0.2: {} + react-refresh@0.17.0: {} react@18.3.1: dependencies: loose-envify: 1.4.0 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + rollup@4.62.2: dependencies: '@types/estree': 1.0.9 @@ -1555,6 +2006,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -1569,6 +2024,12 @@ snapshots: std-env@3.10.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + symbol-tree@3.2.4: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -1579,8 +2040,24 @@ snapshots: tinyspy@3.0.2: {} + tldts-core@7.4.4: {} + + tldts@7.4.4: + dependencies: + tldts-core: 7.4.4 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.4 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + typescript@5.9.3: {} + undici@7.28.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.4): dependencies: browserslist: 4.28.4 @@ -1613,7 +2090,7 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - vitest@2.1.9: + vitest@2.1.9(jsdom@29.1.1): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(vite@5.4.21) @@ -1635,6 +2112,8 @@ snapshots: vite: 5.4.21 vite-node: 2.1.9 why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 29.1.1 transitivePeerDependencies: - less - lightningcss @@ -1646,11 +2125,31 @@ snapshots: - supports-color - terser + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yaml@2.9.0: {} diff --git a/src/components/ConfirmModal.test.tsx b/src/components/ConfirmModal.test.tsx new file mode 100644 index 0000000..3b210e8 --- /dev/null +++ b/src/components/ConfirmModal.test.tsx @@ -0,0 +1,58 @@ +/** + * @vitest-environment jsdom + */ +import { fireEvent, render, screen } from "../test/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "../i18n"; +import ConfirmModal from "./ConfirmModal"; + +function renderModal(props: Partial[0]> = {}) { + return render( + + + + ); +} + +describe("ConfirmModal", () => { + beforeEach(() => { + localStorage.setItem("locale", "zh-CN"); + }); + + it("renders title, message, and default actions", () => { + renderModal(); + + expect(screen.getByText("确认操作")).toBeInTheDocument(); + expect(screen.getByText("是否继续?")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "取消" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "确定" })).toBeInTheDocument(); + }); + + it("calls callbacks from buttons and keyboard shortcuts", () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + renderModal({ onConfirm, onCancel }); + + fireEvent.click(screen.getByRole("button", { name: "确定" })); + fireEvent.keyDown(window, { key: "Escape" }); + fireEvent.keyDown(window, { key: "Enter" }); + + expect(onConfirm).toHaveBeenCalledTimes(2); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("cancels when clicking the overlay", () => { + const onCancel = vi.fn(); + renderModal({ onCancel }); + + fireEvent.click(screen.getByText("确认操作").closest(".modal-overlay")!); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/CopyButton.test.tsx b/src/components/CopyButton.test.tsx new file mode 100644 index 0000000..2d48306 --- /dev/null +++ b/src/components/CopyButton.test.tsx @@ -0,0 +1,40 @@ +/** + * @vitest-environment jsdom + */ +import { act, fireEvent, render, screen } from "../test/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CopyButton from "./CopyButton"; + +vi.mock("../lib/clipboard", () => ({ + copyText: vi.fn(), +})); + +describe("CopyButton", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it("is disabled without text", () => { + render(); + + expect(screen.getByRole("button", { name: "复制" })).toBeDisabled(); + }); + + it("shows copied feedback after successful copy", async () => { + const { copyText } = await import("../lib/clipboard"); + vi.mocked(copyText).mockResolvedValue(true); + render(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "复制内容" })); + }); + + expect(screen.getByRole("button", { name: "✓ 已复制" })).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1200); + }); + + expect(screen.getByRole("button", { name: "复制内容" })).toBeInTheDocument(); + }); +}); diff --git a/src/components/DiffPanel.test.tsx b/src/components/DiffPanel.test.tsx new file mode 100644 index 0000000..fa0af98 --- /dev/null +++ b/src/components/DiffPanel.test.tsx @@ -0,0 +1,59 @@ +/** + * @vitest-environment jsdom + */ +import { fireEvent, render, screen } from "../test/react"; +import { describe, expect, it } from "vitest"; +import DiffPanel from "./DiffPanel"; + +describe("DiffPanel", () => { + it("shows an identical state and both labels", () => { + render( + + ); + + expect(screen.getByText("✓ 两侧内容完全一致")).toBeInTheDocument(); + expect(screen.getByText("dev/app.yaml")).toBeInTheDocument(); + expect(screen.getByText("prod/app.yaml")).toBeInTheDocument(); + }); + + it("shows diff counters for changed content", () => { + render( + + ); + + expect(document.querySelector(".stat-add")).toHaveTextContent("+1 新增"); + expect(document.querySelector(".stat-del")).toHaveTextContent("−0 删除"); + expect(document.querySelector(".stat-mod")).toHaveTextContent("~1 修改"); + }); + + it("can show only changed rows", () => { + render( + + ); + + expect(screen.getAllByText("same")).toHaveLength(2); + + fireEvent.click(screen.getByLabelText("仅显示变更")); + + expect(screen.queryByText("same")).not.toBeInTheDocument(); + expect(screen.getByText("old")).toBeInTheDocument(); + expect(screen.getByText("new")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Pager.test.tsx b/src/components/Pager.test.tsx new file mode 100644 index 0000000..290d926 --- /dev/null +++ b/src/components/Pager.test.tsx @@ -0,0 +1,37 @@ +/** + * @vitest-environment jsdom + */ +import { fireEvent, render, screen } from "../test/react"; +import { describe, expect, it, vi } from "vitest"; +import Pager from "./Pager"; + +describe("Pager", () => { + it("renders nothing when there is only one page", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("moves to previous and next pages", () => { + const onPage = vi.fn(); + render(); + + fireEvent.click(screen.getByTitle("上一页")); + fireEvent.click(screen.getByTitle("下一页")); + + expect(onPage).toHaveBeenNthCalledWith(1, 1); + expect(onPage).toHaveBeenNthCalledWith(2, 3); + }); + + it("disables navigation at boundaries or while loading", () => { + const onPage = vi.fn(); + const { rerender } = render(); + + expect(screen.getByTitle("上一页")).toBeDisabled(); + + rerender(); + + expect(screen.getByTitle("上一页")).toBeDisabled(); + expect(screen.getByTitle("下一页")).toBeDisabled(); + }); +}); diff --git a/src/components/Select.test.tsx b/src/components/Select.test.tsx new file mode 100644 index 0000000..f296573 --- /dev/null +++ b/src/components/Select.test.tsx @@ -0,0 +1,41 @@ +/** + * @vitest-environment jsdom + */ +import { fireEvent, render, screen } from "../test/react"; +import { describe, expect, it, vi } from "vitest"; +import Select from "./Select"; + +const options = [ + { value: "json", label: "JSON" }, + { value: "yaml", label: "YAML" }, +]; + +describe("Select", () => { + it("shows the selected label", () => { + render(); + + fireEvent.click(screen.getByRole("button")); + fireEvent.mouseDown(screen.getByText("YAML")); + + expect(onChange).toHaveBeenCalledWith("yaml"); + expect(screen.queryByText("YAML")).not.toBeInTheDocument(); + }); + + it("closes on Escape", () => { + render( setProxyField("httpProxy", e.target.value)} + /> + + + +
+ +

{t('about.features')}

    diff --git a/src/locales/en-US.json b/src/locales/en-US.json index e2bda13..ab17566 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -204,6 +204,16 @@ "tech2": "Backend: Go + Wails 2", "tech3": "UI: Dark VSCode theme", "supportedCenters": "📦 Supported Config Centers", + "updateTitle": "App Updates", + "checkUpdate": "Check for Updates", + "checkingUpdate": "Checking…", + "openDownload": "Open Download Page", + "updateAvailable": "New version v{version} available", + "updateSourceHit": "Source: {source}", + "noUpdate": "You are on the latest version", + "httpProxy": "HTTP Proxy", + "httpsProxy": "HTTPS Proxy", + "noProxy": "No Proxy", "feedback": "🐛 Report Issue", "close": "Close" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 83ae6db..f33f04d 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -204,6 +204,16 @@ "tech2": "后端:Go + Wails 2", "tech3": "UI:深色 VSCode 风格", "supportedCenters": "📦 支持的配置中心", + "updateTitle": "应用更新", + "checkUpdate": "检查更新", + "checkingUpdate": "检查中…", + "openDownload": "打开下载页", + "updateAvailable": "发现新版本 v{version}", + "updateSourceHit": "命中线路:{source}", + "noUpdate": "当前已是最新版本", + "httpProxy": "HTTP 代理", + "httpsProxy": "HTTPS 代理", + "noProxy": "不走代理", "feedback": "🐛 反馈问题", "close": "关闭" } diff --git a/src/store/settings.test.ts b/src/store/settings.test.ts new file mode 100644 index 0000000..1ac6514 --- /dev/null +++ b/src/store/settings.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { loadSettings, saveSettings, updateProxySettings } from "./settings"; + +class MemoryStorage { + private values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string) { + this.values.set(key, value); + } + + removeItem(key: string) { + this.values.delete(key); + } + + clear() { + this.values.clear(); + } +} + +describe("settings store", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", new MemoryStorage()); + }); + + it("returns defaults when storage is empty or malformed", () => { + expect(loadSettings()).toEqual({ + proxy: { httpProxy: "", httpsProxy: "", noProxy: "" }, + update: { skipVersion: "", lastCheckAt: "" }, + }); + + localStorage.setItem("cs.settings", "{bad json"); + + expect(loadSettings()).toEqual({ + proxy: { httpProxy: "", httpsProxy: "", noProxy: "" }, + update: { skipVersion: "", lastCheckAt: "" }, + }); + }); + + it("persists global proxy settings", () => { + updateProxySettings({ + httpProxy: "http://127.0.0.1:7890", + httpsProxy: "http://127.0.0.1:7890", + noProxy: "localhost,127.0.0.1", + }); + + expect(loadSettings().proxy).toEqual({ + httpProxy: "http://127.0.0.1:7890", + httpsProxy: "http://127.0.0.1:7890", + noProxy: "localhost,127.0.0.1", + }); + }); + + it("preserves unrelated settings when saving", () => { + saveSettings({ + proxy: { httpProxy: "", httpsProxy: "", noProxy: "" }, + update: { skipVersion: "1.2.0", lastCheckAt: "2026-06-28T00:00:00Z" }, + }); + + updateProxySettings({ httpProxy: "http://proxy.local:8080" }); + + expect(loadSettings()).toEqual({ + proxy: { httpProxy: "http://proxy.local:8080", httpsProxy: "", noProxy: "" }, + update: { skipVersion: "1.2.0", lastCheckAt: "2026-06-28T00:00:00Z" }, + }); + }); +}); diff --git a/src/store/settings.ts b/src/store/settings.ts new file mode 100644 index 0000000..1c9cb0d --- /dev/null +++ b/src/store/settings.ts @@ -0,0 +1,67 @@ +export interface ProxySettings { + httpProxy: string; + httpsProxy: string; + noProxy: string; +} + +export interface UpdateSettings { + skipVersion: string; + lastCheckAt: string; +} + +export interface AppSettings { + proxy: ProxySettings; + update: UpdateSettings; +} + +const KEY = "cs.settings"; + +const defaults: AppSettings = { + proxy: { httpProxy: "", httpsProxy: "", noProxy: "" }, + update: { skipVersion: "", lastCheckAt: "" }, +}; + +export function loadSettings(): AppSettings { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return structuredClone(defaults); + const parsed = JSON.parse(raw); + return normalizeSettings(parsed); + } catch { + return structuredClone(defaults); + } +} + +export function saveSettings(settings: AppSettings) { + localStorage.setItem(KEY, JSON.stringify(normalizeSettings(settings))); +} + +export function updateProxySettings(proxy: Partial) { + const current = loadSettings(); + saveSettings({ + ...current, + proxy: { + ...current.proxy, + ...proxy, + }, + }); +} + +function normalizeSettings(value: unknown): AppSettings { + const input = value as Partial; + return { + proxy: { + httpProxy: stringValue(input?.proxy?.httpProxy), + httpsProxy: stringValue(input?.proxy?.httpsProxy), + noProxy: stringValue(input?.proxy?.noProxy), + }, + update: { + skipVersion: stringValue(input?.update?.skipVersion), + lastCheckAt: stringValue(input?.update?.lastCheckAt), + }, + }; +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} diff --git a/src/styles.css b/src/styles.css index 2c9e2ff..f4be7ce 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1000,6 +1000,24 @@ select, button, .mode-btn, .tab-btn { cursor: pointer; } text-decoration: none; } +.update-actions { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} + +.update-source-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 8px 0 10px; +} + +.update-proxy-row { + align-items: flex-start; +} + .about-footer { text-align: center; padding-top: 20px; diff --git a/wailsjs/go/main/App.ts b/wailsjs/go/main/App.ts index 7930dd3..3abee69 100644 --- a/wailsjs/go/main/App.ts +++ b/wailsjs/go/main/App.ts @@ -1,4 +1,6 @@ type GoApp = { + GetAppInfo(): Promise; + CheckForUpdates(request: any): Promise; NacosDetectVersion(baseUrl: string): Promise; NacosLogin(baseUrl: string, username: string, password: string, apiVersion: string): Promise; NacosNamespaces(baseUrl: string, accessToken: string, apiVersion: string): Promise; @@ -75,6 +77,10 @@ declare global { const app = () => window.go.main.App; +export const GetAppInfo = () => app().GetAppInfo(); + +export const CheckForUpdates = (request: any) => app().CheckForUpdates(request); + export const NacosDetectVersion = (baseUrl: string) => app().NacosDetectVersion(baseUrl); export const NacosLogin = (baseUrl: string, username: string, password: string, apiVersion: string) => From 85c058aeb3cd7594a240000fb46c48abdbc5206d Mon Sep 17 00:00:00 2001 From: Adsryen Date: Sun, 28 Jun 2026 22:52:02 +0800 Subject: [PATCH 008/167] =?UTF-8?q?docs:=20=E6=8C=89=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E8=B7=AF=E7=BA=BF=E5=9B=BE=E9=87=8D=E6=8E=92=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=E8=A7=84=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加 MAJOR、MINOR、PATCH 和预发布版本规则 - 将待办按版本和 P0/P1/P2 优先级重新组织 - 收敛重复主题章节为能力池和取舍清单 --- docs/todo/todo.md | 1379 ++++++++++++++++++--------------------------- 1 file changed, 550 insertions(+), 829 deletions(-) diff --git a/docs/todo/todo.md b/docs/todo/todo.md index a23597d..27b4cc9 100644 --- a/docs/todo/todo.md +++ b/docs/todo/todo.md @@ -6,45 +6,136 @@ --- -## 近期执行顺序 - -- [x] **补齐现有核心自动化测试第一批**:已覆盖 Go 后端 Nacos/SSH 基线,以及前端核心纯函数、状态模块和主要配置操作组件。 -- [x] **应用检查更新第一版**:已实现 ConfScope 自身版本检查、更新提示、国内加速线路回退、下载入口和检查更新代理配置。 -- [ ] **下一项立即做:代码结构拆分**:在检查更新完成并有测试保护后,推进后端 provider 抽象、前端 API 边界和大组件拆分。 -- [ ] **再进入多环境治理主线**:审计矩阵 UI、环境间应用、操作历史与回退、Apollo 支持按后续章节逐步推进。 +## 0. 版本规划规则 + +这个文档以后按“版本 -> 优先级 -> 任务”维护。版本号由更新内容决定,不因为做了一个小功能就随意提高大版本。 + +### 0.1 版本号规则 + +- **MAJOR: `v2.0.0`、`v3.0.0`** + - 核心产品形态发生明显变化。 + - 本地数据结构、配置文件、操作历史、连接模型存在不兼容迁移。 + - Provider 抽象、权限/安全模型、批量治理流程发生破坏性变化。 + - 示例:从“单配置中心工具”升级为“多配置中心治理平台”。 +- **MINOR: `v1.1.0`、`v1.2.0`** + - 一组完整、可发布、用户可感知的新能力。 + - 向后兼容,不要求用户重新配置才能继续使用已有核心功能。 + - 示例:审计矩阵、备份快照、Apollo 只读接入、环境应用计划。 +- **PATCH: `v1.0.1`、`v1.1.1`** + - 修 bug、补测试、优化 UI 细节、补发布工程、完善检查更新等小增强。 + - 内部重构只有在不改变用户能力边界时才放 PATCH。 + - 示例:`-ldflags` 注入版本号、修复代理配置保存、补 E2E 冒烟测试。 +- **预发布: `v1.2.0-alpha.1`、`v1.2.0-beta.1`、`v1.2.0-rc.1`** + - 大功能尚未稳定但需要内部验证。 + - `alpha` 用于流程未完整,`beta` 用于功能基本完整,`rc` 用于候选正式版。 + +### 0.2 优先级规则 + +- **P0: 发布门槛** + - 这个版本不做就不应该发布。 + - 必须有自动化测试或明确的验证方式。 + - 涉及写入、删除、恢复、生产环境操作时必须有安全确认和回退策略。 +- **P1: 版本完整度** + - 做了会明显提升体验或稳定性。 + - 可以延后到同版本 patch,但不能长期没有归属。 +- **P2: 可延后增强** + - 不影响当前版本主路径。 + - 适合滚入后续 minor 或 patch,不阻塞发布。 + +### 0.3 发布门槛 + +每个正式版本至少满足: + +- [ ] P0 任务全部完成或明确移出该版本。 +- [ ] Go 测试通过:`go test ./... -count=1`。 +- [ ] 前端类型检查通过:`pnpm exec tsc --noEmit`。 +- [ ] 前端测试通过:`pnpm test`。 +- [ ] Web 构建通过:`pnpm build:web`。 +- [ ] `CHANGELOG`、应用版本、更新 manifest 同步。 +- [ ] 危险操作、代理、凭据、导出内容经过脱敏检查。 + +### 0.4 文档维护规则 + +- 同一个待办只放在一个版本里,避免多处重复打勾。 +- “能力池/参考清单”只记录方向和取舍,不作为执行状态来源。 +- 当前版本的 P0 永远优先于未来版本的 P1/P2。 +- 小修小补优先归入当前 patch 线,不为它们单独开 minor。 +- 如果任务延期,要移动到目标版本,不保留原位置的重复项。 --- -## 0. 参考项目结论与取舍 - -`ConfigCenterComparer` 值得参考的是产品模型,不是代码实现。它通过数据库读取 Apollo/Nacos 配置,把不同环境的数据整理成统一矩阵,再做一致性判断、忽略、过滤、导出和重复配置检查。 +## 1. 当前执行指针 -### 值得吸收 +**当前基线**: `v1.0.0` +**当前开发线**: `v1.1.0` +**下一项立即做**: `v1.1.0 / P0` 的代码结构拆分与 provider 底座。 -- [ ] **多环境一致性矩阵**:把 `配置中心 / 环境 / 命名空间 / group / dataId / key / value / 更新时间` 规整成统一行模型,再计算一致性状态。 -- [ ] **key 级配置拆解**:将 YAML、JSON、Properties 等内容解析为 `key -> value`,而不是只做整文件文本 diff。 -- [ ] **忽略列表**:允许用户把预期不同的配置项加入忽略规则,减少巡检噪音。 -- [ ] **名称归一化**:支持 dataId、namespace、service/app 名称的前缀裁剪、后缀裁剪、精确替换,用于对齐不同环境命名。 -- [ ] **重复配置检查**:在同一环境内找出多个服务重复配置了相同 `key + value` 的场景。 -- [ ] **导出审计结果**:支持把一致性矩阵、异常项、重复项导出为 CSV/JSON。 +- [x] `v1.0.0` 已具备 Nacos 浏览、编辑、历史、diff、多连接、SSH 基础能力。 +- [x] 已完成核心自动化测试第一批。 +- [x] 已完成应用检查更新第一版:manifest、国内加速线路、代理配置、About 页入口。 +- [ ] 进入 `v1.1.0` 前,先保证当前工作区干净、测试通过、todo 与 changelog 同步。 -### 不建议照搬 +--- -- [ ] **不直接复制 GPL-3.0 代码**:参考项目是 GPL-3.0,ConfScope 是 MIT,只能借鉴产品思路并重写实现。 -- [ ] **不优先走数据库直连**:参考项目通过 MySQL 表读取 Apollo/Nacos,依赖内部表结构。ConfScope 应优先走配置中心 OpenAPI,保留权限、历史、发布语义。 -- [ ] **不固定 4 个环境**:参考项目偏 `PRO/PRE/TEST/DEV`,ConfScope 应支持任意数量环境,并允许用户指定基准环境。 -- [ ] **不只支持 YAML**:ConfScope 当前已有多格式识别能力,后续应支持 YAML、JSON、Properties、TOML;XML/TEXT 可做降级处理。 -- [ ] **不把比较规则写死**:一致性状态、忽略规则、生产环境确认规则都应配置化。 +## 2. 版本路线图 + +### 2.1 `v1.0.x`: 稳定化补丁线 + +**版本性质**: PATCH +**目标**: 稳住 `v1.0.0` 已有能力,补齐发布、检查更新、测试和小问题,不引入大规模新工作流。 + +#### P0 + +- [x] 补齐现有核心自动化测试第一批。 + - Go 后端覆盖 Nacos client、SSH manager/tunnel、检查更新。 + - 前端覆盖 diff、format、keys、validate、normalize、audit、highlight、toast、connection store、检查更新,以及配置浏览、编辑、历史、删除确认、连接管理等核心组件路径。 +- [x] 应用检查更新第一版。 + - Go 侧 `internal/updatecheck` 独立实现 manifest 检查、版本比较、更新源回退、HTTPS 下载校验。 + - 默认线路:GitHub 官方、`gh.llkk.cc`、`gh-proxy.com`、`ghfast.top`。 + - About 页展示当前版本、更新状态、命中线路、下载入口。 + - 检查更新支持 HTTP proxy、HTTPS proxy、no_proxy。 +- [ ] 发布时统一应用版本来源。 + - 当前 Go 侧仍有 `appVersion = "1.0.0"`。 + - 打包时改用 `-ldflags` 注入版本,避免 `package.json` 与 Go 常量分叉。 +- [ ] 准备更新 manifest 发布流程。 + - manifest 字段:`version`、`notes`、`downloadUrl`、`publishedAt`、`sha256`、`mandatory`。 + - 发布前校验下载链接必须为 HTTPS。 + +#### P1 + +- [ ] 检查更新本地状态。 + - 记录 `lastCheckAt`、`lastSeenVersion`、`skipVersion`。 + - 支持“忽略此版本”。 + - `mandatory=true` 时不允许忽略,但仍不自动安装。 +- [ ] 启动后低频后台检查。 + - 默认不弹强打断弹框。 + - 有更新时只给顶部提示或 About 红点。 +- [ ] 将 About 页中的代理入口迁移到 SettingsView 后,About 只保留检查更新操作。 + +#### P2 + +- [ ] 补 `CHANGELOG` 自动生成或半自动模板。 +- [ ] 桌面壳冒烟脚本。 + - 构建成功。 + - 应用启动。 + - 首页无白屏。 --- -## 1. 总体架构方向 +### 2.2 `v1.1.0`: 架构底座与设置中心 -当前项目已经有 `internal/nacos`、`src/api/nacos.ts`、`DiffView`、`ConfigBrowser`。下一阶段应先抽象“配置中心通用模型”,否则 Apollo、Consul、备份、本地模式、审计矩阵都会继续绑死在 Nacos 上。 +**版本性质**: MINOR +**目标**: 先拆结构,再做大功能。把 Nacos 现有能力迁移到通用 provider 边界,为 AuditView、Apollo、备份、环境应用铺路。 -### 1.1 配置中心 Provider 抽象 +#### P0 -- [ ] **设计 Go 后端通用接口** +- [ ] 后端 Provider 抽象。 + - 定义 `ProviderType`: `nacos | apollo | consul | local`。 + - 定义 `ConnectionProfile`: 连接、认证、代理、SSH、环境标签、安全等级。 + - 定义 `ConfigRef`: provider、connectionId、namespace、group、dataId、key。 + - 定义 `ConfigDocument`: 原始内容、格式、更新时间、版本、来源。 + - 定义 `ConfigEntry`: 拆解后的 key/value/path/type。 +- [ ] 后端通用接口。 - `ListNamespaces` - `ListConfigs` - `GetConfig` @@ -53,815 +144,546 @@ - `ListHistory` - `GetHistoryDetail` - `TestConnection` -- [ ] **定义统一领域模型** - - `ProviderType`: `nacos | apollo | consul | local` - - `ConnectionProfile`: 连接、认证、代理、SSH、环境标签、安全等级 - - `ConfigRef`: provider、connectionId、namespace、group、dataId、key - - `ConfigDocument`: 原始内容、格式、更新时间、版本、来源 - - `ConfigEntry`: 拆解后的 key/value/path/type -- [ ] **保留 Nacos 现有能力,但迁移到 provider 模型** - - 先做适配层,不重写所有业务。 - - 前端仍可通过 `src/api/nacos.ts` 兼容过渡,新增通用 `src/api/configCenter.ts`。 - -### 1.2 前端模块边界 - -- [ ] `ConfigBrowser` 保持负责浏览和编辑单个配置。 -- [ ] `DiffView` 保持负责两个来源或批量 dataId 的文本级/key 级 diff。 -- [ ] 新增 `AuditView`,负责多环境一致性矩阵、过滤、忽略、导出。 -- [ ] 新增 `BackupView`,负责云端/本地/WebDAV 备份恢复。 -- [ ] 新增 `SettingsView`,集中管理代理、安全确认、名称归一化、忽略规则。 -- [ ] 顶层导航从“顶部双模式按钮”升级为“左侧主导航 + 顶部上下文工具栏”。 - - 左侧主导航承载:浏览、对比、审计、备份、任务、设置。 - - 顶部工具栏只展示当前页面相关的连接、命名空间、筛选、刷新等操作。 - - 保持深色、紧凑、工程工具风格,不做营销式大卡片布局。 - -### 1.3 UI/交互架构方向 - -- [ ] **浏览类页面采用双栏结构** - - 左侧:连接、命名空间、配置列表、搜索。 - - 右侧:内容、历史、编辑、diff。 -- [ ] **治理类页面采用矩阵结构** - - 顶部:审计范围、基准环境、过滤条件。 - - 中部:状态摘要与矩阵表格。 - - 右侧或底部:选中行详情、diff、忽略原因。 -- [ ] **设置类页面采用分组表单** +- [ ] Nacos 适配层。 + - 保留现有 Nacos 行为,不重写业务。 + - 先把 `internal/nacos.Client` 包进 provider 适配器。 + - Wails `App` 只保留薄绑定和参数转发。 +- [ ] 前端 API 边界。 + - 保留 `src/api/nacos.ts` 兼容过渡。 + - 新增通用 `src/api/configCenter.ts`。 + - 页面逐步从 Nacos 专属 API 迁移到通用 API。 +- [ ] 自动化测试。 + - provider 抽象单元测试。 + - Nacos 适配器测试。 + - App Wails 绑定薄测试:参数透传、错误返回、provider 分发。 + +#### P1 + +- [ ] SettingsView。 - 基础信息。 - 认证。 - 网络。 - 安全。 - 高级。 -- [ ] **长任务采用任务中心** - - 批量审计、备份、恢复、导出、WebDAV 上传下载都进入任务中心。 - - 展示进度、成功数、失败数、跳过数、可取消状态和错误详情。 -- [ ] **危险操作采用统一确认组件** - - 普通确认。 - - 高风险二次确认。 - - 生产环境输入指定文字确认。 - - dry-run 预览后确认执行。 -- [ ] **状态色体系统一** - - 一致。 - - 部分一致。 - - 不一致。 - - 缺失。 - - 无法解析。 - - 已忽略。 - - 危险环境。 - -### 1.4 Go 与前端职责取舍 - -- [ ] **Go 后端负责** - - OpenAPI 调用、SSH 隧道、代理、文件读写、WebDAV、敏感信息处理。 - - 大批量拉取时的并发控制、取消、超时、错误聚合。 -- [ ] **前端负责** - - 交互状态、表格过滤、diff 展示、用户确认流程。 - - 小规模解析和展示可以留在前端。 -- [ ] **共享算法优先放 TypeScript** - - 配置格式识别、key/value 归一化、矩阵计算先放前端,迭代快。 - - 如果后续出现大数据性能瓶颈,再迁移到 Go。 +- [ ] 全局代理进入 SettingsView。 + - HTTP proxy。 + - HTTPS proxy。 + - no_proxy。 + - Nacos HTTP client 支持读取全局代理。 + - 每个连接允许覆盖全局代理。 + - UI 标识当前连接是否走代理。 +- [ ] 连接管理升级为 provider 感知表单。 + - provider 类型选择。 + - 根据 provider 切换字段。 + - 基础信息、认证、网络、安全、高级分组。 + - 连接列表显示 provider、环境标签、安全等级、SSH/代理状态。 +- [ ] SSH 隧道状态完善。 + - 显示已连接、本地端口、失败原因。 + - 支持手动重连和断开。 + - 连接删除或修改时自动关闭旧隧道。 + +#### P2 + +- [ ] 主布局骨架。 + - 从顶部双模式切换升级为左侧主导航。 + - 左侧主导航承载:浏览、对比、审计、备份、任务、设置。 + - 顶部栏只展示当前页面相关的上下文操作。 +- [ ] 工程质量。 + - 配置 ESLint。 + - 配置 Prettier。 + - TypeScript strict 模式评估并逐步开启。 + - 移除未使用代码。 --- -## 2. 高优先级:审计矩阵与批量治理 +### 2.3 `v1.2.0`: Nacos 只读审计矩阵 -这是最应该从 `ConfigCenterComparer` 吸收的核心能力。目标是让 ConfScope 从“打开两个配置看 diff”升级为“批量发现环境差异”。 +**版本性质**: MINOR +**目标**: 先在 Nacos 上跑通参考项目最有价值的能力:多环境一致性矩阵。该版本只做只读审计,不做环境写入。 -### 2.1 配置内容标准化 +#### P0 -- [x] **实现 `normalizeConfig(content, format)`** - - YAML:使用现有 `yaml` 依赖解析,输出点路径 key。 - - JSON:递归展开对象;数组默认作为整体值,后续再考虑数组下标策略。 +- [x] 配置内容标准化纯函数。 + - YAML:使用 `yaml` 依赖解析,输出点路径 key。 + - JSON:递归展开对象。 - Properties:按 `key=value` / `key: value` 解析。 - - TOML:可先按文本降级,后续再引入解析。 - - XML/TEXT:暂不拆 key,只保留整文档 hash 和文本 diff。 -- [x] **输出统一 `ConfigEntry`** + - TOML/XML/TEXT:可先降级为整文档 hash 和文本 diff。 +- [x] 统一 `ConfigEntry` 输出。 - `key` - `value` - `valueType` - `sourcePath` - `parseStatus` - `parseError` -- [x] **补测试** - - YAML 嵌套对象。 - - JSON 嵌套对象。 - - Properties 注释与空行。 - - 解析失败降级。 - -### 2.2 多环境一致性矩阵 - -- [ ] **新增 AuditView** - - 选择多个连接作为环境。 +- [x] 矩阵计算纯函数。 + - 主键:`normalizedName + namespace + group + dataId + key`。 + - 列:每个环境的 value、更新时间、存在状态。 + - 状态:一致、部分一致、不一致、缺失、无法解析、已忽略。 +- [ ] 新增 AuditView。 + - 选择多个 Nacos 连接作为环境。 - 每个环境可选择 namespace、group、dataId 过滤条件。 - 支持同名 dataId 批量匹配。 - 支持按基准环境比较。 -- [ ] **AuditView UI 布局** +- [ ] AuditView UI。 - 顶部条件栏:环境选择、namespace/group/dataId 范围、基准环境、比较模式。 - 状态摘要条:总数、一致、不一致、缺失、无法解析、已忽略。 - 主矩阵表格:`dataId / key / 状态 / 各环境值 / 更新时间`。 - 详情面板:展示当前行在各环境的原始值、差异、来源链接。 -- [x] **生成矩阵行** - - 主键:`normalizedName + namespace + group + dataId + key` - - 列:每个环境的 value、更新时间、存在状态。 - - 状态:`一致 / 部分一致 / 不一致 / 缺失 / 无法解析 / 已忽略` -- [ ] **矩阵结果接入 UI** - - 当前已完成 `buildAuditMatrix` 纯函数,AuditView 还未接入。 - - UI 接入时复用现有状态枚举,不在组件里重复写判断逻辑。 - - 状态摘要、过滤、导出都应基于同一份矩阵结果派生。 -- [ ] **过滤与排序** - - 仅看不一致。 - - 仅看缺失。 - - 仅看无法解析。 - - 隐藏已忽略。 - - 按 dataId、key、状态、环境排序。 -- [ ] **矩阵行跳转** - - 点击某行可打开对应 dataId 的详细 diff。 - - 可从矩阵直接复制 key/value。 -- [ ] **矩阵大数据交互** - - 使用虚拟滚动,避免上万行卡顿。 - - 支持列冻结:固定 `dataId/key/status`。 - - 支持列显隐:环境列、更新时间列、原始名称列。 - - 支持保存过滤条件。 - -### 2.3 忽略规则 - -- [ ] **新增本地忽略规则存储** - - 初期存 localStorage。 - - 后续迁移到 Wails 应用数据目录,便于备份和跨设备同步。 -- [ ] **规则字段** - - providerType - - namespace pattern - - group pattern - - dataId pattern - - key pattern - - reason - - createdAt -- [ ] **匹配策略** - - 精确匹配优先。 - - 支持 `*` 通配。 - - 暂不支持复杂正则,避免误伤。 -- [x] **忽略规则纯函数匹配** - - 已支持 namespace、group、dataId、key、providerType 的 `*` 通配匹配。 - - 已支持命中后把矩阵行状态标记为 `ignored` 并保留 reason。 -- [ ] **UI 行为** - - 矩阵行右键加入忽略。 +- [ ] 忽略规则 UI。 + - 矩阵行加入忽略。 - 忽略规则管理列表。 - 可临时显示已忽略项。 - -### 2.4 名称归一化 - -- [ ] **新增对比前名称处理规则** +- [ ] 名称归一化 UI。 - 前缀裁剪。 - 后缀裁剪。 - 精确替换。 - 大小写是否敏感。 -- [ ] **应用范围** - - dataId。 - - namespace 展示名。 - - Apollo appId / cluster / namespace。 - - 本地备份文件路径。 -- [ ] **配置方式** - - 按连接组保存。 - - 在 AuditView 里可临时启用/禁用。 -- [ ] **安全约束** - 默认不自动归一化,用户显式开启。 - - 归一化后需要展示原始名称,避免误判。 -- [x] **矩阵计算支持名称归一化回调** - - 已支持对 dataId 做归一化后再分组。 - - 已保留各环境的 originalDataIds,供 UI 展示原始名称。 +- [ ] 测试。 + - AuditView 过滤状态。 + - 行选择。 + - 忽略规则。 + - 名称归一化。 + - 跳转 diff。 -### 2.5 重复配置检查 +#### P1 -- [ ] **同环境重复检查** - - 在同一个环境内,按 `key + normalizedValue` 分组。 +- [ ] 审计结果导出。 + - CSV:适合发给团队审阅。 + - JSON:适合后续导入或自动化处理。 + - 敏感字段默认剔除。 +- [ ] 过滤与排序。 + - 仅看不一致。 + - 仅看缺失。 + - 仅看无法解析。 + - 隐藏已忽略。 + - 按 dataId、key、状态、环境排序。 +- [ ] 矩阵行跳转。 + - 点击某行可打开对应 dataId 的详细 diff。 + - 可从矩阵直接复制 key/value。 +- [ ] DiffView 与 AuditView 职责拆分。 + - DiffView 保持深度对比定位。 + - 批量治理入口迁移到 AuditView。 + +#### P2 + +- [ ] 矩阵大数据交互。 + - 虚拟滚动。 + - 固定 `dataId/key/status` 列。 + - 环境列、更新时间列、原始名称列可显隐。 + - 保存过滤条件。 +- [ ] 重复配置检查第一版。 + - 同环境按 `key + normalizedValue` 分组。 - 只展示出现次数 >= 2 的项。 - - 过滤空值、明显默认值可配置。 -- [ ] **跨环境重复检查** - - 同一 key/value 是否在多个环境重复出现。 - - 用于发现“环境差异本应存在但被复制一致”的情况。 -- [ ] **输出** - - 重复 key。 - - 重复 value。 - - 出现在哪些 dataId / namespace / group。 - 可导出 CSV。 +- [ ] Playwright Web E2E。 + - 使用 mock Wails API。 + - 覆盖浏览、diff、审计主流程。 --- -## 3. 高优先级:连接、安全与运行环境 - -这些能力直接影响生产可用性,优先级高于新增更多配置中心。 - -### 3.1 应用检查更新 - -第一版已完成手动检查、更新提示、下载入口、国内加速线路回退和检查更新代理配置。剩余工作集中在自动后台检查、忽略版本、版本注入和后续内置下载校验。 - -- [x] **统一当前版本来源** - - `package.json` 已有 `version`,但 Go/Wails 运行时还没有统一暴露应用版本。 - - 增加 Go 侧 `AppVersion` 或 `GetAppInfo` 绑定,前端 About/设置页统一读取。 - - [ ] 后续打包时考虑用 `-ldflags` 注入版本,避免手工改多处。 -- [x] **定义更新源** - - 第一版优先支持一个 release manifest JSON,字段包括 `version`、`notes`、`downloadUrl`、`publishedAt`、`sha256`、`mandatory`。 - - 如果后续托管在 GitHub Releases,可用 GitHub API 或把 manifest 放在 release asset 中。 - - 更新源 URL 先做常量或配置项,不把业务逻辑绑死到某个平台。 -- [x] **Go 后端实现检查更新** - - `CheckForUpdates(currentVersion)`:请求更新源、超时控制、解析 manifest、比较 semver。 - - 返回统一结果:`currentVersion`、`latestVersion`、`hasUpdate`、`downloadUrl`、`releaseNotes`、`mandatory`、`error`。 - - 网络失败不影响应用启动,只返回可展示错误。 - - 不在第一版做自动下载、自动安装和静默替换。 -- [x] **前端 UI/交互** - - About 或 Settings 中增加“检查更新”入口。 - - 展示当前版本、最新版本、检查中、已是最新、检查失败、有新版本。 - - 有新版本时展示 release notes 摘要和“打开下载页”按钮。 - - [ ] 支持启动后低频后台检查,但默认不弹强打断弹框;只在有更新时给顶部提示或 About 红点。 -- [ ] **本地状态** - - [x] 记录检查更新使用的全局代理配置。 - - [ ] 记录 `lastUpdateCheckAt`、`lastSeenVersion`、`skipVersion`。 - - 用户可选择“忽略此版本”,避免每次启动重复提示。 - - 强制更新 `mandatory=true` 时不允许忽略,但仍不自动安装。 -- [x] **安全与校验** - - 下载链接必须使用 HTTPS。 - - manifest 支持 sha256 字段;第一版只展示校验值,后续若做内置下载再强制校验。 - - release notes 只按纯文本/Markdown 安全渲染,不执行 HTML。 -- [x] **自动化测试** - - Go 使用 `httptest.Server` 覆盖:有更新、无更新、manifest 不合法、网络失败、版本比较。 - - TypeScript 覆盖更新状态派生和 UI 展示。 - - 如果新增 About/Settings UI,补 React Testing Library 组件测试。 - -### 3.2 SSH 隧道 - -- [x] 已有基础 SSH 隧道能力。 -- [ ] 补齐连接管理 UI 的状态展示:已连接、本地端口、失败原因。 -- [ ] SSH 配置在连接管理中独立成“网络”分组。 -- [ ] SSH 状态在连接列表与顶部上下文栏显示。 -- [ ] 支持手动重连和断开。 -- [ ] 连接删除或修改时自动关闭旧隧道。 -- [ ] 批量审计时复用隧道,避免重复创建。 -- [ ] 补 Go 单元测试或集成测试边界:端口占用、认证失败、StopAll。 - -### 3.3 全局代理 - -- [ ] 新增全局代理设置。 - - HTTP proxy。 - - HTTPS proxy。 - - no_proxy。 -- [x] 检查更新入口已支持录入并持久化全局代理配置。 -- [x] 检查更新 Go HTTP client 支持读取全局代理配置。 -- [ ] Nacos / Apollo / WebDAV 等配置中心 HTTP client 支持读取代理配置。 -- [ ] 每个连接允许覆盖全局代理。 -- [ ] UI 标识当前连接是否走代理。 -- [ ] 代理配置放入 SettingsView 的“网络”分组。 - -### 3.4 生产/沙箱安全确认 - -- [ ] 连接增加安全等级。 - - 普通。 - - 测试。 - - 预发。 - - 生产。 - - 沙箱。 -- [ ] 高风险操作二次确认。 - - 发布配置。 - - 删除配置。 - - 回滚配置。 - - 环境间配置应用。 - - 批量操作。 -- [ ] 生产环境要求人工输入确认文本。 - - 例如输入 `确认发布到生产`。 - - 确认文案可在连接配置中设置。 -- [ ] UI 中对生产/沙箱连接做持续可见标识。 - - 顶部连接选择器显示安全等级。 - - 发布、删除、恢复、应用到环境按钮使用危险态样式。 - - 批量操作预览页标出将影响的生产项数量。 -- [ ] 沙箱环境策略。 - - 可配置为禁止发布。 - - 可配置为发布后不立即生效,如果目标配置中心支持该语义。 - - Nacos 不天然支持“发布但不生效”,需要在 UI 上明确限制,不假装支持。 -- [ ] 环境晋级策略。 - - 真实环境可配置为必须先经过指定沙箱环境。 - - 真实环境应用必须引用已执行成功的沙箱计划。 - - 如果源环境或目标环境在计划生成后发生变化,需要提示重新生成计划。 - -### 3.5 敏感信息处理 - -- [ ] 当前连接密码存在 localStorage,需规划迁移。 -- [ ] Go 侧优先使用系统 keychain/credential store;做不到时至少应用数据目录加密存储。 -- [ ] 导出配置时默认不导出密码、token、私钥。 -- [ ] 日志中屏蔽 password、accessKey、secretKey、token。 - ---- - -## 4. 高优先级:导入、导出、备份、恢复 +### 2.4 `v1.3.0`: 导出、备份与本地快照 -这是用户已有备注里的重点,也能承接审计矩阵输出。 +**版本性质**: MINOR +**目标**: 给审计结果和配置内容提供可落地的保存、导出、快照和本地对比能力。 -### 4.1 导出 +#### P0 - [ ] 单配置导出。 - 原始内容。 - 元信息 JSON。 -- [ ] 审计矩阵导出。 - - CSV:适合发给团队审阅。 - - JSON:适合后续导入或自动化处理。 - [ ] 差异导出。 - 文本 diff。 - key/value 差异 JSON。 -- [ ] 重复配置检查结果导出。 - -### 4.2 本地备份模式 - -- [ ] 定义本地备份目录结构。 +- [ ] 审计矩阵导出增强。 + - CSV 转义。 + - JSON 字段完整性。 + - 敏感字段默认剔除。 +- [ ] 本地备份目录结构。 - provider。 - connection name。 - namespace。 - group。 - dataId。 - metadata.json。 -- [ ] 支持从云端拉取一次快照保存到本地。 -- [ ] 支持本地快照作为一个 `local` provider 参与浏览和对比。 -- [ ] 支持同环境多配置位置对比。 - - 云端 vs 本地备份。 - - 本地备份 A vs 本地备份 B。 - - 同一连接不同 namespace/group。 -- [ ] 新增 BackupView。 +- [ ] 云端到本地快照。 + - 支持从 Nacos 拉取一次快照保存到本地。 + - 快照保存原始内容和元信息。 +- [ ] local provider。 + - 本地快照作为 `local` provider 参与浏览和对比。 + - 支持云端 vs 本地备份。 + - 支持本地备份 A vs 本地备份 B。 +- [ ] BackupView。 - 快照列表。 - 快照详情。 - - 云端/本地/WebDAV 来源标识。 + - 云端/本地来源标识。 - 快照与当前云端的差异入口。 -### 4.3 WebDAV 备份与恢复 +#### P1 -- [ ] 新增 WebDAV 连接配置。 - - URL。 - - 用户名。 - - 密码/token。 - - 根目录。 -- [ ] 支持上传本地备份到 WebDAV。 -- [ ] 支持从 WebDAV 下载备份到本地。 -- [ ] 恢复前必须预览差异。 -- [ ] WebDAV 上传、下载、恢复进入任务中心。 -- [ ] 恢复到生产环境必须走生产确认流程。 +- [ ] 任务中心基础能力。 + - 批量导出、备份进入任务中心。 + - 展示任务名称、范围、进度、状态、开始时间、耗时。 + - 失败任务支持展开错误详情和复制错误。 + - 可取消的任务显示取消按钮。 +- [ ] 导出安全策略。 + - 默认不导出密码、token、私钥。 + - 日志中屏蔽 password、accessKey、secretKey、token。 +- [ ] 备份测试。 + - 本地目录结构生成。 + - 快照元信息完整性。 + - local provider 浏览与 diff。 + +#### P2 + +- [ ] 备份压缩包格式。 +- [ ] 备份导入向导。 +- [ ] Git 集成评估。 -### 4.4 导入/恢复策略 +--- -- [ ] 单配置恢复。 -- [ ] 批量恢复。 -- [ ] 只新增缺失项。 -- [ ] 覆盖已有项。 -- [ ] 跳过已存在项。 -- [ ] dry-run 模式:只生成计划,不执行发布。 +### 2.5 `v1.4.0`: 环境应用、操作历史与回退 -### 4.5 环境间配置应用/晋级 +**版本性质**: MINOR +**目标**: 闭环“发现差异 -> 应用到沙箱 -> 验证 -> 晋级真实环境 -> 可回退”。这是写入能力版本,安全门槛最高。 -这是审计矩阵之后必须补齐的闭环能力:用户发现 A 环境与 B 环境差异后,可以把 A 环境的配置应用到 B 沙箱环境,验证通过后再应用到真实环境。它不应被当成普通“复制粘贴发布”,必须带计划、确认、历史和回退点。 +#### P0 -- [ ] **应用入口** +- [ ] ApplyPlan 领域模型。 + - 源环境。 + - 目标环境。 + - provider、connection、namespace、group、dataId、key。 + - 源值、目标当前值、应用后值。 + - 新增、覆盖、删除、跳过、解析失败状态。 +- [ ] 应用入口。 - 从 AuditView 的差异行发起。 - 从 DiffView 的单配置差异发起。 - 从 BackupView 的快照差异发起。 -- [ ] **应用范围** - - 单 key 应用:只同步某个拆解后的 key/value。 - - 单配置应用:同步整个 dataId 内容。 - - 批量应用:同步筛选后的多条差异。 -- [ ] **目标环境策略** - - 支持 `源环境 -> 沙箱环境 -> 真实环境` 的两阶段晋级。 - - 沙箱应用完成后生成验证状态,不自动继续推真实环境。 - - 真实环境应用必须基于沙箱验证过的计划版本,避免中途源配置变化导致误发。 -- [ ] **dry-run 应用计划** - - 展示源值、目标当前值、应用后值。 - - 展示新增、覆盖、删除、跳过、解析失败项。 - - 展示影响的 provider、connection、namespace、group、dataId、key。 - - 计划生成后保存计划快照,确认执行时使用计划快照而不是重新隐式计算。 -- [ ] **确认机制** - - 沙箱环境至少需要普通确认。 - - 生产/真实环境需要输入确认文本。 - - 批量应用需要先展示影响数量和失败风险。 +- [ ] 应用范围。 + - 单 key 应用。 + - 单配置应用。 + - 批量应用。 +- [ ] dry-run 应用计划。 - 禁止跳过 dry-run 直接写入。 -- [ ] **执行与任务中心** - - 应用操作进入任务中心。 - - 展示每项执行状态:待执行、成功、失败、跳过、已回退。 - - 支持失败后继续/停止策略。 - - 支持复制执行报告。 -- [ ] **写入前备份** + - 计划生成后保存计划快照。 + - 确认执行时使用计划快照,而不是重新隐式计算。 + - 如果源或目标在计划后变化,要求重新生成计划。 +- [ ] 沙箱到真实环境晋级。 + - 支持 `源环境 -> 沙箱环境 -> 真实环境`。 + - 沙箱应用完成后生成验证状态,不自动继续推真实环境。 + - 真实环境应用必须基于沙箱验证过的计划版本。 +- [ ] 写入前备份。 - 每次应用前保存目标环境原始内容快照。 - - 快照与操作历史绑定,作为回退来源。 + - 快照与操作历史绑定。 - 如果备份失败,默认禁止继续写入。 -- [ ] **操作历史** - - 新增 `OperationHistory` 领域模型。 - - 记录操作类型:apply、promote、restore、rollback、delete、publish。 - - 记录操作者、本机时间、源环境、目标环境、计划摘要、执行结果、错误详情。 - - 记录目标写入前内容、写入后内容、对应配置中心历史版本。 - - 历史记录默认保存在 Wails 应用数据目录,后续可导出 JSON。 -- [ ] **回退操作** +- [ ] OperationHistory 领域模型。 + - 操作类型:apply、promote、restore、rollback、delete、publish。 + - 操作者、本机时间、源环境、目标环境、计划摘要、执行结果、错误详情。 + - 目标写入前内容、写入后内容、对应配置中心历史版本。 + - 默认保存在 Wails 应用数据目录。 +- [ ] 回退操作。 - 基于写入前备份生成 rollback dry-run。 - - 回退也必须进入任务中心和操作历史。 + - 回退进入任务中心和操作历史。 - 生产回退同样需要生产确认文本。 - - 支持从操作历史详情页一键发起回退计划。 -- [ ] **UI 页面** - - 新增 ApplyPlanView 或在任务中心中提供计划详情页。 - - OperationHistoryView 展示操作列表、筛选、详情、回退入口。 - - AuditView 行操作提供“应用到沙箱”“创建晋级计划”。 +- [ ] 安全确认。 + - 沙箱环境至少需要普通确认。 + - 生产/真实环境需要输入确认文本。 + - 批量应用先展示影响数量和失败风险。 +- [ ] 测试。 + - 应用计划生成。 + - 写入前备份失败禁止执行。 + - 计划后源/目标变化要求重新生成。 + - 回退计划使用 before 快照。 ---- +#### P1 -## 5. 中优先级:Apollo 配置中心 +- [ ] ApplyPlanView。 + - 展示计划摘要。 + - 展示每项 diff。 + - 展示确认状态。 +- [ ] OperationHistoryView。 + - 操作列表、筛选、详情、回退入口。 + - 支持复制执行报告。 +- [ ] 执行与任务中心。 + - 每项执行状态:待执行、成功、失败、跳过、已回退。 + - 支持失败后继续/停止策略。 + - 支持复制执行报告。 +- [ ] Playwright 主流程。 + - 差异 -> 沙箱应用 -> 真实环境晋级 -> 历史回退。 -当前 ConfScope 还不支持 Apollo。参考项目支持 Apollo,但它走数据库;ConfScope 应优先走 Apollo OpenAPI。 +#### P2 -### 5.1 是否做 +- [ ] 环境晋级策略模板。 + - 真实环境必须先经过指定沙箱环境。 + - 沙箱环境可配置为禁止发布。 + - Nacos 不天然支持“发布但不生效”,UI 上明确限制,不假装支持。 +- [ ] 操作历史导出 JSON。 -- [ ] **做 Apollo OpenAPI 适配**:符合 ConfScope 多配置中心定位。 -- [ ] **暂不做 Apollo 数据库直连**:除非 OpenAPI 无法满足历史或权限场景。 -- [ ] **暂不追求 Apollo 全量管理后台能力**:先支持浏览、获取、diff、审计、导出。 +--- -### 5.2 Apollo 概念映射 +### 2.6 `v1.5.0`: Apollo 只读第一阶段 -- [ ] Nacos `namespace` 对应 Apollo 的 `appId + cluster + namespaceName` 组合。 -- [ ] Nacos `group` 在 Apollo 中没有直接等价物,可显示为 cluster 或固定为空。 -- [ ] Nacos `dataId` 可映射为 Apollo namespaceName 或单个配置集合。 -- [ ] Apollo item 的 `key/value` 天然适合审计矩阵。 +**版本性质**: MINOR +**目标**: 支持 Apollo OpenAPI 的连接、浏览、读取、diff、审计。第一阶段只做只读能力,不做发布/回滚。 -### 5.3 第一阶段能力 +#### P0 -- [ ] Apollo 连接管理。 +- [ ] Apollo OpenAPI 适配。 - Portal/OpenAPI 地址。 - token。 - appId。 - cluster。 - - namespace。 -- [ ] Apollo 连接 UI 使用 provider 专属表单。 + - namespaceName。 +- [ ] Apollo provider 专属连接表单。 - 不强行展示 Nacos 的 group/dataId 字段。 - - 表单中明确 Apollo 的 appId、cluster、namespaceName。 - - 权限不足时提供手动输入 appId/cluster/namespaceName 的模式。 -- [ ] Apollo 配置浏览。 + - 表单明确 Apollo 的 appId、cluster、namespaceName。 + - 权限不足时允许手动输入 appId/cluster/namespaceName。 +- [ ] Apollo 概念映射。 + - Nacos `namespace` 对应 Apollo 的 `appId + cluster + namespaceName` 组合。 + - Nacos `group` 在 Apollo 中没有直接等价物,可显示为 cluster 或固定为空。 + - Nacos `dataId` 可映射为 namespaceName 或单个配置集合。 + - Apollo item 的 `key/value` 天然适合审计矩阵。 +- [ ] Apollo 浏览。 - App 列表如果 API 权限允许则自动获取。 - 权限不足时允许手动输入 appId。 -- [ ] Apollo 浏览 UI 使用 Apollo 概念命名。 - - App。 - - Cluster。 - - Namespace。 - - Item key/value。 -- [ ] Apollo namespace 配置项列表。 -- [ ] Apollo 配置详情。 -- [ ] Apollo 与 Nacos、本地备份参与统一 diff 和 AuditView。 -- [ ] AuditView 中统一展示通用列,同时保留 provider 原始字段。 + - Apollo namespace 配置项列表。 + - Apollo 配置详情。 +- [ ] Apollo 参与统一 diff 和 AuditView。 - 通用列:环境、namespace、group、dataId、key、value。 - 原始字段:Apollo appId/cluster/namespaceName,Nacos tenant/group/dataId。 +- [ ] Apollo Go client 测试。 + - 使用 `httptest.Server` 覆盖连接、浏览、读取、错误处理。 -### 5.4 第二阶段能力 +#### P1 -- [ ] Apollo 历史版本。 -- [ ] Apollo 发布/回滚。 -- [ ] Apollo 灰度配置支持。 -- [ ] Apollo namespace 创建/删除。 -- [ ] Apollo 配置导入导出。 +- [ ] Apollo 审计导出。 +- [ ] Apollo 错误提示。 + - 网络失败。 + - token 失效。 + - 权限不足。 + - app/cluster/namespace 不存在。 +- [ ] Apollo 样例 fixture。 + - app。 + - cluster。 + - namespace。 + - item。 -### 5.5 风险 +#### P2 -- [ ] Apollo OpenAPI 权限和部署差异较大,需要清晰错误提示。 -- [ ] Apollo 概念与 Nacos 不一致,UI 不能强行套 `group/dataId`。 -- [ ] 发布 Apollo 配置通常涉及修改 item + release,应单独设计确认流程。 +- [ ] Apollo 历史版本评估。 +- [ ] Apollo 发布/回滚设计,不在本版本执行。 +- [ ] Apollo 灰度配置支持评估。 --- -## 6. 中优先级:Consul 与其他配置中心 +### 2.7 `v1.6.0`: WebDAV 与远端备份恢复 -### 6.1 Consul +**版本性质**: MINOR +**目标**: 把本地备份扩展到远端存储,并提供恢复预览和 dry-run。 -- [ ] 支持 Consul KV 浏览。 -- [ ] 支持 token 认证。 -- [ ] 支持 datacenter 选择。 -- [ ] 支持 key prefix 过滤。 -- [ ] 支持 Consul KV 与 Nacos/Apollo/local 的 diff。 -- [ ] 暂不优先做 Consul session、watch、service discovery 管理。 +#### P0 -### 6.2 后续候选 +- [ ] WebDAV 连接配置。 + - URL。 + - 用户名。 + - 密码/token。 + - 根目录。 +- [ ] WebDAV 客户端。 + - 上传本地备份。 + - 下载远端备份。 + - 错误分类:网络、认证、权限、路径不存在、写入失败。 +- [ ] WebDAV 任务接入任务中心。 + - 上传。 + - 下载。 + - 恢复预览。 +- [ ] 恢复策略。 + - 单配置恢复。 + - 批量恢复。 + - 只新增缺失项。 + - 覆盖已有项。 + - 跳过已存在项。 +- [ ] 恢复 dry-run。 + - 恢复前必须预览差异。 + - 恢复到生产环境必须走生产确认流程。 + - 禁止跳过 dry-run 直接恢复。 +- [ ] WebDAV 测试。 + - 客户端单元测试。 + - 恢复计划测试。 + - 任务失败聚合测试。 + +#### P1 + +- [ ] WebDAV 凭据安全存储。 +- [ ] 远端备份列表缓存。 +- [ ] Playwright 覆盖备份、恢复预览、dry-run 禁止误执行。 + +#### P2 + +- [ ] 定时备份评估。 +- [ ] 多远端备份目标评估。 + +--- + +### 2.8 `v2.0.0`: 多配置中心治理平台化 + +**版本性质**: MAJOR +**目标**: Provider 抽象稳定后,将 ConfScope 从 Nacos 优先的桌面工具升级为多配置中心治理工具。 + +#### P0 + +- [ ] 多 provider 聚合视图。 + - Nacos。 + - Apollo。 + - local。 + - WebDAV 备份来源。 +- [ ] 跨配置中心审计矩阵。 +- [ ] 跨配置中心环境应用策略。 +- [ ] 本地数据结构迁移策略。 + - 连接配置。 + - 代理配置。 + - 操作历史。 + - 忽略规则。 + - 备份索引。 +- [ ] 安全模型升级。 + - 凭据迁移。 + - 操作历史脱敏。 + - 导出默认脱敏。 + +#### P1 + +- [ ] Consul KV。 + - KV 浏览。 + - token 认证。 + - datacenter 选择。 + - key prefix 过滤。 + - 与 Nacos/Apollo/local 的 diff 和 AuditView。 +- [ ] 阿里云 MSE / Nacos 企业场景。 + - AccessKey 模式。 + - 认证类型:username/password、accessKey/secretKey、token。 + - Go 后端封装签名逻辑,前端不直接处理 secret。 + - 连接测试区分认证失败、网络失败、权限不足。 +- [ ] 配置治理报告。 + - 摘要。 + - 异常趋势。 + - 重复配置。 + - 导出。 + +#### P2 - [ ] Etcd。 - [ ] ZooKeeper。 - [ ] Kubernetes ConfigMap/Secret。 - [ ] Spring Cloud Config。 - -### 6.3 取舍 - -- [ ] 只有当 provider 抽象稳定后再新增第三个配置中心。 -- [ ] 每个 provider 先满足浏览、读取、diff、审计,再考虑写入。 -- [ ] 写入能力必须接入安全确认机制。 +- [ ] Git 集成。 +- [ ] Linux ARM64、Windows ARM64、macOS ARM64。 --- -## 7. 中优先级:阿里云 MSE / Nacos 企业场景 - -- [ ] 支持阿里云 MSE Nacos AccessKey 模式。 -- [ ] 连接配置增加认证类型。 - - username/password。 - - accessKey/secretKey。 - - token。 -- [ ] Go 后端封装签名逻辑,前端不直接处理 secret。 -- [ ] 日志脱敏。 -- [ ] 连接测试需要区分认证失败、网络失败、权限不足。 -- [ ] 文档中说明 MSE 与自建 Nacos 的能力差异。 +## 3. 能力池与取舍清单 ---- +本节只记录方向和边界,不作为执行状态来源。实际执行状态以第 2 节版本路线图为准。 -## 8. 中优先级:UI/UX 与大数据体验 - -### 8.0 主布局演进 - -- [ ] 从顶部双模式切换升级为左侧主导航。 -- [ ] 主导航支持折叠,只显示图标时仍有 tooltip。 -- [ ] 顶部栏改为当前工作区上下文操作区。 -- [ ] 保留当前 VSCode Dark 风格,但统一按钮、表格、状态标签、危险态样式。 -- [ ] 不使用卡片堆叠承载核心工作流,优先使用分栏、表格、工具栏和详情面板。 - -### 8.1 表格体验 - -- [ ] AuditView 使用虚拟列表或表格虚拟滚动。 -- [ ] 支持列显示/隐藏。 -- [ ] 支持列宽调整。 -- [ ] 支持复制单元格、复制整行、复制异常摘要。 -- [ ] 支持保存过滤条件。 -- [ ] 状态列使用固定视觉语言。 - - 一致:绿色。 - - 部分一致:黄色。 - - 不一致:红色。 - - 缺失:橙色或红色描边。 - - 无法解析:灰色加错误标识。 - - 已忽略:低对比度。 - -### 8.1.1 连接管理体验 - -- [ ] 连接管理从单页长表单升级为分组表单或标签页。 -- [ ] 支持 provider 类型选择。 -- [ ] 根据 provider 类型切换字段。 -- [ ] 基础信息、认证、网络、安全、高级分区。 -- [ ] 连接列表显示 provider、环境标签、安全等级、SSH/代理状态。 -- [ ] 测试连接结果展示更细:网络可达、认证成功、权限范围、版本信息。 - -### 8.2 搜索 - -- [ ] 配置列表 dataId 搜索已支持,继续优化。 -- [ ] 支持配置内容全文搜索。 -- [ ] 支持 key 搜索。 -- [ ] 支持 value 搜索。 -- [ ] 支持跨连接搜索。 - -### 8.3 Diff - -- [ ] 当前行级 diff 保留。 -- [ ] DiffView 保持深度对比定位,不继续承接审计矩阵职责。 -- [ ] key/value diff 与审计矩阵打通。 -- [ ] 支持忽略空白、忽略顺序、忽略注释。 -- [ ] 大文件 diff 增加性能保护:超过阈值提示用户选择模式。 -- [ ] 批量 diff 结果增加汇总区。 - - 总数。 - - 一致数量。 - - 有差异数量。 - - 失败数量。 - - 可快速跳到下一个差异项。 - -### 8.3.1 任务中心 - -- [ ] 新增任务中心入口。 -- [ ] 批量审计、批量导出、备份、恢复、WebDAV 上传下载统一进入任务中心。 -- [ ] 每个任务展示名称、范围、进度、状态、开始时间、耗时。 -- [ ] 失败任务支持展开错误详情和复制错误。 -- [ ] 可取消的任务显示取消按钮。 -- [ ] 任务完成后可跳转到结果页。 - -### 8.3.2 危险操作体验 - -- [ ] 统一危险操作确认弹窗。 -- [ ] 生产环境操作要求输入确认文本。 -- [ ] 批量操作先展示影响范围,再允许执行。 -- [ ] 恢复操作必须先展示 dry-run 差异计划。 -- [ ] 危险操作按钮文案必须具体,例如“发布到生产”“删除 12 个配置”,避免只写“确定”。 - -### 8.4 多语言 - -- [x] 已有多语言基础。 -- [ ] 补齐新增页面文案。 -- [ ] 检查所有硬编码中文/英文。 -- [ ] 语言切换不应重置当前工作区状态。 +### 3.1 参考项目结论 ---- +`ConfigCenterComparer` 值得参考的是产品模型,不是代码实现。它通过数据库读取 Apollo/Nacos 配置,把不同环境的数据整理成统一矩阵,再做一致性判断、忽略、过滤、导出和重复配置检查。 -## 9. 中优先级:日志、错误处理与可观测性 - -- [ ] 增加应用日志查看页面。 -- [ ] 日志页面接入主导航的“任务/日志”区域。 -- [ ] 增加操作历史页面。 - - 展示环境应用、发布、恢复、回退、删除等操作。 - - 可按连接、环境、安全等级、操作类型、状态筛选。 - - 支持从历史详情复制执行报告。 -- [ ] Go 后端统一错误类型。 - - 网络错误。 - - 认证错误。 - - 权限错误。 - - 解析错误。 - - 写入失败。 -- [ ] 批量任务展示进度。 -- [ ] 批量任务支持取消。 -- [ ] 批量任务结束后展示成功、失败、跳过数量。 -- [ ] 错误详情可复制。 -- [ ] 日志默认脱敏。 -- [ ] 操作历史默认脱敏。 +#### 值得吸收 ---- +- 多环境一致性矩阵。 +- key 级配置拆解,而不是只做整文件文本 diff。 +- 忽略列表,减少预期差异带来的噪音。 +- 名称归一化,用于对齐不同环境命名。 +- 重复配置检查。 +- 审计结果导出。 -## 10. 测试与工程质量 - -当前自动化测试已经补上第一层基线:Go 后端覆盖 Nacos client、SSH manager/tunnel 与检查更新,前端已引入 Vitest + React Testing Library,并覆盖 diff、format、keys、validate、normalize、audit、highlight、toast、connection store、检查更新,以及配置浏览、编辑、历史、删除确认、连接管理等核心组件路径。下一阶段推进 Playwright Web E2E;否则 AuditView、BackupView、连接管理升级后仍会依赖大量人工回归。 - -### 10.1 Go 测试 - -- [x] 已有 `internal/nacos/client_test.go`。 -- [x] Nacos client 基础行为测试。 -- [x] SSH manager 测试。 -- [x] SSH tunnel 基础测试。 -- [x] 检查更新测试。 - - 有更新、无更新、manifest 不合法、网络失败、版本比较。 - - 国内加速线路回退。 - - 全局代理与 no_proxy。 -- [ ] provider 抽象加单元测试。 -- [ ] Nacos v1/v3 响应兼容测试。 -- [ ] App Wails 绑定层加薄测试。 - - [x] 应用信息与检查更新默认版本。 - - [ ] 参数透传。 - - [ ] 错误返回。 - - provider 分发。 -- [ ] Nacos / Apollo / WebDAV 代理配置测试。 -- [ ] WebDAV 客户端测试。 -- [ ] Apollo client 使用 `httptest.Server` 模拟 Portal/OpenAPI。 -- [ ] 批量任务调度测试。 - - 并发限制。 - - 取消。 - - 部分失败聚合。 - - 超时。 - -### 10.2 前端测试 - -- [x] 引入 Vitest。 -- [x] 测试 `normalizeConfig`。 -- [x] 测试一致性矩阵状态计算。 -- [x] 测试忽略规则匹配。 -- [x] 测试名称归一化。 -- [x] 测试 diff 统计。 -- [x] 测试格式识别。 -- [x] 测试 key 提取。 -- [x] 测试发布前格式校验。 -- [x] 测试语法高亮兜底与逐行缓存。 -- [x] 测试 toast 发布订阅与自动移除。 -- [x] 测试连接本地存储的读取、新增、更新、删除、SSH 配置保存。 -- [x] 测试检查更新 API 包装和代理配置存储。 -- [ ] 测试导出数据生成。 - - CSV 转义。 - - JSON 字段完整性。 - - 敏感字段默认剔除。 -- [ ] 测试危险操作确认逻辑。 - - 普通环境。 - - 生产环境输入确认文本。 - - dry-run 未完成时禁止执行。 -- [ ] 测试环境应用计划生成。 - - 单 key 应用。 - - 单配置应用。 - - 批量应用。 - - 源/目标在计划后变化时要求重新生成。 -- [ ] 测试操作历史与回退计划。 - - 写入前备份失败禁止执行。 - - 应用成功后记录 before/after。 - - 回退计划使用 before 快照。 +#### 不建议照搬 -### 10.2.1 React 组件测试 - -- [x] 引入 React Testing Library。 -- [x] `ConfigBrowser` 基础测试。 - - 搜索防抖。 - - 打开配置。 - - 编辑格式校验。 - - 发布配置并刷新。 - - 删除确认入口。 -- [ ] `ConfigBrowser` 进阶测试。 - - 翻页。 - - 编辑脏状态拦截。 - - 新建配置成功后自动打开。 - - 列表请求乱序时只采用最新结果。 -- [x] `ConfigEditor` 测试。 - - Data ID 必填。 - - 格式校验失败禁止发布。 - - 发布时裁剪 Data ID 并回退默认 group。 - - 发布失败保留编辑器。 -- [x] `DeleteConfirm` 测试。 - - 精确输入 dataId 才允许删除。 - - Escape / 遮罩取消。 - - 删除失败后恢复按钮状态并展示错误。 -- [x] `HistoryView` 测试。 - - 历史列表加载。 - - 查看版本并拉取上一版做 diff。 - - 勾选版本与当前线上对比。 - - 二次确认后回滚发布。 -- [x] `DiffPanel` 测试。 - - 统计显示。 - - 仅显示变更。 - - 无差异状态。 -- [x] 基础通用组件测试。 - - `Pager`。 - - `Select`。 - - `ConfirmModal`。 - - `CopyButton`。 -- [ ] `ConnectionManager` 进阶测试。 - - provider 切换字段。 - - SSH/代理/安全分组。 - - 测试连接结果展示细节。 -- [x] `ConnectionManager` 基础测试。 - - 空状态。 - - 新增保存。 - - 必填校验。 - - 删除确认。 - - 连接测试成功/失败。 - - SSH 配置保存。 -- [ ] 检查更新组件测试。 - - [x] 当前版本展示。 - - [x] 检查中/已最新/有更新/检查失败状态。 - - [ ] 忽略此版本。 - - [x] 打开下载页。 -- [ ] `AuditView` 测试。 - - 过滤状态。 - - 行选择。 - - 忽略规则。 - - 跳转 diff。 +- 不直接复制 GPL-3.0 代码,ConfScope 是 MIT,只借鉴产品思路并重写实现。 +- 不优先走数据库直连,ConfScope 应优先走配置中心 OpenAPI,保留权限、历史、发布语义。 +- 不固定 4 个环境,ConfScope 应支持任意数量环境,并允许用户指定基准环境。 +- 不只支持 YAML,应支持 YAML、JSON、Properties、TOML,XML/TEXT 可降级处理。 +- 不把比较规则写死,一致性状态、忽略规则、生产环境确认规则都应配置化。 -### 10.2.2 端到端测试 - -- [ ] 引入 Playwright,先覆盖 Web 模式。 - - 只测前端路由、交互、mock API,不直接启动 Wails 桌面壳。 -- [ ] 使用 mock 的 Wails API。 - - 模拟连接列表。 - - 模拟 Nacos namespace/config/history。 - - 模拟错误和超时。 -- [ ] 覆盖核心用户流程。 - - 新增连接。 - - 浏览配置。 - - 查看历史。 - - 两配置 diff。 - - 审计矩阵过滤。 - - 导出审计结果。 - - 从差异创建应用计划。 - - 应用到沙箱后再晋级真实环境。 - - 从操作历史发起回退 dry-run。 -- [ ] 桌面壳冒烟测试单独评估。 - - 构建成功。 - - 应用启动。 - - 首页无白屏。 - - 不把所有业务流程都压到桌面 E2E。 +### 3.2 UI/交互原则 -### 10.2.3 测试数据与 Mock 策略 +- 浏览类页面采用双栏结构。 + - 左侧:连接、命名空间、配置列表、搜索。 + - 右侧:内容、历史、编辑、diff。 +- 治理类页面采用矩阵结构。 + - 顶部:审计范围、基准环境、过滤条件。 + - 中部:状态摘要与矩阵表格。 + - 右侧或底部:选中行详情、diff、忽略原因。 +- 设置类页面采用分组表单。 + - 基础信息。 + - 认证。 + - 网络。 + - 安全。 + - 高级。 +- 长任务进入任务中心。 + - 批量审计、备份、恢复、导出、WebDAV 上传下载。 + - 展示进度、成功数、失败数、跳过数、取消状态和错误详情。 +- 危险操作使用统一确认组件。 + - 普通确认。 + - 高风险二次确认。 + - 生产环境输入指定文字确认。 + - dry-run 预览后确认执行。 +- 保持深色、紧凑、工程工具风格。 + - 不做营销式大卡片布局。 + - 核心工作流优先使用分栏、表格、工具栏和详情面板。 + +### 3.3 Go 与前端职责 + +- Go 后端负责: + - OpenAPI 调用。 + - SSH 隧道。 + - 代理。 + - 文件读写。 + - WebDAV。 + - 敏感信息处理。 + - 大批量拉取时的并发控制、取消、超时、错误聚合。 +- 前端负责: + - 交互状态。 + - 表格过滤。 + - diff 展示。 + - 用户确认流程。 + - 小规模解析和展示。 +- 共享算法优先放 TypeScript: + - 配置格式识别。 + - key/value 归一化。 + - 矩阵计算。 + - 如果后续出现大数据性能瓶颈,再迁移到 Go。 -- [ ] 建立 `src/test/fixtures`。 +### 3.4 测试策略 + +- Go 测试: + - provider 抽象。 + - Nacos v1/v3 响应兼容。 + - App Wails 绑定薄测试。 + - Nacos/Apollo/WebDAV 代理配置。 + - Apollo client 使用 `httptest.Server`。 + - WebDAV client。 + - 批量任务调度:并发限制、取消、部分失败聚合、超时。 +- 前端测试: + - 纯函数优先覆盖。 + - Store 持久化和迁移逻辑必须覆盖。 + - 组件测试覆盖关键交互,不测试 mock 本身。 + - E2E 使用 mock Wails API,不依赖真实 Nacos/Apollo 服务。 +- 测试数据: - Nacos v1 配置列表响应。 - Nacos v3 配置列表响应。 - YAML/JSON/Properties 样例。 - Apollo app/cluster/namespace/item 样例。 -- [ ] 建立 mock API 层。 - - 前端组件测试不访问真实 Wails 绑定。 - - E2E 测试不依赖真实 Nacos/Apollo 服务。 -- [ ] 建立回归样例。 - - 空配置。 - - 解析失败。 - - 超大配置。 - - 缺失环境。 - - 值相同但顺序不同。 - - 生产环境危险操作。 - -### 10.3 代码质量 - -- [ ] 配置 ESLint。 -- [ ] 配置 Prettier。 -- [ ] TypeScript strict 模式评估并逐步开启。 -- [ ] 移除未使用代码。 -- [ ] 统一 import 路径。 - -### 10.4 CI/CD - -- [ ] CI 增加 Go test。 -- [ ] CI 增加前端 test。 -- [ ] CI 增加 Playwright Web E2E。 -- [ ] CI 增加 lint。 -- [ ] CI 增加 `npm run build:web`,确保前端可构建。 -- [ ] Windows/Linux 构建继续保持。 -- [ ] macOS 构建问题单独跟踪。 + - 空配置、解析失败、超大配置、缺失环境、生产环境危险操作。 ---- - -## 11. 平台与发布 - -### 11.1 当前支持 +### 3.5 平台与发布 -- [x] Windows amd64。 -- [x] Linux amd64。 -- [ ] macOS amd64 暂停。 -- [ ] macOS arm64 暂停。 - -### 11.2 macOS 问题 - -**错误信息**: +- 当前支持: + - Windows amd64。 + - Linux amd64。 +- macOS 暂停: + - macOS amd64。 + - macOS arm64。 +- macOS 已知问题: ```text dyld[1744]: missing LC_UUID load command in /Users/runner/go/bin/wails @@ -869,141 +691,40 @@ dyld[1744]: missing LC_UUID load command Abort trap: 6 ``` -- [ ] 跟踪 Wails CLI v2.12.0 在 macOS GitHub Actions runner 上的问题。 -- [ ] 尝试固定 Go 版本。 -- [ ] 尝试固定或降级 Wails CLI。 -- [ ] 尝试 macOS 本机 runner。 -- [ ] 恢复 macOS 前先保证 CI 可重复。 - -### 11.3 未来平台 - -- [ ] Linux ARM64。 -- [ ] Windows ARM64。 -- [ ] macOS ARM64。 +- 恢复 macOS 前需要: + - 跟踪 Wails CLI v2.12.0 在 macOS GitHub Actions runner 上的问题。 + - 尝试固定 Go 版本。 + - 尝试固定或降级 Wails CLI。 + - 尝试 macOS 本机 runner。 + - 保证 CI 可重复。 --- -## 12. 分阶段里程碑 - -### v1.1:Nacos 审计增强 - -- [ ] 主布局支持左侧主导航骨架。 -- [x] 配置内容标准化。 -- [ ] AuditView 多环境一致性矩阵。 -- [ ] AuditView 顶部条件栏、状态摘要、虚拟矩阵表格、详情面板。 -- [x] 忽略规则纯函数匹配。 -- [x] 名称归一化参与矩阵分组。 -- [ ] 审计结果导出 CSV/JSON。 -- [ ] DiffView 与 AuditView 职责拆分,批量治理入口迁移到 AuditView。 -- [x] 引入 Vitest,覆盖标准化、矩阵状态、忽略规则、名称归一化。 -- [x] 前端核心算法测试第一批。 -- [ ] AuditView 接入现有 `normalizeConfig` / `buildAuditMatrix`。 -- [ ] 前端核心算法测试补齐导出、危险确认与过滤派生逻辑。 - -### v1.2:备份与安全 - -- [ ] 连接管理升级为 provider 感知的分组表单。 -- [ ] 本地备份 provider。 -- [ ] BackupView 快照列表、详情、差异入口。 -- [ ] 云端到本地快照。 -- [ ] 本地快照浏览与 diff。 -- [ ] 生产/沙箱安全确认。 -- [ ] 危险操作统一确认组件。 -- [ ] 全局代理。 -- [ ] SSH 隧道状态完善。 -- [ ] 任务中心基础能力。 -- [ ] React 组件测试覆盖连接管理、DiffPanel、BackupView 基础交互。 - -### v1.3:环境应用与操作历史 - -- [ ] ApplyPlan 领域模型。 -- [ ] OperationHistory 领域模型。 -- [ ] 写入前目标配置快照。 -- [ ] 从 DiffView/AuditView 创建应用计划。 -- [ ] 源环境到沙箱环境应用。 -- [ ] 沙箱验证通过后晋级真实环境。 -- [ ] 生产/真实环境确认文本。 -- [ ] 应用任务接入任务中心。 -- [ ] 操作历史列表与详情页。 -- [ ] 从操作历史生成 rollback dry-run。 -- [ ] 回退执行接入任务中心和操作历史。 -- [ ] Playwright 覆盖“差异 -> 沙箱应用 -> 真实环境晋级 -> 历史回退”主流程。 - -### v1.4:Apollo 第一阶段 - -- [ ] provider 抽象落地。 -- [ ] Apollo OpenAPI 连接。 -- [ ] Apollo provider 专属连接表单。 -- [ ] Apollo 浏览与读取。 -- [ ] Apollo 浏览页使用 appId、cluster、namespace、item key/value 概念。 -- [ ] Apollo 参与 diff 和 AuditView。 -- [ ] AuditView 同时展示通用模型字段与 Apollo 原始字段。 -- [ ] Apollo 审计导出。 -- [ ] Apollo Go client 使用 `httptest.Server` 覆盖连接、浏览、读取、错误处理。 - -### v1.5:WebDAV 与恢复 - -- [ ] WebDAV 备份上传/下载。 -- [ ] WebDAV 任务接入任务中心。 -- [ ] 恢复预览。 -- [ ] 恢复 dry-run 计划 UI。 -- [ ] dry-run 恢复计划。 -- [ ] 单配置恢复。 -- [ ] 批量恢复。 -- [ ] Playwright 覆盖备份、恢复预览、dry-run 禁止误执行。 - -### v2.0:多配置中心治理 - -- [ ] Consul KV。 -- [ ] 多 provider 聚合视图。 -- [ ] 跨配置中心聚合视图。 -- [ ] 重复配置检查增强。 -- [ ] 配置治理报告。 -- [ ] 治理报告页面支持摘要、异常趋势、导出。 -- [ ] Git 集成评估。 - ---- - -## 13. 已完成 +## 4. 已完成基线 - [x] Nacos v1/v3 双版本支持。 - [x] 配置浏览与搜索。 +- [x] 配置编辑与发布。 +- [x] 配置删除确认。 - [x] 历史版本查看。 -- [x] 智能配置对比:行级 diff。 +- [x] 历史版本回滚。 +- [x] 行级 diff。 - [x] 任意两个配置来源对比。 - [x] 批量同名 dataId 对比基础能力。 - [x] 多连接管理。 - [x] 自动认证与 Token 管理。 - [x] SSH 隧道基础能力。 +- [x] 配置内容标准化纯函数。 +- [x] 审计矩阵计算纯函数。 +- [x] 忽略规则匹配纯函数。 +- [x] 名称归一化参与矩阵分组。 +- [x] 应用检查更新第一版。 - [x] React 18 + TypeScript + Vite 5。 - [x] Wails 2 + Go 后端。 +- [x] Vitest + React Testing Library 基础覆盖。 - [x] README、CHANGELOG、LICENSE、CREDITS。 --- -## 14. 近期建议执行顺序 - -1. [x] 先实现 `normalizeConfig` 与测试。 -2. [x] 再实现矩阵状态计算与忽略规则匹配。 -3. [x] 引入 Vitest,把核心纯函数测试跑进 `npm test`。 -4. [ ] 先落左侧主导航骨架,给 AuditView、BackupView、SettingsView、任务中心预留入口。 -5. [ ] 新增 AuditView,只支持 Nacos,不引入 Apollo。 -6. [ ] AuditView 接入 `normalizeConfig` / `buildAuditMatrix`,先跑通多环境只读审计。 -7. [ ] 做审计导出。 -8. [ ] 引入 React Testing Library,覆盖 AuditView/连接管理/DiffPanel 的关键交互。 -9. [ ] 引入 Playwright Web E2E,用 mock Wails API 覆盖浏览、diff、审计主流程。 -10. [ ] 设计 ApplyPlan 与 OperationHistory,先不写入真实环境。 -11. [ ] 实现从 DiffView/AuditView 创建应用 dry-run 计划。 -12. [ ] 实现应用到沙箱环境,并记录操作历史和写入前快照。 -13. [ ] 实现沙箱计划晋级真实环境,接入生产确认文本。 -14. [ ] 实现基于操作历史的 rollback dry-run。 -15. [ ] 抽 provider 接口,把 Nacos 迁移进去。 -16. [ ] 连接管理升级为 provider 感知分组表单。 -17. [ ] 开始 Apollo OpenAPI 第一阶段。 - -这个顺序的原因:先把参考项目最有价值的“审计矩阵”在现有 Nacos 能力上跑通,再抽象 provider。否则一开始就抽象 Apollo/Consul,容易为了未知需求过度设计。 - ---- - **维护者**: Adsryen **项目**: https://github.com/Adsryen/ConfScope From 60fcfe2f435ebb72d2064ea0a5f4f9aeeca5ba84 Mon Sep 17 00:00:00 2001 From: Adsryen Date: Mon, 29 Jun 2026 01:09:03 +0800 Subject: [PATCH 009/167] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=AD=E5=BF=83=20provider=20=E5=BA=95=E5=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增通用 provider 模型、接口和 Nacos 适配器 - 在 App 中注册 NacosProvider,为后续统一分发做准备 - 补充 provider 适配器和 App 注册测试 - 同步 todo 中 v1.1.0 P0 的第一批完成状态 --- app.go | 23 +++- app_test.go | 16 +++ docs/todo/todo.md | 15 +-- internal/provider/nacos.go | 150 ++++++++++++++++++++++ internal/provider/nacos_test.go | 212 ++++++++++++++++++++++++++++++++ internal/provider/types.go | 111 +++++++++++++++++ 6 files changed, 516 insertions(+), 11 deletions(-) create mode 100644 internal/provider/nacos.go create mode 100644 internal/provider/nacos_test.go create mode 100644 internal/provider/types.go diff --git a/app.go b/app.go index 7f0cbdf..73607c6 100644 --- a/app.go +++ b/app.go @@ -2,8 +2,10 @@ package main import ( "context" + "fmt" "confscope/internal/nacos" + "confscope/internal/provider" "confscope/internal/ssh" "confscope/internal/updatecheck" ) @@ -21,19 +23,32 @@ type AppInfo struct { // 这一层只做桌面端方法绑定和参数转发,具体 Nacos HTTP 协议适配由 // internal/nacos.Client 负责,避免前端绑定层混入业务解析逻辑。 type App struct { - ctx context.Context - nacos *nacos.Client - sshMgr *ssh.Manager + ctx context.Context + nacos *nacos.Client + sshMgr *ssh.Manager + providers map[provider.ProviderType]provider.ConfigProvider } // NewApp 创建应用服务实例。 func NewApp() *App { + nacosClient := nacos.NewClient() return &App{ - nacos: nacos.NewClient(), + nacos: nacosClient, sshMgr: ssh.NewManager(), + providers: map[provider.ProviderType]provider.ConfigProvider{ + provider.ProviderNacos: provider.NewNacosProvider(nacosClient), + }, } } +func (a *App) providerFor(providerType provider.ProviderType) (provider.ConfigProvider, error) { + p, ok := a.providers[providerType] + if !ok || p == nil { + return nil, fmt.Errorf("不支持的配置中心类型: %s", providerType) + } + return p, nil +} + // GetAppInfo 返回应用基础信息和内置更新源。 func (a *App) GetAppInfo() AppInfo { return AppInfo{ diff --git a/app_test.go b/app_test.go index 8b2d5c9..42ce164 100644 --- a/app_test.go +++ b/app_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "testing" + "confscope/internal/provider" "confscope/internal/updatecheck" ) @@ -60,3 +61,18 @@ func TestCheckForUpdatesUsesAppVersionWhenCurrentVersionIsEmpty(t *testing.T) { t.Fatalf("unexpected result: %+v", result) } } + +func TestNewAppRegistersNacosProvider(t *testing.T) { + app := NewApp() + + p, err := app.providerFor(provider.ProviderNacos) + if err != nil { + t.Fatalf("providerFor returned error: %v", err) + } + if p == nil { + t.Fatal("providerFor returned nil provider") + } + if _, ok := p.(*provider.NacosProvider); !ok { + t.Fatalf("provider type = %T, want *provider.NacosProvider", p) + } +} diff --git a/docs/todo/todo.md b/docs/todo/todo.md index 27b4cc9..c1585d3 100644 --- a/docs/todo/todo.md +++ b/docs/todo/todo.md @@ -129,13 +129,13 @@ #### P0 -- [ ] 后端 Provider 抽象。 +- [x] 后端 Provider 抽象第一批。 - 定义 `ProviderType`: `nacos | apollo | consul | local`。 - 定义 `ConnectionProfile`: 连接、认证、代理、SSH、环境标签、安全等级。 - 定义 `ConfigRef`: provider、connectionId、namespace、group、dataId、key。 - 定义 `ConfigDocument`: 原始内容、格式、更新时间、版本、来源。 - - 定义 `ConfigEntry`: 拆解后的 key/value/path/type。 -- [ ] 后端通用接口。 + - [ ] 后续补 `ConfigEntry` 后端模型;当前 key/value 拆解仍保留在前端算法层。 +- [x] 后端通用接口第一批。 - `ListNamespaces` - `ListConfigs` - `GetConfig` @@ -144,7 +144,7 @@ - `ListHistory` - `GetHistoryDetail` - `TestConnection` -- [ ] Nacos 适配层。 +- [x] Nacos 适配层第一批。 - 保留现有 Nacos 行为,不重写业务。 - 先把 `internal/nacos.Client` 包进 provider 适配器。 - Wails `App` 只保留薄绑定和参数转发。 @@ -153,9 +153,10 @@ - 新增通用 `src/api/configCenter.ts`。 - 页面逐步从 Nacos 专属 API 迁移到通用 API。 - [ ] 自动化测试。 - - provider 抽象单元测试。 - - Nacos 适配器测试。 - - App Wails 绑定薄测试:参数透传、错误返回、provider 分发。 + - [x] provider 抽象单元测试第一批。 + - [x] Nacos 适配器测试第一批。 + - [x] App provider 注册薄测试。 + - [ ] App Wails 绑定 provider 分发测试:参数透传、错误返回、provider 分发。 #### P1 diff --git a/internal/provider/nacos.go b/internal/provider/nacos.go new file mode 100644 index 0000000..1600297 --- /dev/null +++ b/internal/provider/nacos.go @@ -0,0 +1,150 @@ +package provider + +import "confscope/internal/nacos" + +type NacosProvider struct { + client *nacos.Client +} + +func NewNacosProvider(client *nacos.Client) *NacosProvider { + if client == nil { + client = nacos.NewClient() + } + return &NacosProvider{client: client} +} + +func (p *NacosProvider) ListNamespaces(profile ConnectionProfile) ([]Namespace, error) { + items, err := p.client.Namespaces(profile.BaseURL, profile.AccessToken, profile.APIVersion) + if err != nil { + return nil, err + } + out := make([]Namespace, 0, len(items)) + for _, item := range items { + out = append(out, Namespace{ + ID: item.Namespace, + Name: item.NamespaceShowName, + ConfigCount: item.ConfigCount, + Kind: item.Kind, + }) + } + return out, nil +} + +func (p *NacosProvider) ListConfigs(profile ConnectionProfile, req ListConfigsRequest) (ConfigPage, error) { + page, err := p.client.ListConfigs( + profile.BaseURL, + profile.AccessToken, + profile.APIVersion, + req.Namespace, + req.DataID, + req.Group, + req.PageNo, + req.PageSize, + ) + if err != nil { + return ConfigPage{}, err + } + + out := ConfigPage{ + TotalCount: page.TotalCount, + PageNumber: page.PageNumber, + PagesAvailable: page.PagesAvailable, + PageItems: make([]ConfigSummary, 0, len(page.PageItems)), + } + for _, item := range page.PageItems { + out.PageItems = append(out.PageItems, ConfigSummary{ + Ref: ConfigRef{ + Provider: ProviderNacos, + ConnectionID: profile.ID, + Namespace: req.Namespace, + Group: item.Group, + DataID: item.DataId, + }, + Content: item.Content, + Format: item.ConfigType, + }) + } + return out, nil +} + +func (p *NacosProvider) GetConfig(profile ConnectionProfile, ref ConfigRef) (ConfigDocument, error) { + content, err := p.client.GetConfig(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group) + if err != nil { + return ConfigDocument{}, err + } + return ConfigDocument{ + Ref: normalizeRef(profile, ref), + Content: content, + Source: string(ProviderNacos), + }, nil +} + +func (p *NacosProvider) PublishConfig(profile ConnectionProfile, req PublishConfigRequest) error { + ref := normalizeRef(profile, req.Ref) + return p.client.PublishConfig(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group, req.Content, req.Format) +} + +func (p *NacosProvider) DeleteConfig(profile ConnectionProfile, ref ConfigRef) error { + ref = normalizeRef(profile, ref) + return p.client.DeleteConfig(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group) +} + +func (p *NacosProvider) ListHistory(profile ConnectionProfile, ref ConfigRef, page PageRequest) (HistoryPage, error) { + ref = normalizeRef(profile, ref) + history, err := p.client.HistoryList(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group, page.PageNo, page.PageSize) + if err != nil { + return HistoryPage{}, err + } + out := HistoryPage{ + TotalCount: history.TotalCount, + PageNumber: history.PageNumber, + PagesAvailable: history.PagesAvailable, + PageItems: make([]HistoryItem, 0, len(history.PageItems)), + } + for _, item := range history.PageItems { + itemRef := ref + itemRef.DataID = item.DataId + itemRef.Group = item.Group + out.PageItems = append(out.PageItems, HistoryItem{ + ID: item.Id, + Ref: itemRef, + OpType: item.OpType, + LastModifiedTime: item.LastModifiedTime, + }) + } + return out, nil +} + +func (p *NacosProvider) GetHistoryDetail(profile ConnectionProfile, ref ConfigRef, id string) (HistoryDetail, error) { + ref = normalizeRef(profile, ref) + detail, err := p.client.HistoryDetail(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group, id) + if err != nil { + return HistoryDetail{}, err + } + detailRef := ref + detailRef.DataID = detail.DataId + detailRef.Group = detail.Group + return HistoryDetail{ + ID: detail.Id, + Ref: detailRef, + Content: detail.Content, + OpType: detail.OpType, + CreatedTime: detail.CreatedTime, + LastModifiedTime: detail.LastModifiedTime, + }, nil +} + +func (p *NacosProvider) TestConnection(profile ConnectionProfile) error { + _, err := p.client.Namespaces(profile.BaseURL, profile.AccessToken, profile.APIVersion) + return err +} + +func normalizeRef(profile ConnectionProfile, ref ConfigRef) ConfigRef { + if ref.Provider == "" { + ref.Provider = ProviderNacos + } + if ref.ConnectionID == "" { + ref.ConnectionID = profile.ID + } + return ref +} diff --git a/internal/provider/nacos_test.go b/internal/provider/nacos_test.go new file mode 100644 index 0000000..427a0e3 --- /dev/null +++ b/internal/provider/nacos_test.go @@ -0,0 +1,212 @@ +package provider + +import ( + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "confscope/internal/nacos" +) + +func newProviderIPv4Server(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen tcp4: %v", err) + } + + server := httptest.NewUnstartedServer(handler) + server.Listener = listener + server.Start() + t.Cleanup(server.Close) + return server +} + +func TestNacosProviderImplementsConfigProvider(t *testing.T) { + var _ ConfigProvider = NewNacosProvider(nacos.NewClient()) +} + +func TestNacosProviderListsNamespacesAndConfigs(t *testing.T) { + server := newProviderIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("accessToken") != "token" { + t.Fatalf("missing accessToken header") + } + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/v3/console/core/namespace/list": + _, _ = w.Write([]byte(`{"code":0,"data":[{"namespaceId":"ns-a","namespaceShowName":"Namespace A","configCount":7,"type":2}]}`)) + case "/v3/console/cs/config/list": + q := r.URL.Query() + if q.Get("namespaceId") != "ns-a" || q.Get("groupName") != "GROUP" || q.Get("dataId") != "app" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"code":0,"data":{"totalCount":1,"pageNumber":2,"pagesAvailable":3,"pageItems":[{"dataId":"app.yaml","groupName":"GROUP","content":"a: 1","type":"yaml"}]}}`)) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + + p := NewNacosProvider(nacos.NewClient()) + profile := ConnectionProfile{ + ID: "conn-1", + Provider: ProviderNacos, + BaseURL: server.URL, + AccessToken: "token", + APIVersion: "v3", + } + + namespaces, err := p.ListNamespaces(profile) + if err != nil { + t.Fatalf("ListNamespaces returned error: %v", err) + } + if len(namespaces) != 1 { + t.Fatalf("len(namespaces) = %d, want 1", len(namespaces)) + } + if namespaces[0].ID != "ns-a" || namespaces[0].Name != "Namespace A" || namespaces[0].ConfigCount != 7 { + t.Fatalf("unexpected namespace: %+v", namespaces[0]) + } + + page, err := p.ListConfigs(profile, ListConfigsRequest{ + Namespace: "ns-a", + Group: "GROUP", + DataID: "app", + PageNo: 2, + PageSize: 50, + }) + if err != nil { + t.Fatalf("ListConfigs returned error: %v", err) + } + if page.TotalCount != 1 || page.PageNumber != 2 || page.PagesAvailable != 3 || len(page.PageItems) != 1 { + t.Fatalf("unexpected page: %+v", page) + } + item := page.PageItems[0] + if item.Ref.Provider != ProviderNacos || item.Ref.ConnectionID != "conn-1" || item.Ref.Namespace != "ns-a" || item.Ref.Group != "GROUP" || item.Ref.DataID != "app.yaml" { + t.Fatalf("unexpected config ref: %+v", item.Ref) + } + if item.Content != "a: 1" || item.Format != "yaml" { + t.Fatalf("unexpected config summary: %+v", item) + } +} + +func TestNacosProviderTestsConnectionWithNamespaces(t *testing.T) { + server := newProviderIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/console/core/namespace/list" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("accessToken") != "token" { + t.Fatalf("missing accessToken header") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"data":[]}`)) + })) + + p := NewNacosProvider(nacos.NewClient()) + err := p.TestConnection(ConnectionProfile{ + ID: "conn-1", + Provider: ProviderNacos, + BaseURL: server.URL, + AccessToken: "token", + APIVersion: "v3", + }) + + if err != nil { + t.Fatalf("TestConnection returned error: %v", err) + } +} + +func TestNacosProviderGetsPublishesDeletesAndReadsHistory(t *testing.T) { + server := newProviderIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/cs/configs": + q := r.URL.Query() + if q.Get("tenant") != "public" || q.Get("group") != "DEFAULT_GROUP" || q.Get("dataId") != "app.yaml" || q.Get("accessToken") != "token" { + t.Fatalf("unexpected get config query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte("plain: true")) + case r.Method == http.MethodPost && r.URL.Path == "/v1/cs/configs": + if r.URL.Query().Get("accessToken") != "token" { + t.Fatalf("missing accessToken query") + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if r.Form.Get("tenant") != "public" || r.Form.Get("group") != "DEFAULT_GROUP" || r.Form.Get("dataId") != "app.yaml" || r.Form.Get("content") != "a: 1" || r.Form.Get("type") != "yaml" { + t.Fatalf("unexpected publish form: %v", r.Form) + } + _, _ = w.Write([]byte("true")) + case r.Method == http.MethodDelete && r.URL.Path == "/v1/cs/configs": + q := r.URL.Query() + if q.Get("tenant") != "public" || q.Get("group") != "DEFAULT_GROUP" || q.Get("dataId") != "app.yaml" || q.Get("accessToken") != "token" { + t.Fatalf("unexpected delete query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte("true")) + case r.Method == http.MethodGet && r.URL.Path == "/v1/cs/history": + q := r.URL.Query() + if q.Get("nid") == "42" { + _, _ = w.Write([]byte(`{"id":"42","dataId":"app.yaml","group":"DEFAULT_GROUP","content":"a: 1","opType":"I","createdTime":"2024-01-01","lastModifiedTime":"2024-01-02"}`)) + return + } + if q.Get("search") != "accurate" || q.Get("tenant") != "public" || q.Get("group") != "DEFAULT_GROUP" || q.Get("dataId") != "app.yaml" { + t.Fatalf("unexpected history query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"totalCount":1,"pageNumber":1,"pagesAvailable":1,"pageItems":[{"id":"42","dataId":"app.yaml","group":"DEFAULT_GROUP","opType":"I","lastModifiedTime":"2024-01-02"}]}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + + p := NewNacosProvider(nacos.NewClient()) + profile := ConnectionProfile{ + ID: "conn-1", + Provider: ProviderNacos, + BaseURL: server.URL, + AccessToken: "token", + APIVersion: "v1", + } + ref := ConfigRef{ + Provider: ProviderNacos, + ConnectionID: "conn-1", + Namespace: "public", + Group: "DEFAULT_GROUP", + DataID: "app.yaml", + } + + doc, err := p.GetConfig(profile, ref) + if err != nil { + t.Fatalf("GetConfig returned error: %v", err) + } + if doc.Ref != ref || doc.Content != "plain: true" { + t.Fatalf("unexpected document: %+v", doc) + } + + if err := p.PublishConfig(profile, PublishConfigRequest{Ref: ref, Content: "a: 1", Format: "yaml"}); err != nil { + t.Fatalf("PublishConfig returned error: %v", err) + } + if err := p.DeleteConfig(profile, ref); err != nil { + t.Fatalf("DeleteConfig returned error: %v", err) + } + + history, err := p.ListHistory(profile, ref, PageRequest{PageNo: 1, PageSize: 20}) + if err != nil { + t.Fatalf("ListHistory returned error: %v", err) + } + if history.TotalCount != 1 || len(history.PageItems) != 1 { + t.Fatalf("unexpected history page: %+v", history) + } + if history.PageItems[0].ID != "42" || history.PageItems[0].Ref != ref { + t.Fatalf("unexpected history item: %+v", history.PageItems[0]) + } + + detail, err := p.GetHistoryDetail(profile, ref, "42") + if err != nil { + t.Fatalf("GetHistoryDetail returned error: %v", err) + } + if detail.ID != "42" || detail.Ref != ref || !strings.Contains(detail.Content, "a: 1") { + t.Fatalf("unexpected history detail: %+v", detail) + } +} diff --git a/internal/provider/types.go b/internal/provider/types.go new file mode 100644 index 0000000..65940db --- /dev/null +++ b/internal/provider/types.go @@ -0,0 +1,111 @@ +package provider + +type ProviderType string + +const ( + ProviderNacos ProviderType = "nacos" + ProviderApollo ProviderType = "apollo" + ProviderConsul ProviderType = "consul" + ProviderLocal ProviderType = "local" +) + +type ConnectionProfile struct { + ID string `json:"id"` + Name string `json:"name"` + Provider ProviderType `json:"provider"` + BaseURL string `json:"baseUrl"` + AccessToken string `json:"accessToken"` + APIVersion string `json:"apiVersion"` + Environment string `json:"environment"` + SafetyLevel string `json:"safetyLevel"` +} + +type ConfigRef struct { + Provider ProviderType `json:"provider"` + ConnectionID string `json:"connectionId"` + Namespace string `json:"namespace"` + Group string `json:"group"` + DataID string `json:"dataId"` + Key string `json:"key"` +} + +type Namespace struct { + ID string `json:"id"` + Name string `json:"name"` + ConfigCount int64 `json:"configCount"` + Kind int64 `json:"kind"` +} + +type PageRequest struct { + PageNo int64 `json:"pageNo"` + PageSize int64 `json:"pageSize"` +} + +type ListConfigsRequest struct { + Namespace string `json:"namespace"` + Group string `json:"group"` + DataID string `json:"dataId"` + PageNo int64 `json:"pageNo"` + PageSize int64 `json:"pageSize"` +} + +type ConfigSummary struct { + Ref ConfigRef `json:"ref"` + Content string `json:"content"` + Format string `json:"format"` +} + +type ConfigPage struct { + TotalCount int64 `json:"totalCount"` + PageNumber int64 `json:"pageNumber"` + PagesAvailable int64 `json:"pagesAvailable"` + PageItems []ConfigSummary `json:"pageItems"` +} + +type ConfigDocument struct { + Ref ConfigRef `json:"ref"` + Content string `json:"content"` + Format string `json:"format"` + Version string `json:"version"` + Source string `json:"source"` +} + +type PublishConfigRequest struct { + Ref ConfigRef `json:"ref"` + Content string `json:"content"` + Format string `json:"format"` +} + +type HistoryItem struct { + ID string `json:"id"` + Ref ConfigRef `json:"ref"` + OpType string `json:"opType"` + LastModifiedTime string `json:"lastModifiedTime"` +} + +type HistoryPage struct { + TotalCount int64 `json:"totalCount"` + PageNumber int64 `json:"pageNumber"` + PagesAvailable int64 `json:"pagesAvailable"` + PageItems []HistoryItem `json:"pageItems"` +} + +type HistoryDetail struct { + ID string `json:"id"` + Ref ConfigRef `json:"ref"` + Content string `json:"content"` + OpType string `json:"opType"` + CreatedTime string `json:"createdTime"` + LastModifiedTime string `json:"lastModifiedTime"` +} + +type ConfigProvider interface { + ListNamespaces(profile ConnectionProfile) ([]Namespace, error) + ListConfigs(profile ConnectionProfile, req ListConfigsRequest) (ConfigPage, error) + GetConfig(profile ConnectionProfile, ref ConfigRef) (ConfigDocument, error) + PublishConfig(profile ConnectionProfile, req PublishConfigRequest) error + DeleteConfig(profile ConnectionProfile, ref ConfigRef) error + ListHistory(profile ConnectionProfile, ref ConfigRef, page PageRequest) (HistoryPage, error) + GetHistoryDetail(profile ConnectionProfile, ref ConfigRef, id string) (HistoryDetail, error) + TestConnection(profile ConnectionProfile) error +} From 5387774172dd49532e14e7345468f7fef5f41e71 Mon Sep 17 00:00:00 2001 From: Adsryen Date: Mon, 29 Jun 2026 01:43:09 +0800 Subject: [PATCH 010/167] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=AD=E5=BF=83=E9=80=9A=E7=94=A8=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 App 暴露 ConfigCenter 通用 provider 分发方法 - 新增前端 configCenter API 类型包装和 Wails 绑定 - 补充 Go 分发测试和前端 API 包装测试 - 同步 todo 中 v1.1.0 P0 的前端 API 边界进度 --- app.go | 69 +++++++++++++++++- app_test.go | 113 ++++++++++++++++++++++++++++++ docs/todo/todo.md | 8 ++- src/api/configCenter.test.ts | 102 +++++++++++++++++++++++++++ src/api/configCenter.ts | 132 +++++++++++++++++++++++++++++++++++ wailsjs/go/main/App.ts | 32 +++++++++ 6 files changed, 452 insertions(+), 4 deletions(-) create mode 100644 src/api/configCenter.test.ts create mode 100644 src/api/configCenter.ts diff --git a/app.go b/app.go index 73607c6..17270c3 100644 --- a/app.go +++ b/app.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "confscope/internal/nacos" @@ -12,6 +13,8 @@ import ( var appVersion = "1.0.0" +var errUnsupportedProvider = errors.New("unsupported config center provider") + type AppInfo struct { Name string `json:"name"` Version string `json:"version"` @@ -44,11 +47,75 @@ func NewApp() *App { func (a *App) providerFor(providerType provider.ProviderType) (provider.ConfigProvider, error) { p, ok := a.providers[providerType] if !ok || p == nil { - return nil, fmt.Errorf("不支持的配置中心类型: %s", providerType) + return nil, fmt.Errorf("%w: %s", errUnsupportedProvider, providerType) } return p, nil } +func (a *App) ConfigCenterListNamespaces(profile provider.ConnectionProfile) ([]provider.Namespace, error) { + p, err := a.providerFor(profile.Provider) + if err != nil { + return nil, err + } + return p.ListNamespaces(profile) +} + +func (a *App) ConfigCenterListConfigs(profile provider.ConnectionProfile, req provider.ListConfigsRequest) (provider.ConfigPage, error) { + p, err := a.providerFor(profile.Provider) + if err != nil { + return provider.ConfigPage{}, err + } + return p.ListConfigs(profile, req) +} + +func (a *App) ConfigCenterGetConfig(profile provider.ConnectionProfile, ref provider.ConfigRef) (provider.ConfigDocument, error) { + p, err := a.providerFor(profile.Provider) + if err != nil { + return provider.ConfigDocument{}, err + } + return p.GetConfig(profile, ref) +} + +func (a *App) ConfigCenterPublishConfig(profile provider.ConnectionProfile, req provider.PublishConfigRequest) error { + p, err := a.providerFor(profile.Provider) + if err != nil { + return err + } + return p.PublishConfig(profile, req) +} + +func (a *App) ConfigCenterDeleteConfig(profile provider.ConnectionProfile, ref provider.ConfigRef) error { + p, err := a.providerFor(profile.Provider) + if err != nil { + return err + } + return p.DeleteConfig(profile, ref) +} + +func (a *App) ConfigCenterListHistory(profile provider.ConnectionProfile, ref provider.ConfigRef, page provider.PageRequest) (provider.HistoryPage, error) { + p, err := a.providerFor(profile.Provider) + if err != nil { + return provider.HistoryPage{}, err + } + return p.ListHistory(profile, ref, page) +} + +func (a *App) ConfigCenterGetHistoryDetail(profile provider.ConnectionProfile, ref provider.ConfigRef, id string) (provider.HistoryDetail, error) { + p, err := a.providerFor(profile.Provider) + if err != nil { + return provider.HistoryDetail{}, err + } + return p.GetHistoryDetail(profile, ref, id) +} + +func (a *App) ConfigCenterTestConnection(profile provider.ConnectionProfile) error { + p, err := a.providerFor(profile.Provider) + if err != nil { + return err + } + return p.TestConnection(profile) +} + // GetAppInfo 返回应用基础信息和内置更新源。 func (a *App) GetAppInfo() AppInfo { return AppInfo{ diff --git a/app_test.go b/app_test.go index 42ce164..6ab3ae0 100644 --- a/app_test.go +++ b/app_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "net" "net/http" "net/http/httptest" @@ -76,3 +77,115 @@ func TestNewAppRegistersNacosProvider(t *testing.T) { t.Fatalf("provider type = %T, want *provider.NacosProvider", p) } } + +type fakeConfigProvider struct { + calls []string +} + +func (f *fakeConfigProvider) record(name string) { + f.calls = append(f.calls, name) +} + +func (f *fakeConfigProvider) ListNamespaces(profile provider.ConnectionProfile) ([]provider.Namespace, error) { + f.record("ListNamespaces:" + string(profile.Provider)) + return []provider.Namespace{{ID: "ns-a", Name: "Namespace A"}}, nil +} + +func (f *fakeConfigProvider) ListConfigs(profile provider.ConnectionProfile, req provider.ListConfigsRequest) (provider.ConfigPage, error) { + f.record("ListConfigs:" + req.Namespace) + return provider.ConfigPage{TotalCount: 1, PageItems: []provider.ConfigSummary{{Ref: provider.ConfigRef{DataID: "app.yaml"}}}}, nil +} + +func (f *fakeConfigProvider) GetConfig(profile provider.ConnectionProfile, ref provider.ConfigRef) (provider.ConfigDocument, error) { + f.record("GetConfig:" + ref.DataID) + return provider.ConfigDocument{Ref: ref, Content: "a: 1"}, nil +} + +func (f *fakeConfigProvider) PublishConfig(profile provider.ConnectionProfile, req provider.PublishConfigRequest) error { + f.record("PublishConfig:" + req.Ref.DataID) + return nil +} + +func (f *fakeConfigProvider) DeleteConfig(profile provider.ConnectionProfile, ref provider.ConfigRef) error { + f.record("DeleteConfig:" + ref.DataID) + return nil +} + +func (f *fakeConfigProvider) ListHistory(profile provider.ConnectionProfile, ref provider.ConfigRef, page provider.PageRequest) (provider.HistoryPage, error) { + f.record("ListHistory:" + ref.DataID) + return provider.HistoryPage{TotalCount: 1, PageItems: []provider.HistoryItem{{ID: "42", Ref: ref}}}, nil +} + +func (f *fakeConfigProvider) GetHistoryDetail(profile provider.ConnectionProfile, ref provider.ConfigRef, id string) (provider.HistoryDetail, error) { + f.record("GetHistoryDetail:" + id) + return provider.HistoryDetail{ID: id, Ref: ref, Content: "a: 1"}, nil +} + +func (f *fakeConfigProvider) TestConnection(profile provider.ConnectionProfile) error { + f.record("TestConnection:" + string(profile.Provider)) + return nil +} + +func TestConfigCenterMethodsDispatchToRegisteredProvider(t *testing.T) { + app := NewApp() + fake := &fakeConfigProvider{} + app.providers[provider.ProviderLocal] = fake + profile := provider.ConnectionProfile{ID: "local-1", Provider: provider.ProviderLocal} + ref := provider.ConfigRef{Provider: provider.ProviderLocal, ConnectionID: "local-1", DataID: "app.yaml"} + + if namespaces, err := app.ConfigCenterListNamespaces(profile); err != nil || len(namespaces) != 1 { + t.Fatalf("ConfigCenterListNamespaces = %+v, %v", namespaces, err) + } + if page, err := app.ConfigCenterListConfigs(profile, provider.ListConfigsRequest{Namespace: "public"}); err != nil || page.TotalCount != 1 { + t.Fatalf("ConfigCenterListConfigs = %+v, %v", page, err) + } + if doc, err := app.ConfigCenterGetConfig(profile, ref); err != nil || doc.Content != "a: 1" { + t.Fatalf("ConfigCenterGetConfig = %+v, %v", doc, err) + } + if err := app.ConfigCenterPublishConfig(profile, provider.PublishConfigRequest{Ref: ref}); err != nil { + t.Fatalf("ConfigCenterPublishConfig returned error: %v", err) + } + if err := app.ConfigCenterDeleteConfig(profile, ref); err != nil { + t.Fatalf("ConfigCenterDeleteConfig returned error: %v", err) + } + if history, err := app.ConfigCenterListHistory(profile, ref, provider.PageRequest{PageNo: 1, PageSize: 20}); err != nil || history.TotalCount != 1 { + t.Fatalf("ConfigCenterListHistory = %+v, %v", history, err) + } + if detail, err := app.ConfigCenterGetHistoryDetail(profile, ref, "42"); err != nil || detail.ID != "42" { + t.Fatalf("ConfigCenterGetHistoryDetail = %+v, %v", detail, err) + } + if err := app.ConfigCenterTestConnection(profile); err != nil { + t.Fatalf("ConfigCenterTestConnection returned error: %v", err) + } + + want := []string{ + "ListNamespaces:local", + "ListConfigs:public", + "GetConfig:app.yaml", + "PublishConfig:app.yaml", + "DeleteConfig:app.yaml", + "ListHistory:app.yaml", + "GetHistoryDetail:42", + "TestConnection:local", + } + if len(fake.calls) != len(want) { + t.Fatalf("calls = %#v, want %#v", fake.calls, want) + } + for i := range want { + if fake.calls[i] != want[i] { + t.Fatalf("calls[%d] = %q, want %q; all calls: %#v", i, fake.calls[i], want[i], fake.calls) + } + } +} + +func TestConfigCenterMethodsRejectUnsupportedProvider(t *testing.T) { + app := NewApp() + + _, err := app.ConfigCenterListNamespaces(provider.ConnectionProfile{Provider: provider.ProviderApollo}) + if err == nil { + t.Fatal("ConfigCenterListNamespaces returned nil error") + } + if !errors.Is(err, errUnsupportedProvider) { + t.Fatalf("error = %v, want errUnsupportedProvider", err) + } +} diff --git a/docs/todo/todo.md b/docs/todo/todo.md index c1585d3..8f54a4c 100644 --- a/docs/todo/todo.md +++ b/docs/todo/todo.md @@ -148,15 +148,17 @@ - 保留现有 Nacos 行为,不重写业务。 - 先把 `internal/nacos.Client` 包进 provider 适配器。 - Wails `App` 只保留薄绑定和参数转发。 -- [ ] 前端 API 边界。 +- [x] 前端 API 边界第一批。 - 保留 `src/api/nacos.ts` 兼容过渡。 - 新增通用 `src/api/configCenter.ts`。 - - 页面逐步从 Nacos 专属 API 迁移到通用 API。 + - [ ] 页面逐步从 Nacos 专属 API 迁移到通用 API。 - [ ] 自动化测试。 - [x] provider 抽象单元测试第一批。 - [x] Nacos 适配器测试第一批。 - [x] App provider 注册薄测试。 - - [ ] App Wails 绑定 provider 分发测试:参数透传、错误返回、provider 分发。 + - [x] App Wails 绑定 provider 分发测试第一批。 + - [x] 前端通用 configCenter API 包装测试第一批。 + - [ ] 页面迁移后的组件回归测试。 #### P1 diff --git a/src/api/configCenter.test.ts b/src/api/configCenter.test.ts new file mode 100644 index 0000000..6e1fda7 --- /dev/null +++ b/src/api/configCenter.test.ts @@ -0,0 +1,102 @@ +/** + * @vitest-environment jsdom + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + deleteConfig, + getConfig, + getHistoryDetail, + listConfigs, + listHistory, + listNamespaces, + publishConfig, + testConnection, + type ConfigRef, + type ConnectionProfile, +} from "./configCenter"; + +const goApp = { + ConfigCenterListNamespaces: vi.fn(), + ConfigCenterListConfigs: vi.fn(), + ConfigCenterGetConfig: vi.fn(), + ConfigCenterPublishConfig: vi.fn(), + ConfigCenterDeleteConfig: vi.fn(), + ConfigCenterListHistory: vi.fn(), + ConfigCenterGetHistoryDetail: vi.fn(), + ConfigCenterTestConnection: vi.fn(), +}; + +describe("config center api", () => { + const profile: ConnectionProfile = { + id: "conn-1", + name: "dev", + provider: "nacos", + baseUrl: "http://127.0.0.1:8848/nacos", + accessToken: "token", + apiVersion: "v3", + environment: "dev", + safetyLevel: "test", + }; + const ref: ConfigRef = { + provider: "nacos", + connectionId: "conn-1", + namespace: "public", + group: "DEFAULT_GROUP", + dataId: "app.yaml", + key: "", + }; + + beforeEach(() => { + for (const fn of Object.values(goApp)) fn.mockReset(); + vi.stubGlobal("go", { + main: { + App: goApp, + }, + }); + }); + + it("passes profile to namespace and connection calls", async () => { + goApp.ConfigCenterListNamespaces.mockResolvedValue([{ id: "public", name: "Public" }]); + goApp.ConfigCenterTestConnection.mockResolvedValue(undefined); + + await expect(listNamespaces(profile)).resolves.toEqual([{ id: "public", name: "Public" }]); + await expect(testConnection(profile)).resolves.toBeUndefined(); + + expect(goApp.ConfigCenterListNamespaces).toHaveBeenCalledWith(profile); + expect(goApp.ConfigCenterTestConnection).toHaveBeenCalledWith(profile); + }); + + it("passes typed config requests to Wails bindings", async () => { + goApp.ConfigCenterListConfigs.mockResolvedValue({ totalCount: 1, pageItems: [] }); + goApp.ConfigCenterGetConfig.mockResolvedValue({ ref, content: "a: 1" }); + goApp.ConfigCenterPublishConfig.mockResolvedValue(undefined); + goApp.ConfigCenterDeleteConfig.mockResolvedValue(undefined); + + await listConfigs(profile, { namespace: "public", group: "DEFAULT_GROUP", dataId: "app", pageNo: 1, pageSize: 20 }); + await getConfig(profile, ref); + await publishConfig(profile, { ref, content: "a: 1", format: "yaml" }); + await deleteConfig(profile, ref); + + expect(goApp.ConfigCenterListConfigs).toHaveBeenCalledWith(profile, { + namespace: "public", + group: "DEFAULT_GROUP", + dataId: "app", + pageNo: 1, + pageSize: 20, + }); + expect(goApp.ConfigCenterGetConfig).toHaveBeenCalledWith(profile, ref); + expect(goApp.ConfigCenterPublishConfig).toHaveBeenCalledWith(profile, { ref, content: "a: 1", format: "yaml" }); + expect(goApp.ConfigCenterDeleteConfig).toHaveBeenCalledWith(profile, ref); + }); + + it("passes typed history requests to Wails bindings", async () => { + goApp.ConfigCenterListHistory.mockResolvedValue({ totalCount: 1, pageItems: [] }); + goApp.ConfigCenterGetHistoryDetail.mockResolvedValue({ id: "42", ref, content: "a: 1" }); + + await listHistory(profile, ref, { pageNo: 1, pageSize: 20 }); + await getHistoryDetail(profile, ref, "42"); + + expect(goApp.ConfigCenterListHistory).toHaveBeenCalledWith(profile, ref, { pageNo: 1, pageSize: 20 }); + expect(goApp.ConfigCenterGetHistoryDetail).toHaveBeenCalledWith(profile, ref, "42"); + }); +}); diff --git a/src/api/configCenter.ts b/src/api/configCenter.ts new file mode 100644 index 0000000..46649fa --- /dev/null +++ b/src/api/configCenter.ts @@ -0,0 +1,132 @@ +import { + ConfigCenterDeleteConfig, + ConfigCenterGetConfig, + ConfigCenterGetHistoryDetail, + ConfigCenterListConfigs, + ConfigCenterListHistory, + ConfigCenterListNamespaces, + ConfigCenterPublishConfig, + ConfigCenterTestConnection, +} from "../../wailsjs/go/main/App"; + +export type ProviderType = "nacos" | "apollo" | "consul" | "local"; + +export interface ConnectionProfile { + id: string; + name: string; + provider: ProviderType; + baseUrl: string; + accessToken: string; + apiVersion: string; + environment: string; + safetyLevel: string; +} + +export interface ConfigRef { + provider: ProviderType; + connectionId: string; + namespace: string; + group: string; + dataId: string; + key: string; +} + +export interface Namespace { + id: string; + name: string; + configCount: number; + kind: number; +} + +export interface PageRequest { + pageNo: number; + pageSize: number; +} + +export interface ListConfigsRequest extends PageRequest { + namespace: string; + group: string; + dataId: string; +} + +export interface ConfigSummary { + ref: ConfigRef; + content: string; + format: string; +} + +export interface ConfigPage { + totalCount: number; + pageNumber: number; + pagesAvailable: number; + pageItems: ConfigSummary[]; +} + +export interface ConfigDocument { + ref: ConfigRef; + content: string; + format: string; + version: string; + source: string; +} + +export interface PublishConfigRequest { + ref: ConfigRef; + content: string; + format: string; +} + +export interface HistoryItem { + id: string; + ref: ConfigRef; + opType: string; + lastModifiedTime: string; +} + +export interface HistoryPage { + totalCount: number; + pageNumber: number; + pagesAvailable: number; + pageItems: HistoryItem[]; +} + +export interface HistoryDetail { + id: string; + ref: ConfigRef; + content: string; + opType: string; + createdTime: string; + lastModifiedTime: string; +} + +export function listNamespaces(profile: ConnectionProfile): Promise { + return ConfigCenterListNamespaces(profile); +} + +export function listConfigs(profile: ConnectionProfile, request: ListConfigsRequest): Promise { + return ConfigCenterListConfigs(profile, request); +} + +export function getConfig(profile: ConnectionProfile, ref: ConfigRef): Promise { + return ConfigCenterGetConfig(profile, ref); +} + +export function publishConfig(profile: ConnectionProfile, request: PublishConfigRequest): Promise { + return ConfigCenterPublishConfig(profile, request); +} + +export function deleteConfig(profile: ConnectionProfile, ref: ConfigRef): Promise { + return ConfigCenterDeleteConfig(profile, ref); +} + +export function listHistory(profile: ConnectionProfile, ref: ConfigRef, page: PageRequest): Promise { + return ConfigCenterListHistory(profile, ref, page); +} + +export function getHistoryDetail(profile: ConnectionProfile, ref: ConfigRef, id: string): Promise { + return ConfigCenterGetHistoryDetail(profile, ref, id); +} + +export function testConnection(profile: ConnectionProfile): Promise { + return ConfigCenterTestConnection(profile); +} diff --git a/wailsjs/go/main/App.ts b/wailsjs/go/main/App.ts index 3abee69..f20f8fd 100644 --- a/wailsjs/go/main/App.ts +++ b/wailsjs/go/main/App.ts @@ -1,6 +1,14 @@ type GoApp = { GetAppInfo(): Promise; CheckForUpdates(request: any): Promise; + ConfigCenterListNamespaces(profile: any): Promise; + ConfigCenterListConfigs(profile: any, request: any): Promise; + ConfigCenterGetConfig(profile: any, ref: any): Promise; + ConfigCenterPublishConfig(profile: any, request: any): Promise; + ConfigCenterDeleteConfig(profile: any, ref: any): Promise; + ConfigCenterListHistory(profile: any, ref: any, page: any): Promise; + ConfigCenterGetHistoryDetail(profile: any, ref: any, id: string): Promise; + ConfigCenterTestConnection(profile: any): Promise; NacosDetectVersion(baseUrl: string): Promise; NacosLogin(baseUrl: string, username: string, password: string, apiVersion: string): Promise; NacosNamespaces(baseUrl: string, accessToken: string, apiVersion: string): Promise; @@ -81,6 +89,30 @@ export const GetAppInfo = () => app().GetAppInfo(); export const CheckForUpdates = (request: any) => app().CheckForUpdates(request); +export const ConfigCenterListNamespaces = (profile: any) => + app().ConfigCenterListNamespaces(profile); + +export const ConfigCenterListConfigs = (profile: any, request: any) => + app().ConfigCenterListConfigs(profile, request); + +export const ConfigCenterGetConfig = (profile: any, ref: any) => + app().ConfigCenterGetConfig(profile, ref); + +export const ConfigCenterPublishConfig = (profile: any, request: any) => + app().ConfigCenterPublishConfig(profile, request); + +export const ConfigCenterDeleteConfig = (profile: any, ref: any) => + app().ConfigCenterDeleteConfig(profile, ref); + +export const ConfigCenterListHistory = (profile: any, ref: any, page: any) => + app().ConfigCenterListHistory(profile, ref, page); + +export const ConfigCenterGetHistoryDetail = (profile: any, ref: any, id: string) => + app().ConfigCenterGetHistoryDetail(profile, ref, id); + +export const ConfigCenterTestConnection = (profile: any) => + app().ConfigCenterTestConnection(profile); + export const NacosDetectVersion = (baseUrl: string) => app().NacosDetectVersion(baseUrl); export const NacosLogin = (baseUrl: string, username: string, password: string, apiVersion: string) => From db8ad7521bd28662a3453696dd7fc4aad8e4ea14 Mon Sep 17 00:00:00 2001 From: Adsryen Date: Mon, 29 Jun 2026 02:18:32 +0800 Subject: [PATCH 011/167] =?UTF-8?q?refactor:=20=E9=80=9A=E8=BF=87=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E5=85=A5=E5=8F=A3=E8=AF=BB=E5=8F=96=20Nacos=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 Nacos 命名空间、配置和历史读取迁移到 configCenter API - 保持前端 Nacos API 签名和返回结构兼容 - 补充 MSE Nacos 的 v1.1.0 阶段规划 - 增加 Nacos API 兼容桥接测试 --- docs/todo/todo.md | 21 +++-- src/api/nacos.test.ts | 198 ++++++++++++++++++++++++++++++++++++++++++ src/api/nacos.ts | 161 ++++++++++++++++++++++++++++------ 3 files changed, 348 insertions(+), 32 deletions(-) create mode 100644 src/api/nacos.test.ts diff --git a/docs/todo/todo.md b/docs/todo/todo.md index 8f54a4c..4385bb3 100644 --- a/docs/todo/todo.md +++ b/docs/todo/todo.md @@ -1,6 +1,6 @@ # ConfScope 开发待办规划 -**最后更新**: 2026-06-28 +**最后更新**: 2026-06-29 **规划参考**: `/mnt/c/Users/adsry/Desktop/fsdownload/ConfigCenterComparer` **当前定位**: Go + Wails 桌面端配置中心管理、对比、审计工具。MVP 已支持 Nacos,后续应从“单配置浏览与 diff”升级为“多环境配置治理”。 @@ -68,7 +68,7 @@ **当前基线**: `v1.0.0` **当前开发线**: `v1.1.0` -**下一项立即做**: `v1.1.0 / P0` 的代码结构拆分与 provider 底座。 +**下一项立即做**: `v1.1.0 / P0` 的 MSE Nacos 连接第一阶段与 provider 兼容迁移。 - [x] `v1.0.0` 已具备 Nacos 浏览、编辑、历史、diff、多连接、SSH 基础能力。 - [x] 已完成核心自动化测试第一批。 @@ -152,12 +152,20 @@ - 保留 `src/api/nacos.ts` 兼容过渡。 - 新增通用 `src/api/configCenter.ts`。 - [ ] 页面逐步从 Nacos 专属 API 迁移到通用 API。 +- [ ] 阿里云 MSE Nacos 连接第一阶段。 + - 作为 Nacos provider 的连接形态增强,不单独提升到大版本。 + - 连接模型增加 Nacos 发行版/来源:`opensource | aliyun-mse`。 + - 认证模型增加 `none | nacos-password | aliyun-aksk`,保留旧连接的 username/password 兼容迁移。 + - 后端 Nacos client 封装 MSE 鉴权请求构造,前端不直接拼签名、不暴露 secret 到日志。 + - 第一阶段覆盖测试连接、命名空间、配置列表、配置详情、历史查询。 + - 写操作先沿用现有危险操作确认;若 MSE 权限不足,要给出明确错误提示。 - [ ] 自动化测试。 - [x] provider 抽象单元测试第一批。 - [x] Nacos 适配器测试第一批。 - [x] App provider 注册薄测试。 - [x] App Wails 绑定 provider 分发测试第一批。 - [x] 前端通用 configCenter API 包装测试第一批。 + - [ ] MSE Nacos 鉴权请求构造与连接模型迁移测试。 - [ ] 页面迁移后的组件回归测试。 #### P1 @@ -560,11 +568,10 @@ - datacenter 选择。 - key prefix 过滤。 - 与 Nacos/Apollo/local 的 diff 和 AuditView。 -- [ ] 阿里云 MSE / Nacos 企业场景。 - - AccessKey 模式。 - - 认证类型:username/password、accessKey/secretKey、token。 - - Go 后端封装签名逻辑,前端不直接处理 secret。 - - 连接测试区分认证失败、网络失败、权限不足。 +- [ ] 阿里云 MSE / Nacos 企业治理增强。 + - RAM 角色、STS 临时凭证、KMS/系统钥匙串托管凭据评估。 + - 企业权限视图与批量治理策略。 + - 更细的错误分类、审计脱敏和凭据轮换提醒。 - [ ] 配置治理报告。 - 摘要。 - 异常趋势。 diff --git a/src/api/nacos.test.ts b/src/api/nacos.test.ts new file mode 100644 index 0000000..4e0e8b1 --- /dev/null +++ b/src/api/nacos.test.ts @@ -0,0 +1,198 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getConfig, + getHistoryDetail, + listConfigs, + listHistory, + listNamespaces, +} from "./nacos"; +import type { Connection } from "../store/connections"; + +const goApp = { + ConfigCenterListNamespaces: vi.fn(), + ConfigCenterListConfigs: vi.fn(), + ConfigCenterGetConfig: vi.fn(), + ConfigCenterPublishConfig: vi.fn(), + ConfigCenterDeleteConfig: vi.fn(), + ConfigCenterListHistory: vi.fn(), + ConfigCenterGetHistoryDetail: vi.fn(), + ConfigCenterTestConnection: vi.fn(), + NacosDetectVersion: vi.fn(), + NacosLogin: vi.fn(), + NacosNamespaces: vi.fn(), + NacosListConfigs: vi.fn(), + NacosGetConfig: vi.fn(), + NacosHistoryList: vi.fn(), + NacosHistoryDetail: vi.fn(), + NacosPublishConfig: vi.fn(), + NacosDeleteConfig: vi.fn(), + CreateSSHTunnel: vi.fn(), + StopSSHTunnel: vi.fn(), +}; + +function makeConnection(id: string): Connection { + return { + id, + name: "dev", + baseUrl: `http://127.0.0.1:8848/${id}/nacos`, + username: "nacos", + password: "secret", + defaultNamespace: "public", + }; +} + +function expectedProfile(conn: Connection, accessToken = "token-1") { + return { + id: conn.id, + name: conn.name, + provider: "nacos", + baseUrl: conn.baseUrl, + accessToken, + apiVersion: "v3", + environment: "", + safetyLevel: "", + }; +} + +function expectedRef(conn: Connection, namespace = "public", dataId = "app.yaml", group = "DEFAULT_GROUP") { + return { + provider: "nacos", + connectionId: conn.id, + namespace, + group, + dataId, + key: "", + }; +} + +describe("nacos api compatibility bridge", () => { + beforeEach(() => { + for (const fn of Object.values(goApp)) fn.mockReset(); + goApp.NacosDetectVersion.mockResolvedValue("v3"); + goApp.NacosLogin.mockResolvedValue({ + accessToken: "token-1", + tokenTtl: 18000, + globalAdmin: true, + }); + vi.stubGlobal("go", { + main: { + App: goApp, + }, + }); + vi.stubGlobal("window", { + go: { + main: { + App: goApp, + }, + }, + }); + }); + + it("routes namespace reads through configCenter and keeps the old Nacos namespace shape", async () => { + const conn = makeConnection("conn-namespaces"); + goApp.ConfigCenterListNamespaces.mockResolvedValue([ + { id: "public", name: "Public", configCount: 3, kind: 2 }, + ]); + + await expect(listNamespaces(conn)).resolves.toEqual([ + { namespace: "public", namespaceShowName: "Public", configCount: 3, kind: 2 }, + ]); + + expect(goApp.NacosDetectVersion).toHaveBeenCalledWith(conn.baseUrl); + expect(goApp.NacosLogin).toHaveBeenCalledWith(conn.baseUrl, "nacos", "secret", "v3"); + expect(goApp.ConfigCenterListNamespaces).toHaveBeenCalledWith(expectedProfile(conn)); + expect(goApp.NacosNamespaces).not.toHaveBeenCalled(); + }); + + it("routes config and history reads through configCenter while preserving old return contracts", async () => { + const conn = makeConnection("conn-reads"); + const ref = expectedRef(conn); + goApp.ConfigCenterListConfigs.mockResolvedValue({ + totalCount: 1, + pageNumber: 1, + pagesAvailable: 1, + pageItems: [{ ref, content: "server:\n port: 8080", format: "yaml" }], + }); + goApp.ConfigCenterGetConfig.mockResolvedValue({ + ref, + content: "server:\n port: 8080", + format: "yaml", + version: "42", + source: "nacos", + }); + goApp.ConfigCenterListHistory.mockResolvedValue({ + totalCount: 1, + pageNumber: 1, + pagesAvailable: 1, + pageItems: [{ id: "h-1", ref, opType: "U", lastModifiedTime: "1710000000000" }], + }); + goApp.ConfigCenterGetHistoryDetail.mockResolvedValue({ + id: "h-1", + ref, + content: "server:\n port: 8080", + opType: "U", + createdTime: "1700000000000", + lastModifiedTime: "1710000000000", + }); + + await expect(listConfigs(conn, "public", "app.yaml", "DEFAULT_GROUP", 1, 20)).resolves.toEqual({ + totalCount: 1, + pageNumber: 1, + pagesAvailable: 1, + pageItems: [ + { dataId: "app.yaml", group: "DEFAULT_GROUP", content: "server:\n port: 8080", configType: "yaml" }, + ], + }); + await expect(getConfig(conn, "public", "app.yaml", "DEFAULT_GROUP")).resolves.toBe("server:\n port: 8080"); + await expect(listHistory(conn, "public", "app.yaml", "DEFAULT_GROUP", 1, 20)).resolves.toEqual({ + totalCount: 1, + pageNumber: 1, + pagesAvailable: 1, + pageItems: [{ id: "h-1", dataId: "app.yaml", group: "DEFAULT_GROUP", opType: "U", lastModifiedTime: "1710000000000" }], + }); + await expect(getHistoryDetail(conn, "public", "app.yaml", "DEFAULT_GROUP", "h-1")).resolves.toEqual({ + id: "h-1", + dataId: "app.yaml", + group: "DEFAULT_GROUP", + content: "server:\n port: 8080", + opType: "U", + createdTime: "1700000000000", + lastModifiedTime: "1710000000000", + }); + + expect(goApp.ConfigCenterListConfigs).toHaveBeenCalledWith(expectedProfile(conn), { + namespace: "public", + dataId: "app.yaml", + group: "DEFAULT_GROUP", + pageNo: 1, + pageSize: 20, + }); + expect(goApp.ConfigCenterGetConfig).toHaveBeenCalledWith(expectedProfile(conn), ref); + expect(goApp.ConfigCenterListHistory).toHaveBeenCalledWith(expectedProfile(conn), ref, { pageNo: 1, pageSize: 20 }); + expect(goApp.ConfigCenterGetHistoryDetail).toHaveBeenCalledWith(expectedProfile(conn), ref, "h-1"); + expect(goApp.NacosListConfigs).not.toHaveBeenCalled(); + expect(goApp.NacosGetConfig).not.toHaveBeenCalled(); + expect(goApp.NacosHistoryList).not.toHaveBeenCalled(); + expect(goApp.NacosHistoryDetail).not.toHaveBeenCalled(); + }); + + it("refreshes token and retries configCenter reads on 403", async () => { + const conn = makeConnection("conn-retry"); + const ref = expectedRef(conn); + goApp.NacosLogin + .mockResolvedValueOnce({ accessToken: "expired-token", tokenTtl: 18000, globalAdmin: true }) + .mockResolvedValueOnce({ accessToken: "fresh-token", tokenTtl: 18000, globalAdmin: true }); + goApp.ConfigCenterGetConfig + .mockRejectedValueOnce(new Error("code=403")) + .mockResolvedValueOnce({ ref, content: "ok", format: "text", version: "", source: "nacos" }); + + await expect(getConfig(conn, "public", "app.yaml", "DEFAULT_GROUP")).resolves.toBe("ok"); + + expect(goApp.NacosLogin).toHaveBeenCalledTimes(2); + expect(goApp.ConfigCenterGetConfig).toHaveBeenNthCalledWith(1, expectedProfile(conn, "expired-token"), ref); + expect(goApp.ConfigCenterGetConfig).toHaveBeenNthCalledWith(2, expectedProfile(conn, "fresh-token"), ref); + }); +}); diff --git a/src/api/nacos.ts b/src/api/nacos.ts index 7fe00ba..54149ba 100644 --- a/src/api/nacos.ts +++ b/src/api/nacos.ts @@ -1,16 +1,24 @@ import { NacosDeleteConfig, NacosDetectVersion, - NacosGetConfig, - NacosHistoryDetail, - NacosHistoryList, - NacosListConfigs, NacosLogin, - NacosNamespaces, NacosPublishConfig, CreateSSHTunnel, StopSSHTunnel, } from "../../wailsjs/go/main/App"; +import { + getConfig as configCenterGetConfig, + getHistoryDetail as configCenterGetHistoryDetail, + listConfigs as configCenterListConfigs, + listHistory as configCenterListHistory, + listNamespaces as configCenterListNamespaces, + type ConfigPage as ConfigCenterConfigPage, + type ConfigRef, + type ConnectionProfile, + type HistoryDetail as ConfigCenterHistoryDetail, + type HistoryPage as ConfigCenterHistoryPage, + type Namespace as ConfigCenterNamespace, +} from "./configCenter"; import type { Connection } from "../store/connections"; // ── SSH 隧道缓存:按连接 id 缓存隧道的本地 baseUrl ── @@ -173,6 +181,105 @@ async function withAuth( } } +async function withProfile( + conn: Connection, + call: (profile: ConnectionProfile) => Promise +): Promise { + const apiVersion = await getVersion(conn); + const accessToken = await getToken(conn); + const baseUrl = await resolveBaseUrl(conn); + try { + return await call(toConnectionProfile(conn, baseUrl, accessToken, apiVersion)); + } catch (e) { + const msg = String(e); + if (conn.username && (msg.includes("403") || msg.includes("token") || msg.includes("code=403"))) { + const fresh = await getToken(conn, true); + return await call(toConnectionProfile(conn, baseUrl, fresh, apiVersion)); + } + throw e; + } +} + +function toConnectionProfile( + conn: Connection, + baseUrl: string, + accessToken: string, + apiVersion: ApiVersion +): ConnectionProfile { + const optional = conn as Connection & { environment?: string; safetyLevel?: string }; + return { + id: conn.id, + name: conn.name, + provider: "nacos", + baseUrl, + accessToken, + apiVersion, + environment: optional.environment ?? "", + safetyLevel: optional.safetyLevel ?? "", + }; +} + +function toConfigRef(conn: Connection, namespace: string, dataId: string, group: string): ConfigRef { + return { + provider: "nacos", + connectionId: conn.id, + namespace, + group, + dataId, + key: "", + }; +} + +function fromConfigCenterNamespace(item: ConfigCenterNamespace): Namespace { + return { + namespace: item.id, + namespaceShowName: item.name, + configCount: item.configCount, + kind: item.kind, + }; +} + +function fromConfigCenterConfigPage(page: ConfigCenterConfigPage): ConfigPage { + return { + totalCount: page.totalCount, + pageNumber: page.pageNumber, + pagesAvailable: page.pagesAvailable, + pageItems: page.pageItems.map((item) => ({ + dataId: item.ref.dataId, + group: item.ref.group, + content: item.content, + configType: item.format, + })), + }; +} + +function fromConfigCenterHistoryPage(page: ConfigCenterHistoryPage): HistoryPage { + return { + totalCount: page.totalCount, + pageNumber: page.pageNumber, + pagesAvailable: page.pagesAvailable, + pageItems: page.pageItems.map((item) => ({ + id: item.id, + dataId: item.ref.dataId, + group: item.ref.group, + opType: item.opType, + lastModifiedTime: item.lastModifiedTime, + })), + }; +} + +function fromConfigCenterHistoryDetail(detail: ConfigCenterHistoryDetail): HistoryDetail { + return { + id: detail.id, + dataId: detail.ref.dataId, + group: detail.ref.group, + content: detail.content, + opType: detail.opType, + createdTime: detail.createdTime, + lastModifiedTime: detail.lastModifiedTime, + }; +} + // ── 业务接口封装 ── export async function testConnection(conn: Connection): Promise { const apiVersion = await getVersion(conn); @@ -181,10 +288,10 @@ export async function testConnection(conn: Connection): Promise { } export async function listNamespaces(conn: Connection): Promise { - const baseUrl = await resolveBaseUrl(conn); - return withAuth(conn, (accessToken, apiVersion) => - NacosNamespaces(baseUrl, accessToken, apiVersion) - ); + return withProfile(conn, async (profile) => { + const items = await configCenterListNamespaces(profile); + return items.map(fromConfigCenterNamespace); + }); } export async function listConfigs( @@ -195,10 +302,10 @@ export async function listConfigs( pageNo: number, pageSize: number ): Promise { - const baseUrl = await resolveBaseUrl(conn); - return withAuth(conn, (accessToken, apiVersion) => - NacosListConfigs(baseUrl, accessToken, apiVersion, namespace, dataId, group, pageNo, pageSize) - ); + return withProfile(conn, async (profile) => { + const page = await configCenterListConfigs(profile, { namespace, dataId, group, pageNo, pageSize }); + return fromConfigCenterConfigPage(page); + }); } export async function getConfig( @@ -207,10 +314,10 @@ export async function getConfig( dataId: string, group: string ): Promise { - const baseUrl = await resolveBaseUrl(conn); - return withAuth(conn, (accessToken, apiVersion) => - NacosGetConfig(baseUrl, accessToken, apiVersion, namespace, dataId, group) - ); + return withProfile(conn, async (profile) => { + const document = await configCenterGetConfig(profile, toConfigRef(conn, namespace, dataId, group)); + return document.content; + }); } export async function listHistory( @@ -221,10 +328,14 @@ export async function listHistory( pageNo: number, pageSize: number ): Promise { - const baseUrl = await resolveBaseUrl(conn); - return withAuth(conn, (accessToken, apiVersion) => - NacosHistoryList(baseUrl, accessToken, apiVersion, namespace, dataId, group, pageNo, pageSize) - ); + return withProfile(conn, async (profile) => { + const page = await configCenterListHistory( + profile, + toConfigRef(conn, namespace, dataId, group), + { pageNo, pageSize } + ); + return fromConfigCenterHistoryPage(page); + }); } export async function publishConfig( @@ -260,10 +371,10 @@ export async function getHistoryDetail( group: string, nid: string ): Promise { - const baseUrl = await resolveBaseUrl(conn); - return withAuth(conn, (accessToken, apiVersion) => - NacosHistoryDetail(baseUrl, accessToken, apiVersion, namespace, dataId, group, nid) - ); + return withProfile(conn, async (profile) => { + const detail = await configCenterGetHistoryDetail(profile, toConfigRef(conn, namespace, dataId, group), nid); + return fromConfigCenterHistoryDetail(detail); + }); } /** 统一格式化 Nacos 时间:v3 是 epoch 毫秒,v1 是字符串,纯数字按时间戳格式化。 */ From b776a32ab809700fed9a710d031ffaad7a891407 Mon Sep 17 00:00:00 2001 From: Adsryen Date: Mon, 29 Jun 2026 11:07:16 +0800 Subject: [PATCH 012/167] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=98=BF?= =?UTF-8?q?=E9=87=8C=E4=BA=91=20MSE=20Nacos=20=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加 MSE Nacos 的 AccessKey 鉴权请求头注入 - 扩展连接模型和 provider profile 传递 MSE 认证信息 - 在连接管理中加入 MSE 类型和 AK/SK 表单入口 - 补充 MSE 鉴权、连接迁移和 API 桥接测试 --- internal/nacos/client.go | 18 ++- internal/nacos/mse_auth.go | 43 ++++++ internal/nacos/mse_auth_test.go | 73 ++++++++++ internal/nacos/request.go | 26 ++++ internal/provider/nacos.go | 35 +++-- internal/provider/nacos_test.go | 50 +++++++ internal/provider/types.go | 34 +++-- src/api/configCenter.test.ts | 5 + src/api/configCenter.ts | 7 + src/api/nacos.test.ts | 40 +++++- src/api/nacos.ts | 13 ++ src/components/ConnectionManager.test.tsx | 26 ++++ src/components/ConnectionManager.tsx | 155 +++++++++++++++++----- src/locales/en-US.json | 11 ++ src/locales/zh-CN.json | 11 ++ src/store/connections.mse.test.ts | 87 ++++++++++++ src/store/connections.ts | 40 +++++- 17 files changed, 621 insertions(+), 53 deletions(-) create mode 100644 internal/nacos/mse_auth.go create mode 100644 internal/nacos/mse_auth_test.go create mode 100644 src/store/connections.mse.test.ts diff --git a/internal/nacos/client.go b/internal/nacos/client.go index 1e4fb80..245bfbf 100644 --- a/internal/nacos/client.go +++ b/internal/nacos/client.go @@ -18,7 +18,9 @@ const timeout = 15 * time.Second // Client 不保存登录态;accessToken 的缓存和刷新由前端 API 层负责,这里只根据传入 // 的 token 和 API 版本发起请求并归一化响应。 type Client struct { - http *http.Client + http *http.Client + mseAuth MSEAuth + clock func() time.Time } // NewClient 创建 Nacos HTTP 客户端。 @@ -32,5 +34,19 @@ func NewClient() *Client { TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // Keep compatibility with existing self-signed Nacos deployments. }, }, + clock: time.Now, } } + +func (c *Client) SetMSEAuth(auth MSEAuth) { + c.mseAuth = auth +} + +func (c *Client) WithMSEAuth(auth MSEAuth) *Client { + next := *c + next.mseAuth = auth + if next.clock == nil { + next.clock = time.Now + } + return &next +} diff --git a/internal/nacos/mse_auth.go b/internal/nacos/mse_auth.go new file mode 100644 index 0000000..45c7531 --- /dev/null +++ b/internal/nacos/mse_auth.go @@ -0,0 +1,43 @@ +package nacos + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "net/http" + "strconv" + "time" +) + +type MSEAuth struct { + AccessKeyID string + AccessKeySecret string + SecurityToken string +} + +func (a MSEAuth) enabled() bool { + return a.AccessKeyID != "" && a.AccessKeySecret != "" +} + +func (c *Client) applyMSEAuth(req *http.Request, namespace string, group string) { + if !c.mseAuth.enabled() { + return + } + clock := c.clock + if clock == nil { + clock = time.Now + } + timestamp := strconv.FormatInt(clock().UnixMilli(), 10) + req.Header.Set("Spas-AccessKey", c.mseAuth.AccessKeyID) + req.Header.Set("Timestamp", timestamp) + req.Header.Set("Spas-Signature", signMSERequest(c.mseAuth.AccessKeySecret, namespace, group, timestamp)) + if c.mseAuth.SecurityToken != "" { + req.Header.Set("Spas-SecurityToken", c.mseAuth.SecurityToken) + } +} + +func signMSERequest(secret string, namespace string, group string, timestamp string) string { + mac := hmac.New(sha1.New, []byte(secret)) + _, _ = mac.Write([]byte(namespace + "+" + group + "+" + timestamp)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/nacos/mse_auth_test.go b/internal/nacos/mse_auth_test.go new file mode 100644 index 0000000..6fdf930 --- /dev/null +++ b/internal/nacos/mse_auth_test.go @@ -0,0 +1,73 @@ +package nacos + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "net/http" + "testing" + "time" +) + +func expectedMSESignature(secret, namespace, group, timestamp string) string { + mac := hmac.New(sha1.New, []byte(secret)) + _, _ = mac.Write([]byte(namespace + "+" + group + "+" + timestamp)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +func TestListConfigsAddsMSEAuthHeaders(t *testing.T) { + now := time.UnixMilli(1710000000123) + server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/cs/configs" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + q := r.URL.Query() + if q.Get("tenant") != "public" || q.Get("group") != "DEFAULT_GROUP" || q.Get("dataId") != "app" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + if r.Header.Get("Spas-AccessKey") != "ak-test" { + t.Fatalf("missing Spas-AccessKey header") + } + if r.Header.Get("Timestamp") != "1710000000123" { + t.Fatalf("Timestamp = %q", r.Header.Get("Timestamp")) + } + wantSignature := expectedMSESignature("sk-test", "public", "DEFAULT_GROUP", "1710000000123") + if r.Header.Get("Spas-Signature") != wantSignature { + t.Fatalf("Spas-Signature = %q, want %q", r.Header.Get("Spas-Signature"), wantSignature) + } + if got := r.Header.Get("Spas-SecurityToken"); got != "sts-token" { + t.Fatalf("Spas-SecurityToken = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"totalCount":0,"pageNumber":1,"pagesAvailable":0,"pageItems":[]}`)) + })) + + client := NewClient() + client.clock = func() time.Time { return now } + client.SetMSEAuth(MSEAuth{ + AccessKeyID: "ak-test", + AccessKeySecret: "sk-test", + SecurityToken: "sts-token", + }) + + if _, err := client.ListConfigs(server.URL, "", "v1", "public", "app", "DEFAULT_GROUP", 1, 20); err != nil { + t.Fatalf("ListConfigs returned error: %v", err) + } +} + +func TestListConfigsSkipsMSEAuthWhenCredentialsAreMissing(t *testing.T) { + server := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Spas-AccessKey") != "" || r.Header.Get("Spas-Signature") != "" || r.Header.Get("Timestamp") != "" { + t.Fatalf("unexpected MSE auth headers: %v", r.Header) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"totalCount":0,"pageNumber":1,"pagesAvailable":0,"pageItems":[]}`)) + })) + + client := NewClient() + client.SetMSEAuth(MSEAuth{AccessKeyID: "ak-test"}) + + if _, err := client.ListConfigs(server.URL, "", "v1", "public", "app", "DEFAULT_GROUP", 1, 20); err != nil { + t.Fatalf("ListConfigs returned error: %v", err) + } +} diff --git a/internal/nacos/request.go b/internal/nacos/request.go index 07a4b8b..be879e6 100644 --- a/internal/nacos/request.go +++ b/internal/nacos/request.go @@ -33,6 +33,7 @@ func (c *Client) getText(baseURL, path string, query url.Values, accessToken str if version == apiV3 && accessToken != "" { req.Header.Set("accessToken", accessToken) } + c.applyMSEAuth(req, requestNamespace(query, nil), requestGroup(query, nil)) resp, err := c.http.Do(req) if err != nil { return "", fmt.Errorf("请求失败: %w", err) @@ -102,6 +103,7 @@ func (c *Client) sendForm(method, baseURL, path string, query url.Values, form u if version == apiV3 && accessToken != "" { req.Header.Set("accessToken", accessToken) } + c.applyMSEAuth(req, requestNamespace(query, form), requestGroup(query, form)) resp, err := c.http.Do(req) if err != nil { switch method { @@ -132,3 +134,27 @@ func readBody(resp *http.Response) (string, error) { } return string(body), nil } + +func requestNamespace(query url.Values, form url.Values) string { + return firstValue(query, form, "tenant", "namespaceId") +} + +func requestGroup(query url.Values, form url.Values) string { + return firstValue(query, form, "group", "groupName") +} + +func firstValue(query url.Values, form url.Values, keys ...string) string { + for _, key := range keys { + if query != nil { + if value := query.Get(key); value != "" { + return value + } + } + if form != nil { + if value := form.Get(key); value != "" { + return value + } + } + } + return "" +} diff --git a/internal/provider/nacos.go b/internal/provider/nacos.go index 1600297..269d4bc 100644 --- a/internal/provider/nacos.go +++ b/internal/provider/nacos.go @@ -14,7 +14,8 @@ func NewNacosProvider(client *nacos.Client) *NacosProvider { } func (p *NacosProvider) ListNamespaces(profile ConnectionProfile) ([]Namespace, error) { - items, err := p.client.Namespaces(profile.BaseURL, profile.AccessToken, profile.APIVersion) + client := p.clientFor(profile) + items, err := client.Namespaces(profile.BaseURL, profile.AccessToken, profile.APIVersion) if err != nil { return nil, err } @@ -31,7 +32,8 @@ func (p *NacosProvider) ListNamespaces(profile ConnectionProfile) ([]Namespace, } func (p *NacosProvider) ListConfigs(profile ConnectionProfile, req ListConfigsRequest) (ConfigPage, error) { - page, err := p.client.ListConfigs( + client := p.clientFor(profile) + page, err := client.ListConfigs( profile.BaseURL, profile.AccessToken, profile.APIVersion, @@ -68,7 +70,8 @@ func (p *NacosProvider) ListConfigs(profile ConnectionProfile, req ListConfigsRe } func (p *NacosProvider) GetConfig(profile ConnectionProfile, ref ConfigRef) (ConfigDocument, error) { - content, err := p.client.GetConfig(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group) + client := p.clientFor(profile) + content, err := client.GetConfig(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group) if err != nil { return ConfigDocument{}, err } @@ -81,17 +84,20 @@ func (p *NacosProvider) GetConfig(profile ConnectionProfile, ref ConfigRef) (Con func (p *NacosProvider) PublishConfig(profile ConnectionProfile, req PublishConfigRequest) error { ref := normalizeRef(profile, req.Ref) - return p.client.PublishConfig(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group, req.Content, req.Format) + client := p.clientFor(profile) + return client.PublishConfig(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group, req.Content, req.Format) } func (p *NacosProvider) DeleteConfig(profile ConnectionProfile, ref ConfigRef) error { ref = normalizeRef(profile, ref) - return p.client.DeleteConfig(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group) + client := p.clientFor(profile) + return client.DeleteConfig(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group) } func (p *NacosProvider) ListHistory(profile ConnectionProfile, ref ConfigRef, page PageRequest) (HistoryPage, error) { ref = normalizeRef(profile, ref) - history, err := p.client.HistoryList(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group, page.PageNo, page.PageSize) + client := p.clientFor(profile) + history, err := client.HistoryList(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group, page.PageNo, page.PageSize) if err != nil { return HistoryPage{}, err } @@ -117,7 +123,8 @@ func (p *NacosProvider) ListHistory(profile ConnectionProfile, ref ConfigRef, pa func (p *NacosProvider) GetHistoryDetail(profile ConnectionProfile, ref ConfigRef, id string) (HistoryDetail, error) { ref = normalizeRef(profile, ref) - detail, err := p.client.HistoryDetail(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group, id) + client := p.clientFor(profile) + detail, err := client.HistoryDetail(profile.BaseURL, profile.AccessToken, profile.APIVersion, ref.Namespace, ref.DataID, ref.Group, id) if err != nil { return HistoryDetail{}, err } @@ -135,10 +142,22 @@ func (p *NacosProvider) GetHistoryDetail(profile ConnectionProfile, ref ConfigRe } func (p *NacosProvider) TestConnection(profile ConnectionProfile) error { - _, err := p.client.Namespaces(profile.BaseURL, profile.AccessToken, profile.APIVersion) + client := p.clientFor(profile) + _, err := client.Namespaces(profile.BaseURL, profile.AccessToken, profile.APIVersion) return err } +func (p *NacosProvider) clientFor(profile ConnectionProfile) *nacos.Client { + if profile.Distribution != DistributionAliyunMSE || profile.AuthType != AuthAliyunAKSK { + return p.client + } + return p.client.WithMSEAuth(nacos.MSEAuth{ + AccessKeyID: profile.AccessKeyID, + AccessKeySecret: profile.AccessKeySecret, + SecurityToken: profile.SecurityToken, + }) +} + func normalizeRef(profile ConnectionProfile, ref ConfigRef) ConfigRef { if ref.Provider == "" { ref.Provider = ProviderNacos diff --git a/internal/provider/nacos_test.go b/internal/provider/nacos_test.go index 427a0e3..7fdb595 100644 --- a/internal/provider/nacos_test.go +++ b/internal/provider/nacos_test.go @@ -1,6 +1,9 @@ package provider import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" "net" "net/http" "net/http/httptest" @@ -10,6 +13,12 @@ import ( "confscope/internal/nacos" ) +func expectedProviderMSESignature(secret, namespace, group, timestamp string) string { + mac := hmac.New(sha1.New, []byte(secret)) + _, _ = mac.Write([]byte(namespace + "+" + group + "+" + timestamp)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + func newProviderIPv4Server(t *testing.T, handler http.Handler) *httptest.Server { t.Helper() @@ -118,6 +127,47 @@ func TestNacosProviderTestsConnectionWithNamespaces(t *testing.T) { } } +func TestNacosProviderPassesMSEAuthToClient(t *testing.T) { + server := newProviderIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/cs/configs" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Spas-AccessKey") != "ak-test" { + t.Fatalf("missing Spas-AccessKey header") + } + if r.Header.Get("Timestamp") == "" { + t.Fatalf("missing Timestamp header") + } + wantSignature := expectedProviderMSESignature("sk-test", "public", "DEFAULT_GROUP", r.Header.Get("Timestamp")) + if r.Header.Get("Spas-Signature") != wantSignature { + t.Fatalf("Spas-Signature = %q, want %q", r.Header.Get("Spas-Signature"), wantSignature) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"totalCount":0,"pageNumber":1,"pagesAvailable":0,"pageItems":[]}`)) + })) + + p := NewNacosProvider(nacos.NewClient()) + _, err := p.ListConfigs(ConnectionProfile{ + ID: "conn-mse", + Provider: ProviderNacos, + BaseURL: server.URL, + APIVersion: "v1", + Distribution: DistributionAliyunMSE, + AuthType: AuthAliyunAKSK, + AccessKeyID: "ak-test", + AccessKeySecret: "sk-test", + }, ListConfigsRequest{ + Namespace: "public", + Group: "DEFAULT_GROUP", + DataID: "app", + PageNo: 1, + PageSize: 20, + }) + if err != nil { + t.Fatalf("ListConfigs returned error: %v", err) + } +} + func TestNacosProviderGetsPublishesDeletesAndReadsHistory(t *testing.T) { server := newProviderIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/internal/provider/types.go b/internal/provider/types.go index 65940db..04a0ed1 100644 --- a/internal/provider/types.go +++ b/internal/provider/types.go @@ -1,6 +1,8 @@ package provider type ProviderType string +type Distribution string +type AuthType string const ( ProviderNacos ProviderType = "nacos" @@ -9,15 +11,31 @@ const ( ProviderLocal ProviderType = "local" ) +const ( + DistributionOpenSource Distribution = "opensource" + DistributionAliyunMSE Distribution = "aliyun-mse" +) + +const ( + AuthNone AuthType = "none" + AuthNacosPassword AuthType = "nacos-password" + AuthAliyunAKSK AuthType = "aliyun-aksk" +) + type ConnectionProfile struct { - ID string `json:"id"` - Name string `json:"name"` - Provider ProviderType `json:"provider"` - BaseURL string `json:"baseUrl"` - AccessToken string `json:"accessToken"` - APIVersion string `json:"apiVersion"` - Environment string `json:"environment"` - SafetyLevel string `json:"safetyLevel"` + ID string `json:"id"` + Name string `json:"name"` + Provider ProviderType `json:"provider"` + Distribution Distribution `json:"distribution"` + AuthType AuthType `json:"authType"` + BaseURL string `json:"baseUrl"` + AccessToken string `json:"accessToken"` + APIVersion string `json:"apiVersion"` + AccessKeyID string `json:"accessKeyId"` + AccessKeySecret string `json:"accessKeySecret"` + SecurityToken string `json:"securityToken"` + Environment string `json:"environment"` + SafetyLevel string `json:"safetyLevel"` } type ConfigRef struct { diff --git a/src/api/configCenter.test.ts b/src/api/configCenter.test.ts index 6e1fda7..4141c5a 100644 --- a/src/api/configCenter.test.ts +++ b/src/api/configCenter.test.ts @@ -31,9 +31,14 @@ describe("config center api", () => { id: "conn-1", name: "dev", provider: "nacos", + distribution: "opensource", + authType: "nacos-password", baseUrl: "http://127.0.0.1:8848/nacos", accessToken: "token", apiVersion: "v3", + accessKeyId: "", + accessKeySecret: "", + securityToken: "", environment: "dev", safetyLevel: "test", }; diff --git a/src/api/configCenter.ts b/src/api/configCenter.ts index 46649fa..820a268 100644 --- a/src/api/configCenter.ts +++ b/src/api/configCenter.ts @@ -10,14 +10,21 @@ import { } from "../../wailsjs/go/main/App"; export type ProviderType = "nacos" | "apollo" | "consul" | "local"; +export type Distribution = "opensource" | "aliyun-mse"; +export type AuthType = "none" | "nacos-password" | "aliyun-aksk"; export interface ConnectionProfile { id: string; name: string; provider: ProviderType; + distribution: Distribution; + authType: AuthType; baseUrl: string; accessToken: string; apiVersion: string; + accessKeyId: string; + accessKeySecret: string; + securityToken: string; environment: string; safetyLevel: string; } diff --git a/src/api/nacos.test.ts b/src/api/nacos.test.ts index 4e0e8b1..83e0f6d 100644 --- a/src/api/nacos.test.ts +++ b/src/api/nacos.test.ts @@ -8,6 +8,7 @@ import { listConfigs, listHistory, listNamespaces, + testConnection, } from "./nacos"; import type { Connection } from "../store/connections"; @@ -37,6 +38,9 @@ function makeConnection(id: string): Connection { return { id, name: "dev", + provider: "nacos", + distribution: "opensource", + authType: "nacos-password", baseUrl: `http://127.0.0.1:8848/${id}/nacos`, username: "nacos", password: "secret", @@ -44,14 +48,19 @@ function makeConnection(id: string): Connection { }; } -function expectedProfile(conn: Connection, accessToken = "token-1") { +function expectedProfile(conn: Connection, accessToken = "token-1", apiVersion = "v3") { return { id: conn.id, name: conn.name, provider: "nacos", + distribution: conn.distribution, + authType: conn.authType, baseUrl: conn.baseUrl, accessToken, - apiVersion: "v3", + apiVersion, + accessKeyId: "", + accessKeySecret: "", + securityToken: "", environment: "", safetyLevel: "", }; @@ -195,4 +204,31 @@ describe("nacos api compatibility bridge", () => { expect(goApp.ConfigCenterGetConfig).toHaveBeenNthCalledWith(1, expectedProfile(conn, "expired-token"), ref); expect(goApp.ConfigCenterGetConfig).toHaveBeenNthCalledWith(2, expectedProfile(conn, "fresh-token"), ref); }); + + it("tests Aliyun MSE Nacos connections through configCenter without Nacos password login", async () => { + const conn: Connection = { + ...makeConnection("conn-mse-test"), + distribution: "aliyun-mse", + authType: "aliyun-aksk", + username: "", + password: "", + accessKeyId: "ak-test", + accessKeySecret: "sk-test", + securityToken: "sts-token", + }; + goApp.ConfigCenterTestConnection.mockResolvedValue(undefined); + + await expect(testConnection(conn)).resolves.toEqual({ accessToken: "", tokenTtl: 0, globalAdmin: false }); + + expect(goApp.NacosLogin).not.toHaveBeenCalled(); + expect(goApp.ConfigCenterTestConnection).toHaveBeenCalledWith({ + ...expectedProfile(conn, "", "v1"), + distribution: "aliyun-mse", + authType: "aliyun-aksk", + accessKeyId: "ak-test", + accessKeySecret: "sk-test", + securityToken: "sts-token", + }); + expect(goApp.NacosDetectVersion).not.toHaveBeenCalled(); + }); }); diff --git a/src/api/nacos.ts b/src/api/nacos.ts index 54149ba..e8a3d9b 100644 --- a/src/api/nacos.ts +++ b/src/api/nacos.ts @@ -12,6 +12,7 @@ import { listConfigs as configCenterListConfigs, listHistory as configCenterListHistory, listNamespaces as configCenterListNamespaces, + testConnection as configCenterTestConnection, type ConfigPage as ConfigCenterConfigPage, type ConfigRef, type ConnectionProfile, @@ -121,6 +122,7 @@ const versionCache = new Map(); /** 探测并缓存连接的 Nacos API 版本(v1=1.x/2.x,v3=3.x)。 */ export async function getVersion(conn: Connection): Promise { + if (conn.authType === "aliyun-aksk") return "v1"; const hit = versionCache.get(conn.baseUrl); if (hit) return hit; const baseUrl = await resolveBaseUrl(conn); @@ -211,9 +213,14 @@ function toConnectionProfile( id: conn.id, name: conn.name, provider: "nacos", + distribution: conn.distribution ?? "opensource", + authType: conn.authType ?? (conn.username ? "nacos-password" : "none"), baseUrl, accessToken, apiVersion, + accessKeyId: conn.accessKeyId ?? "", + accessKeySecret: conn.accessKeySecret ?? "", + securityToken: conn.securityToken ?? "", environment: optional.environment ?? "", safetyLevel: optional.safetyLevel ?? "", }; @@ -282,6 +289,12 @@ function fromConfigCenterHistoryDetail(detail: ConfigCenterHistoryDetail): Histo // ── 业务接口封装 ── export async function testConnection(conn: Connection): Promise { + if (conn.authType === "aliyun-aksk") { + const apiVersion = await getVersion(conn); + const baseUrl = await resolveBaseUrl(conn); + await configCenterTestConnection(toConnectionProfile(conn, baseUrl, "", apiVersion)); + return { accessToken: "", tokenTtl: 0, globalAdmin: false }; + } const apiVersion = await getVersion(conn); const baseUrl = await resolveBaseUrl(conn); return NacosLogin(baseUrl, conn.username, conn.password, apiVersion); diff --git a/src/components/ConnectionManager.test.tsx b/src/components/ConnectionManager.test.tsx index a4e9d6c..9fb28c1 100644 --- a/src/components/ConnectionManager.test.tsx +++ b/src/components/ConnectionManager.test.tsx @@ -151,4 +151,30 @@ describe("ConnectionManager", () => { }); expect(screen.getByTitle("SSH 隧道")).toBeInTheDocument(); }); + + it("saves Aliyun MSE Nacos AccessKey settings", async () => { + const onChange = vi.fn(); + renderManager(onChange); + + fireEvent.change(fieldByLabel("连接名称(标签)"), { target: { value: "mse-dev" } }); + fireEvent.change(screen.getByLabelText("Nacos 类型"), { target: { value: "aliyun-mse" } }); + fireEvent.change(fieldByLabel("目标地址"), { target: { value: "https://mse.example.com/nacos" } }); + fireEvent.change(fieldByLabel("AccessKey ID"), { target: { value: "ak-test" } }); + fireEvent.change(fieldByLabel("AccessKey Secret"), { target: { value: "sk-test" } }); + fireEvent.change(fieldByLabel("Security Token(可选)"), { target: { value: "sts-token" } }); + + fireEvent.click(screen.getByRole("button", { name: "保存" })); + + expect(onChange).toHaveBeenCalledWith([ + expect.objectContaining({ + name: "mse-dev", + distribution: "aliyun-mse", + authType: "aliyun-aksk", + baseUrl: "https://mse.example.com/nacos", + accessKeyId: "ak-test", + accessKeySecret: "sk-test", + securityToken: "sts-token", + }), + ]); + }); }); diff --git a/src/components/ConnectionManager.tsx b/src/components/ConnectionManager.tsx index 0adbaad..b81b1c2 100644 --- a/src/components/ConnectionManager.tsx +++ b/src/components/ConnectionManager.tsx @@ -18,6 +18,9 @@ type Draft = Omit & { id?: string }; const emptyDraft = (): Draft => ({ name: "", + provider: "nacos", + distribution: "opensource", + authType: "nacos-password", baseUrl: "http://localhost:8848/nacos", username: "nacos", password: "", @@ -52,6 +55,17 @@ export default function ConnectionManager({ onClose, onChange }: Props) { setTestMsg(null); }; + const setDistribution = (distribution: Draft["distribution"]) => { + setDraft((d) => ({ + ...d, + distribution, + authType: distribution === "aliyun-mse" ? "aliyun-aksk" : "nacos-password", + username: distribution === "aliyun-mse" ? "" : d.username || "nacos", + password: distribution === "aliyun-mse" ? "" : d.password, + })); + setTestMsg(null); + }; + const setSSH = (patch: Partial) => { setDraft((d) => ({ ...d, @@ -117,7 +131,7 @@ export default function ConnectionManager({ onClose, onChange }: Props) { setTesting(true); setTestMsg(null); try { - if (draft.username) { + if (draft.authType === "aliyun-aksk" || draft.username) { const r = await testConnection({ ...(draft as Connection), id: draft.id ?? "test" }); setTestMsg({ ok: true, @@ -211,6 +225,31 @@ export default function ConnectionManager({ onClose, onChange }: Props) { onChange={(e) => set({ name: e.target.value })} /> +
    + + +
    -
    - -