From fe9d02734fd97a1b9f08d50df787509ee2cda997 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 22 Apr 2026 16:42:30 +0800 Subject: [PATCH 01/32] =?UTF-8?q?=F0=9F=93=9D=20docs(jvm):=20=E6=B2=89?= =?UTF-8?q?=E6=B7=80=20JVM=20=E7=BC=93=E5=AD=98=E5=8F=AF=E8=A7=86=E5=8C=96?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 JVM Connector 的统一入口、多 Provider 与能力协商方案 - 明确 JMX 与 Management Endpoint 为 MVP,Agent 仅保留扩展位 - 定义资源模型、AI 协同、Guard Layer、审计与分期边界 - 同步需求追踪中的范围、风险、决策与验证记录 --- ...6-04-22-jvm-cache-visual-editing-design.md | 483 ++++++++++++++++++ .../需求进度追踪-JVM缓存可视化编辑-20260422.md | 86 ++++ 2 files changed, 569 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md create mode 100644 docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md diff --git a/docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md b/docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md new file mode 100644 index 0000000..e808b64 --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md @@ -0,0 +1,483 @@ +# JVM 缓存可视化编辑设计 + +## 1. 背景 + +当前用户在公司 Java 项目中经常把缓存或运行时状态直接保存在 JVM 内存中。出现数据脏值、缓存穿透、临时纠偏或排障时,通常只有两种方式: + +- 为特定业务临时补管理接口 +- 重启应用并依赖重新初始化 + +这两种方式都存在明显问题: + +- 临时接口会污染业务代码,并带来后续维护和权限风险 +- 重启应用成本高,且不适合用于精确修复单个缓存项 + +GoNavi 现有已具备三类可复用基础: + +- 统一连接与工作台能力:`frontend/src/components/ConnectionModal.tsx`、`frontend/src/components/Sidebar.tsx`、`frontend/src/components/TabManager.tsx` +- 独立运行时能力样板:Redis 通过 `internal/app/methods_redis.go` 和专用前端视图实现,不依赖 SQL `Database` 抽象 +- AI 与日志能力底座:`frontend/src/components/AIChatPanel.tsx`、`frontend/src/components/QueryEditor.tsx`、`frontend/src/components/LogPanel.tsx` + +因此,GoNavi 有条件扩展出 JVM 运行时连接与受控编辑能力,但不能简单把该需求理解为“新数据库驱动”。 + +## 2. 目标 + +- 为 GoNavi 增加统一的 `JVM Connector` 子系统,用于连接和浏览 Java 服务的运行时缓存/管理对象 +- 在同一套 UI 下支持多种接入模式,并根据目标 JVM 能力自动协商或手动切换 +- 提供结构化的缓存浏览、值检查、受控修改、操作预览和审计记录 +- 允许 AI 参与解释、分析和生成修改计划,但不默认开放 AI 自动执行 +- 尽量避免强依赖 `-javaagent` 或运行时动态 attach,适配企业内对生产进程注入普遍敏感的环境 + +## 3. 非目标 + +- 不承诺“任意 JVM 内任意对象均可直接读写” +- 不在首期支持任意 Java 表达式执行、任意反射路径写值或任意 classloader 深度探测 +- 不把 JVM 功能强行塞进现有 SQL `Database` / driver-agent 抽象 +- 不在首期承诺通过 Agent 模式支持所有缓存框架 +- 不绕过目标服务现有认证、鉴权和网络边界 + +## 4. 需求与约束 + +### 4.1 需求清单 + +- 统一配置 JVM 连接 +- 探测当前 JVM 支持的接入模式与可用能力 +- 浏览缓存空间、管理对象和受控操作 +- 查看值快照与元数据 +- 执行受控修改,并提供 before/after 预览 +- 将操作结果写入审计记录 +- 支持 AI 对资源结构和修改方案进行分析 + +### 4.2 已确认约束 + +- 用户倾向通用型产品形态,但目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach +- 企业环境下,稳定性与安全性优先级高于“黑科技式通用能力” +- 一期应优先基于标准协议和业务可控接入面,而不是侵入式 runtime 操作 + +## 5. 现状分析 + +### 5.1 GoNavi 架构启示 + +- `internal/db/database.go` 面向标准化数据源 CRUD,适合 SQL 类资源 +- `internal/app/methods_redis.go` 证明 GoNavi 已支持“独立运行时系统能力线” +- `frontend/src/components/RedisViewer.tsx` 与 `frontend/src/components/RedisCommandEditor.tsx` 提供了树形浏览、结构化值编辑和控制台交互样板 +- `frontend/src/components/AIChatPanel.tsx` 与 `frontend/src/components/ai/AIMessageBubble.tsx` 已具备 AI 交互和危险执行确认能力 + +### 5.2 结论 + +JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector` 子系统,而不是复用 SQL `Database` 接口。 + +## 6. 方案比较 + +### 方案 A:单一路径通用 Agent + +- 描述:统一要求目标 JVM 通过 `-javaagent` 或运行时 attach 暴露运行时对象访问能力 +- 优点: + - 理论能力上限最高 + - 可覆盖更多自研缓存和深层对象 +- 缺点: + - 与已知企业约束直接冲突 + - 风险最高,部署与安全成本高 + - 与首期产品化目标不匹配 + +### 方案 B:多接入模式 + 能力协商 + +- 描述:统一做 `JVM Connector`,底层同时支持 `JMX`、`Management Endpoint`、`Agent` +- 优点: + - 产品形态统一 + - 能根据目标 JVM 能力降级 + - 可先做低风险路径,后续再扩展高级模式 +- 缺点: + - 不同模式能力不一致,UI 与权限模型更复杂 + +### 方案 C:只做业务侧管理端点 + +- 描述:完全放弃通用接入,只提供官方 Starter/管理端点接入 +- 优点: + - 结构最稳,AI 最容易接入 + - 权限、审计、预览、回滚最好做 +- 缺点: + - 不满足“尽量通用”的产品定位 + - 无法覆盖仅开放 JMX 的存量系统 + +## 7. 选型 + +采用方案 B,首期 MVP 聚焦: + +- `JMX Provider` +- `Management Endpoint Provider` + +`Agent Provider` 只保留扩展位与接口约束,不纳入首期交付承诺。 + +## 8. 目标架构 + +### 8.1 总体结构 + +新增统一的 `JVM Connector` 子系统,分为五层: + +- `Connection Layer` + - 新增 `jvm` 连接类型 + - 保存目标地址、认证、允许模式、首选模式、环境标签等配置 +- `Capability Layer` + - 建立连接后探测当前支持的 provider 与能力矩阵 +- `Provider Layer` + - `JMX Provider` + - `Management Endpoint Provider` + - `Agent Provider`(预留) +- `Resource Layer` + - 将不同来源统一映射为结构化资源 +- `Guard Layer` + - 统一负责预览、确认、审计、回读验证、错误归一化 + +### 8.2 设计原则 + +- UI 统一,协议多态 +- 读写分离,修改必须经过 Guard Layer +- provider 不得自行绕过权限与审计链路 +- 能力不足时显式降级,不提供“看似可用、实际不可执行”的假入口 + +## 9. Provider 设计 + +### 9.1 JMX Provider + +- 负责: + - 建立 JMX/RMI 连接 + - 发现 MBean + - 读取属性 + - 调用白名单操作 + - 写入允许修改的白名单属性 +- 适用场景: + - 目标 JVM 已开放 JMX + - 缓存或管理对象已暴露为 MBean +- 特点: + - 低侵入、标准化、可落地 + - key/value 级资源能力通常有限 + +### 9.2 Management Endpoint Provider + +- 负责: + - 调用业务服务暴露的 GoNavi 管理端点或 Starter + - 返回结构化缓存资源、元数据和受控动作 + - 提供修改预览与回滚信息 +- 适用场景: + - 业务方愿意接入轻量 Starter/管理端点 + - 需要更强的 key/value 级浏览与修改能力 +- 特点: + - 最适合产品化和 AI 协同 + - 权限、脱敏、审计、回滚最容易做 + +### 9.3 Agent Provider + +- 负责: + - 在特定环境下提供更深层的运行时对象探测与写入能力 +- 定位: + - 高级模式 + - 不默认启用 + - 不在首期交付 + +## 10. 统一资源模型 + +建议统一抽象以下资源: + +- `runtime` + - 目标 JVM 实例 +- `cacheNamespace` + - 缓存空间,如某个 CacheManager 下的 cacheName +- `cacheEntry` + - 具体缓存项 key/value +- `managedBean` + - 可读写的托管对象或 MBean +- `operation` + - 受控操作,如 `evict`、`put`、`refresh`、`clear` +- `auditRecord` + - 每次读写与 AI 建议的审计记录 + +统一资源模型要求: + +- 每个资源都有稳定 ID、显示名、provider 来源、能力标签、敏感级别 +- 值快照必须区分原始值、展示值和可编辑值 +- 资源定位信息必须可写入审计 + +## 11. AI 协同设计 + +### 11.1 AI 的角色 + +AI 在 JVM 场景中只能作为“受控编排者”,不能作为直接执行者。 + +AI 可以: + +- 解释缓存/Bean 的结构和当前状态 +- 生成筛选条件和定位建议 +- 生成结构化修改计划 +- 生成风险说明和回滚建议 +- 对执行前后结果做对比分析 + +AI 不应默认做: + +- 直接执行 JVM 修改 +- 自由生成任意脚本并直写内存 +- 绕过人工确认直接调用 provider + +### 11.2 AI 输出形态 + +AI 不直接输出脚本,而输出结构化变更计划,例如: + +```json +{ + "targetType": "cacheEntry", + "selector": { + "namespace": "userSessionCache", + "key": "user:1001" + }, + "action": "updateValue", + "payload": { + "format": "json", + "value": { + "status": "ACTIVE" + } + }, + "reason": "修复错误缓存态" +} +``` + +### 11.3 AI 执行链路 + +1. AI 读取结构化上下文 +2. AI 产出结构化变更计划 +3. Guard Layer 校验目标资源、能力和权限 +4. UI 展示修改预览与风险提示 +5. 用户确认 +6. provider 执行 +7. 系统回读验证并写审计 + +### 11.4 一期 AI 边界 + +- 支持 AI 分析资源 +- 支持 AI 生成修改计划 +- 不默认支持 AI 自动执行修改 + +## 12. 页面与交互设计 + +### 12.1 连接层 + +在 `ConnectionModal` 中新增 `JVM` 类型,建议配置: + +- 连接名称 +- 目标地址/端口 +- 认证信息 +- 允许模式列表 +- 首选模式 +- 环境标签(DEV/UAT/PROD) +- 默认权限级别(只读/读写) + +### 12.2 侧边栏 + +展示结构: + +- 连接 +- 模式能力 +- 资源类型 +- `cacheNamespace` / `managedBean` / `operation` + +每个连接或节点显示能力徽标,例如: + +- `JMX` +- `Endpoint` +- `Agent(禁用)` +- `只读` +- `可写` + +### 12.3 主工作区 Tab + +建议新增以下 Tab 类型: + +- `概览` +- `资源浏览` +- `值检查器` +- `修改预览` +- `AI 助手` +- `审计记录` + +### 12.4 标准操作流 + +1. 用户连接 JVM +2. 系统探测 provider 能力 +3. 用户选择资源并读取快照 +4. 用户手工修改或让 AI 生成计划 +5. 系统生成 before/after 预览 +6. 用户二次确认 +7. provider 执行 +8. 系统回读验证 +9. 写入审计与操作日志 + +## 13. 权限与审计 + +### 13.1 权限模型 + +权限建议分四层: + +- `连接级` + - 决定默认 `readonly` / `readwrite` +- `模式级` + - 决定某 provider 支持哪些动作 +- `资源级` + - 某些资源永远只读 +- `环境级` + - `PROD` 默认强制二次确认,禁用 AI 自动执行 + +### 13.2 审计要求 + +JVM 审计日志不应复用 SQL 日志数据结构,但可以复用现有 LogPanel 样式。 + +建议记录: + +- 连接 ID / 名称 +- provider 类型 +- 资源定位信息 +- 动作类型 +- 修改原因 +- AI 是否参与 +- 执行前摘要 +- 执行后摘要 +- 结果状态 +- 耗时 +- 错误信息 + +建议本地独立落盘为 `jvm_audit.jsonl` 或等价结构,不混入 `sqlLogs`。 + +## 14. 错误处理与兼容性边界 + +### 14.1 错误分层 + +- `连接层失败` + - 认证失败、证书失败、JMX/RMI 不通、端点 401/403 +- `能力层失败` + - 连接成功但不支持列 key、写值或批量操作 +- `执行层失败` + - 资源不存在、值格式非法、provider 拒绝写入 +- `验证层失败` + - 执行返回成功但回读校验不一致 + +所有错误都应显式标明是哪个 provider、哪一层失败,避免泛化为“修改失败”。 + +### 14.2 首期兼容性承诺 + +优先承诺以下边界: + +- Java 8 / 11 / 17 / 21 +- Spring Boot 服务优先 +- JMX 标准 MBean +- Management Endpoint 模式下优先支持: + - Caffeine + - Ehcache + - Guava Cache + - Spring Cache 抽象下可枚举缓存 + - 接入 GoNavi Starter 的自研缓存 +- 值类型首期优先: + - string + - number + - boolean + - JSON object / JSON array + - map / list 的结构化展示 + +### 14.3 首期不承诺 + +- 任意 Java 对象深度反射编辑 +- 无类型信息的二进制对象直接改写 +- 跨 classloader 任意对象定位 +- 生产环境默认开放批量危险写入 + +## 15. MVP 分期 + +### Phase 1:连接与只读探测 + +- JVM 连接类型 +- JMX / Endpoint 能力探测 +- 资源树浏览 +- 值查看 +- 概览页与能力徽标 +- 不开放写入 + +### Phase 2:受控修改与审计 + +- 白名单资源写入 +- before/after 预览 +- 二次确认 +- 审计日志 +- 回读验证 +- 环境级保护策略 + +### Phase 3:AI 协同 + +- AI 解释资源 +- AI 生成修改计划 +- AI 风险分析 +- AI 回滚建议 +- 仍默认不允许 AI 自动执行 + +### Phase 4:高级模式预留 + +- Agent Provider SPI +- 更深层 runtime 能力 +- 仅在特殊环境启用 + +## 16. 验证策略 + +### 16.1 功能验证 + +- 能连接 JMX 目标 +- 能连接 Endpoint 目标 +- 能列出缓存空间 +- 能查看 key/value +- 能完成受控修改并回读成功 + +### 16.2 兼容性验证 + +- Java 8 / 11 / 17 / 21 +- 本地、容器、K8s 内网场景 +- 开启认证 / 不开启认证 +- 仅 JMX、仅 Endpoint、双模式并存 + +### 16.3 安全验证 + +- 只读连接无法写入 +- `PROD` 环境必须二次确认 +- AI 无法绕过人工确认直接执行 +- 审计日志完整记录修改链路 + +### 16.4 稳定性验证 + +- 目标 JVM 不可达时 UI 不假死 +- 资源树大数量时支持分页或懒加载 +- 回读失败时标识“不确定状态” +- provider 超时、部分失败、降级路径清晰 + +## 17. 风险与缓解 + +### 17.1 风险 + +- 多 provider 模式会带来能力不一致,用户可能误解“所有 JVM 都能随便改” +- JMX 模式的 key/value 级能力可能明显不足 +- 管理端点模式需要业务接入,推广成本高于纯客户端方案 +- 若未来引入 Agent 模式,可能引入新的安全审核和兼容性成本 + +### 17.2 缓解 + +- 在 UI 中显式展示能力矩阵和当前 provider 来源 +- 所有修改都强制经过预览、确认与审计 +- 首期将“通用”定义为“统一入口 + 多模式协商”,而不是“单通道万能能力” +- Agent 仅作为高级扩展位,避免污染 MVP 边界 + +## 18. 最终结论 + +JVM 缓存可视化编辑能力在 GoNavi 中具备落地基础,但必须采用“统一入口、多 provider、能力协商、强 Guard Layer”的产品化方案。 + +推荐结论如下: + +- 新增独立的 `JVM Connector` 子系统 +- 首期支持 `JMX + Management Endpoint` +- `Agent` 仅保留扩展位 +- AI 首期支持分析与生成修改计划,不默认开放自动执行 +- 所有修改必须经过预览、确认、审计和回读验证 + +这一路径能够在兼顾企业安全约束的前提下,为用户提供可持续演进的 JVM 运行时缓存治理能力。 diff --git a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md new file mode 100644 index 0000000..afb3cc0 --- /dev/null +++ b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md @@ -0,0 +1,86 @@ +# 需求进度追踪 - JVM缓存可视化编辑 + +## 1. 需求摘要 +- 需求名称:JVM缓存可视化编辑 +- 提出日期:2026-04-22 +- 负责人:Codex +- 目标:评估并设计 GoNavi 连接 Java JVM、可视化查看并修改 JVM 内缓存/对象值的能力,降低“改缓存只能写接口或重启应用”的运维与排障成本 +- 非目标:不直接承诺覆盖所有 Java 框架/所有对象类型,不在需求未澄清前进入实现,不绕过目标应用现有安全控制 + +## 2. 范围与验收 +- 范围: + - 评估 GoNavi 现有驱动代理模型是否可扩展到 JVM 运行时连接 + - 明确 JVM 缓存可视化编辑的接入方式、权限边界与最小可行产品范围 + - 输出推荐方案、关键风险与后续实施方向 +- 验收标准: + - 明确该需求在 GoNavi 内“能不能做” + - 明确推荐接入模式、前后端分层与安全边界 + - 明确首期建议支持的缓存类型/Java 技术栈范围 +- 依赖与约束: + - 需复用 GoNavi 当前 Wails + React + driver-agent 架构 + - 新能力不得破坏现有数据库/Redis 工作流 + - 高风险写操作必须具备明确鉴权、审计与回滚思路 + +## 3. 里程碑与进度 +- [x] 阶段 1(需求澄清):完成 +- [x] 阶段 2(影响分析):完成 +- [x] 阶段 3(方案设计):完成(已形成正式设计文档) +- [ ] 阶段 4(实施计划): +- [ ] 阶段 5(实现与自检): +- [ ] 阶段 6(评审与交付): +- [ ] 阶段 7(发布与观察): + +## 4. 变更清单 +- 已完成: + - 确认 GoNavi 当前存在统一驱动接口与可选 driver-agent 机制 + - 确认前端已有 Redis 结构化浏览、命令编辑器、Monaco 编辑器、DataGrid 编辑能力可复用 + - 初步判断 JVM 运行时对象编辑不适合直接复用 SQL/Database 抽象,需新增非数据库协议层 + - 用户已确认目标方向为“通用型 JVM 接入” + - 用户已确认升级到完整模式,开始高风险架构评估 + - 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach + - 已形成 JVM 缓存可视化编辑正式设计文档 +- 进行中: + - 等待用户审阅正式设计文档 +- 待处理: + - 基于设计文档输出实施计划 + - 进入 MVP 分期实施与验证 + +## 5. 风险与阻塞 +- 风险: + - 直接修改 JVM 内对象属于高风险运行时操作,误改可能造成业务状态污染 + - 不同缓存框架(Caffeine/Ehcache/Guava/自研 Map)缺少统一标准协议 + - 若依赖 attach agent 或表达式执行,需严格控制安全边界与可观测性 + - 若目标 JVM 不允许预埋或动态注入 Agent,则“通用型”能力边界会明显收缩 + - 多接入模式会带来能力不一致问题,UI 与权限模型必须显式展示“当前模式支持什么/不支持什么” +- 阻塞: + - 目标应用技术栈、缓存框架与接入约束尚未明确 +- 缓解措施: + - 优先收敛到标准接入面(JMX / Spring Actuator / Java Agent 三选一) + - 首期只支持白名单对象类型与受控写操作 + - 要求变更审计、预览、确认与失败回滚路径 + +## 6. 决策记录 +- 决策 1:先做可行性评估与方案设计,不直接进入实现 +- 决策 2:默认优先复用 GoNavi 现有 driver-agent 与前端编辑器能力,避免侵入式重构主流程 +- 决策 3:已按完整模式推进,后续方案将优先评估通用 Agent 路径是否成立 +- 决策 4:由于目标服务大概率不允许 agent/attach,后续推荐方向转为“多接入模式 + 能力协商” + +## 7. 验证记录 +- 验证项: + - GoNavi 驱动代理机制核查 + - GoNavi 现有 Redis/编辑器/UI 复用能力核查 + - JVM Connector 正式设计文档自检 +- 结果: + - 已确认存在可复用的连接桥接与编辑器基础设施 + - 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突 +- 证据(日志/截图/链接): + - `cmd/optional-driver-agent/main.go` + - `internal/db/database.go` + - `frontend/src/components/RedisViewer.tsx` + - `frontend/src/components/RedisCommandEditor.tsx` + - `frontend/src/components/QueryEditor.tsx` + - `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md` + +## 8. 下一步 +- 下一步行动:请用户审阅正式设计文档,确认是否需要修改后再进入实施计划阶段 +- 负责人:Codex From f58427020903f5f980f8db5ce0c653ee0bedb297 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 22 Apr 2026 16:42:30 +0800 Subject: [PATCH 02/32] =?UTF-8?q?=F0=9F=93=9D=20docs(jvm):=20=E6=B2=89?= =?UTF-8?q?=E6=B7=80=20JVM=20Connector=20MVP=20=E5=AE=9E=E6=96=BD=E8=AE=A1?= =?UTF-8?q?=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按 Task 拆分连接契约、Provider、前端工作台与 AI 集成实现路径 - 明确前后端文件边界、TDD 顺序、Wails 绑定刷新与回归命令 - 补齐共享 DTO、provider factory 和审计落盘等关键实现细节 - 同步需求追踪进入实施计划阶段 --- .../plans/2026-04-22-jvm-connector-mvp.md | 1432 +++++++++++++++++ .../需求进度追踪-JVM缓存可视化编辑-20260422.md | 11 +- 2 files changed, 1439 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md diff --git a/docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md b/docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md new file mode 100644 index 0000000..ce3665d --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md @@ -0,0 +1,1432 @@ +# JVM Connector MVP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 GoNavi 中落地 JVM Connector MVP,首期支持 JMX + Management Endpoint 两种接入模式,覆盖连接测试、能力探测、资源浏览、受控预览/写入、审计记录和 AI 变更计划生成。 + +**Architecture:** 复用 GoNavi 现有的“Redis 式独立能力线”,新增 `internal/jvm` 后端包和一组 JVM 专用前端组件,而不是复用 SQL `Database` 接口。所有写操作统一通过 Guard + Preview + Audit 链路,AI 只生成结构化变更计划,不直接执行。 + +**Tech Stack:** Go 1.24, Wails v2, React 18, TypeScript, Zustand, Ant Design 5, Vitest + +--- + +## File Map + +- Modify: `internal/connection/types.go` + - 为 `ConnectionConfig` 增加 `JVMConfig`、JMX/Endpoint 可选配置,保持现有连接持久化链路可复用。 +- Create: `internal/jvm/types.go` + - JVM 能力、资源、值快照、变更预览、审计记录等 DTO。 +- Create: `internal/jvm/config.go` + - 运行模式归一化、只读/生产保护、模式可用性判断。 +- Create: `internal/jvm/provider.go` + - Provider 接口、注册与按模式分发。 +- Create: `internal/jvm/jmx_provider.go` + - JMX Provider 实现。 +- Create: `internal/jvm/http_provider.go` + - Management Endpoint Provider 实现。 +- Create: `internal/jvm/guard.go` + - 写入前预览、权限保护和风险等级判断。 +- Create: `internal/jvm/audit_store.go` + - JSONL 审计落盘与查询。 +- Create: `internal/jvm/config_test.go` + - JVM 配置归一化和保护规则测试。 +- Create: `internal/app/methods_jvm.go` + - Wails 暴露的 JVM 读写方法。 +- Create: `internal/app/methods_jvm_test.go` + - App 层对 fake provider 的集成测试。 +- Modify: `frontend/src/types.ts` + - 新增 JVM 连接配置、资源模型、TabData 扩展。 +- Create: `frontend/src/utils/jvmConnectionConfig.ts` + - JVM 连接默认值、表单转配置、模式标签和默认端口。 +- Create: `frontend/src/utils/jvmConnectionConfig.test.ts` + - JVM 表单配置转换测试。 +- Create: `frontend/src/utils/jvmRuntimePresentation.ts` + - 模式徽标、审计风险文案、JVM tab 标题构造。 +- Create: `frontend/src/utils/jvmRuntimePresentation.test.ts` + - 展示层纯函数测试。 +- Modify: `frontend/src/components/DatabaseIcons.tsx` + - 增加 JVM 图标映射。 +- Modify: `frontend/src/components/ConnectionModal.tsx` + - 新增 JVM 连接类型与表单。 +- Modify: `frontend/src/components/Sidebar.tsx` + - 新增 JVM 节点、懒加载和资源打开动作。 +- Modify: `frontend/src/components/TabManager.tsx` + - 路由 JVM 新 Tab。 +- Create: `frontend/src/components/JVMOverview.tsx` + - 展示连接能力矩阵与风险提示。 +- Create: `frontend/src/components/JVMResourceBrowser.tsx` + - 资源树、值快照和写入入口。 +- Create: `frontend/src/components/JVMAuditViewer.tsx` + - JVM 审计记录查看器。 +- Create: `frontend/src/components/jvm/JVMModeBadge.tsx` + - 统一渲染 `JMX` / `Endpoint` / `只读` / `可写` 徽标。 +- Create: `frontend/src/components/jvm/JVMChangePreviewModal.tsx` + - 写入预览与确认对话框。 +- Create: `frontend/src/utils/jvmAiPlan.ts` + - 解析和校验 AI 结构化变更计划。 +- Create: `frontend/src/utils/jvmAiPlan.test.ts` + - AI 计划解析测试。 +- Modify: `frontend/src/components/AIChatPanel.tsx` + - 向 JVM tab 注入上下文与推荐 prompt。 +- Modify: `frontend/src/components/ai/AIMessageBubble.tsx` + - 检测 JVM 结构化计划,提供“应用到预览”按钮。 +- Regenerate: `frontend/wailsjs/go/app/App.d.ts`, `frontend/wailsjs/go/app/App.js`, `frontend/wailsjs/go/models.ts` + - 由 Wails 命令生成,不手工编辑。 +- Modify: `docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md` + - 记录计划文件、实施进度和验证证据。 + +## Task 1: 定义 JVM 共享契约与配置归一化 + +**Files:** +- Create: `internal/jvm/types.go` +- Create: `internal/jvm/config.go` +- Create: `internal/jvm/config_test.go` +- Modify: `internal/connection/types.go` +- Create: `frontend/src/utils/jvmConnectionConfig.ts` +- Create: `frontend/src/utils/jvmConnectionConfig.test.ts` +- Modify: `frontend/src/types.ts` + +- [ ] **Step 1: 写后端失败测试,锁定 JVM 模式归一化和默认保护规则** + +```go +package jvm + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestNormalizeConnectionConfigDefaultsToReadOnlyJMX(t *testing.T) { + raw := connection.ConnectionConfig{ + Type: "jvm", + Host: "orders-prod.internal", + Port: 9010, + } + + got, err := NormalizeConnectionConfig(raw) + if err != nil { + t.Fatalf("NormalizeConnectionConfig returned error: %v", err) + } + if !got.JVM.ReadOnly { + t.Fatalf("expected JVM connection to default to readOnly") + } + if got.JVM.PreferredMode != ModeJMX { + t.Fatalf("expected preferred mode %q, got %q", ModeJMX, got.JVM.PreferredMode) + } + if len(got.JVM.AllowedModes) != 1 || got.JVM.AllowedModes[0] != ModeJMX { + t.Fatalf("expected allowed modes [jmx], got %#v", got.JVM.AllowedModes) + } + if got.JVM.JMX.Port != 9010 { + t.Fatalf("expected JMX port to inherit root port 9010, got %d", got.JVM.JMX.Port) + } +} + +func TestNormalizeConnectionConfigFallsBackToFirstAllowedMode(t *testing.T) { + raw := connection.ConnectionConfig{ + Type: "jvm", + Host: "cache-svc.internal", + JVM: connection.JVMConfig{ + AllowedModes: []string{ModeEndpoint, ModeJMX}, + PreferredMode: ModeAgent, + Endpoint: connection.JVMEndpointConfig{ + Enabled: true, + BaseURL: "https://cache-svc.internal/manage/jvm", + }, + }, + } + + got, err := NormalizeConnectionConfig(raw) + if err != nil { + t.Fatalf("NormalizeConnectionConfig returned error: %v", err) + } + if got.JVM.PreferredMode != ModeEndpoint { + t.Fatalf("expected preferred mode %q, got %q", ModeEndpoint, got.JVM.PreferredMode) + } +} +``` + +- [ ] **Step 2: 运行测试,确认 `internal/jvm` 还不存在导致失败** + +Run: `go test ./internal/jvm -run TestNormalizeConnectionConfig -count=1` + +Expected: FAIL,提示 `GoNavi-Wails/internal/jvm` 尚不存在或 `NormalizeConnectionConfig` 未定义。 + +- [ ] **Step 3: 实现后端 JVM 类型与归一化规则** + +```go +package connection + +type JVMJMXConfig struct { + Enabled bool `json:"enabled,omitempty"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + DomainAllowlist []string `json:"domainAllowlist,omitempty"` +} + +type JVMEndpointConfig struct { + Enabled bool `json:"enabled,omitempty"` + BaseURL string `json:"baseUrl,omitempty"` + APIKey string `json:"apiKey,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` +} + +type JVMConfig struct { + Environment string `json:"environment,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + AllowedModes []string `json:"allowedModes,omitempty"` + PreferredMode string `json:"preferredMode,omitempty"` + JMX JVMJMXConfig `json:"jmx,omitempty"` + Endpoint JVMEndpointConfig `json:"endpoint,omitempty"` +} +``` + +```go +package jvm + +import ( + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +const ( + ModeJMX = "jmx" + ModeEndpoint = "endpoint" + ModeAgent = "agent" + EnvPROD = "prod" +) + +type Capability struct { + Mode string `json:"mode"` + CanBrowse bool `json:"canBrowse"` + CanWrite bool `json:"canWrite"` + CanPreview bool `json:"canPreview"` + Reason string `json:"reason,omitempty"` + DisplayLabel string `json:"displayLabel"` +} + +type ResourceSummary struct { + ID string `json:"id"` + ParentID string `json:"parentId,omitempty"` + Kind string `json:"kind"` + Name string `json:"name"` + Path string `json:"path"` + ProviderMode string `json:"providerMode"` + CanRead bool `json:"canRead"` + CanWrite bool `json:"canWrite"` + HasChildren bool `json:"hasChildren"` + Sensitive bool `json:"sensitive,omitempty"` +} + +type ValueSnapshot struct { + ResourceID string `json:"resourceId"` + Kind string `json:"kind"` + Format string `json:"format"` + Version string `json:"version,omitempty"` + Value interface{} `json:"value"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type ChangeRequest struct { + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + ExpectedVersion string `json:"expectedVersion,omitempty"` + Payload map[string]any `json:"payload,omitempty"` +} + +type ChangePreview struct { + Allowed bool `json:"allowed"` + RequiresConfirmation bool `json:"requiresConfirmation,omitempty"` + Summary string `json:"summary"` + RiskLevel string `json:"riskLevel"` + BlockingReason string `json:"blockingReason,omitempty"` + Before ValueSnapshot `json:"before"` + After ValueSnapshot `json:"after"` +} + +type ApplyResult struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + UpdatedValue ValueSnapshot `json:"updatedValue"` +} + +type AuditRecord struct { + Timestamp int64 `json:"timestamp"` + ConnectionID string `json:"connectionId"` + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + Result string `json:"result"` +} + +func NormalizeConnectionConfig(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + cfg := raw + if strings.TrimSpace(cfg.Type) != "jvm" { + return connection.ConnectionConfig{}, fmt.Errorf("unexpected connection type: %s", cfg.Type) + } + cfg.Type = "jvm" + cfg.JVM.Environment = strings.ToLower(strings.TrimSpace(cfg.JVM.Environment)) + if cfg.JVM.ReadOnly == false { + cfg.JVM.ReadOnly = true + } + if cfg.JVM.JMX.Port <= 0 { + cfg.JVM.JMX.Port = cfg.Port + } + if len(cfg.JVM.AllowedModes) == 0 { + cfg.JVM.AllowedModes = []string{ModeJMX} + } + cfg.JVM.AllowedModes = normalizeModes(cfg.JVM.AllowedModes) + if cfg.JVM.PreferredMode == "" || !containsMode(cfg.JVM.AllowedModes, cfg.JVM.PreferredMode) { + cfg.JVM.PreferredMode = cfg.JVM.AllowedModes[0] + } + return cfg, nil +} + +func normalizeModes(input []string) []string { + result := make([]string, 0, len(input)) + seen := map[string]struct{}{} + for _, item := range input { + mode := strings.ToLower(strings.TrimSpace(item)) + switch mode { + case ModeJMX, ModeEndpoint, ModeAgent: + default: + continue + } + if _, ok := seen[mode]; ok { + continue + } + seen[mode] = struct{}{} + result = append(result, mode) + } + if len(result) == 0 { + return []string{ModeJMX} + } + return result +} + +func containsMode(items []string, target string) bool { + target = strings.ToLower(strings.TrimSpace(target)) + for _, item := range items { + if strings.ToLower(strings.TrimSpace(item)) == target { + return true + } + } + return false +} +``` + +- [ ] **Step 4: 写前端 JVM 默认值与配置转换的失败测试** + +```ts +import { describe, expect, it } from 'vitest'; +import { buildDefaultJVMConnectionValues, buildJVMConnectionConfig } from './jvmConnectionConfig'; + +describe('jvmConnectionConfig', () => { + it('defaults to readonly jmx mode', () => { + const values = buildDefaultJVMConnectionValues(); + expect(values.type).toBe('jvm'); + expect(values.jvmReadOnly).toBe(true); + expect(values.jvmAllowedModes).toEqual(['jmx']); + expect(values.jvmPreferredMode).toBe('jmx'); + }); + + it('builds nested jvm config payload', () => { + const config = buildJVMConnectionConfig({ + name: 'Orders JVM', + type: 'jvm', + host: 'orders.internal', + port: 9010, + jvmReadOnly: true, + jvmAllowedModes: ['jmx', 'endpoint'], + jvmPreferredMode: 'endpoint', + jvmEnvironment: 'prod', + jvmEndpointEnabled: true, + jvmEndpointBaseUrl: 'https://orders.internal/manage/jvm', + jvmEndpointApiKey: 'token-1', + }); + expect(config.jvm?.preferredMode).toBe('endpoint'); + expect(config.jvm?.endpoint.baseUrl).toBe('https://orders.internal/manage/jvm'); + }); +}); +``` + +- [ ] **Step 5: 实现前端类型与连接工具** + +```ts +export interface JVMJMXConfig { + enabled?: boolean; + host?: string; + port?: number; + username?: string; + password?: string; + domainAllowlist?: string[]; +} + +export interface JVMEndpointConfig { + enabled?: boolean; + baseUrl?: string; + apiKey?: string; + timeoutSeconds?: number; +} + +export interface JVMConfig { + environment?: 'dev' | 'uat' | 'prod'; + readOnly?: boolean; + allowedModes?: Array<'jmx' | 'endpoint' | 'agent'>; + preferredMode?: 'jmx' | 'endpoint' | 'agent'; + jmx?: JVMJMXConfig; + endpoint?: JVMEndpointConfig; +} + +export interface JVMCapability { + mode: 'jmx' | 'endpoint' | 'agent'; + canBrowse: boolean; + canWrite: boolean; + canPreview: boolean; + reason?: string; + displayLabel: string; +} + +export interface JVMResourceSummary { + id: string; + parentId?: string; + kind: string; + name: string; + path: string; + providerMode: 'jmx' | 'endpoint' | 'agent'; + canRead: boolean; + canWrite: boolean; + hasChildren: boolean; + sensitive?: boolean; +} + +export interface JVMValueSnapshot { + resourceId: string; + kind: string; + format: string; + version?: string; + value: any; + metadata?: Record; +} + +export interface JVMChangePreview { + allowed: boolean; + requiresConfirmation?: boolean; + summary: string; + riskLevel: 'low' | 'medium' | 'high'; + blockingReason?: string; + before: JVMValueSnapshot; + after: JVMValueSnapshot; +} +``` + +```ts +import type { ConnectionConfig } from '../types'; + +export const buildDefaultJVMConnectionValues = () => ({ + type: 'jvm', + host: 'localhost', + port: 9010, + jvmReadOnly: true, + jvmAllowedModes: ['jmx'], + jvmPreferredMode: 'jmx', + jvmEnvironment: 'dev', + jvmEndpointEnabled: false, + jvmEndpointBaseUrl: '', + jvmEndpointApiKey: '', +}); + +export const buildJVMConnectionConfig = (values: Record): ConnectionConfig => ({ + type: 'jvm', + host: String(values.host || '').trim(), + port: Number(values.port || 0), + user: '', + password: '', + timeout: Number(values.timeout || 30), + jvm: { + environment: values.jvmEnvironment, + readOnly: Boolean(values.jvmReadOnly), + allowedModes: values.jvmAllowedModes, + preferredMode: values.jvmPreferredMode, + jmx: { + enabled: values.jvmAllowedModes?.includes('jmx'), + host: String(values.jvmJmxHost || values.host || '').trim(), + port: Number(values.jvmJmxPort || values.port || 0), + username: String(values.jvmJmxUsername || '').trim(), + password: String(values.jvmJmxPassword || ''), + }, + endpoint: { + enabled: Boolean(values.jvmEndpointEnabled), + baseUrl: String(values.jvmEndpointBaseUrl || '').trim(), + apiKey: String(values.jvmEndpointApiKey || ''), + timeoutSeconds: Number(values.jvmEndpointTimeoutSeconds || values.timeout || 30), + }, + }, +}); +``` + +- [ ] **Step 6: 运行单测,确认前后端配置契约稳定** + +Run: `go test ./internal/jvm -run TestNormalizeConnectionConfig -count=1` + +Expected: PASS,输出 `ok GoNavi-Wails/internal/jvm` + +Run: `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts` + +Expected: PASS,2 个测试通过。 + +- [ ] **Step 7: 提交配置契约** + +```bash +git add internal/connection/types.go internal/jvm/types.go internal/jvm/config.go internal/jvm/config_test.go frontend/src/types.ts frontend/src/utils/jvmConnectionConfig.ts frontend/src/utils/jvmConnectionConfig.test.ts +git commit -m "feat(jvm): 定义 JVM 连接契约与配置归一化" +``` + +## Task 2: 建立后端 Provider 注册与连接探测 API + +**Files:** +- Create: `internal/jvm/provider.go` +- Create: `internal/jvm/jmx_provider.go` +- Create: `internal/jvm/http_provider.go` +- Create: `internal/app/methods_jvm.go` +- Create: `internal/app/methods_jvm_test.go` +- Regenerate: `frontend/wailsjs/go/app/App.d.ts` +- Regenerate: `frontend/wailsjs/go/app/App.js` +- Regenerate: `frontend/wailsjs/go/models.ts` + +- [ ] **Step 1: 写 App 层失败测试,锁定连接测试与能力探测输出** + +```go +package app + +import ( + "context" + "testing" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/jvm" +) + +type fakeJVMProvider struct { + testErr error + probe []jvm.Capability + list []jvm.ResourceSummary + value jvm.ValueSnapshot + apply jvm.ApplyResult +} + +func (f fakeJVMProvider) Mode() string { return jvm.ModeJMX } +func (f fakeJVMProvider) TestConnection(context.Context, connection.ConnectionConfig) error { return f.testErr } +func (f fakeJVMProvider) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]jvm.Capability, error) { + return f.probe, nil +} +func (f fakeJVMProvider) ListResources(context.Context, connection.ConnectionConfig, string) ([]jvm.ResourceSummary, error) { + return f.list, nil +} +func (f fakeJVMProvider) GetValue(context.Context, connection.ConnectionConfig, string) (jvm.ValueSnapshot, error) { + return f.value, nil +} +func (f fakeJVMProvider) PreviewChange(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ChangePreview, error) { + return jvm.ChangePreview{Allowed: true, Summary: "preview"}, nil +} +func (f fakeJVMProvider) ApplyChange(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) { + return f.apply, nil +} + +func swapJVMProviderFactory(factory func(mode string) (jvm.Provider, error)) func() { + prev := newJVMProvider + newJVMProvider = factory + return func() { newJVMProvider = prev } +} + +func TestTestJVMConnectionUsesPreferredProvider(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{}, nil + }) + defer restore() + + res := app.TestJVMConnection(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }) + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } +} + +func TestJVMProbeCapabilitiesReturnsCapabilityArray(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + probe: []jvm.Capability{{Mode: jvm.ModeJMX, CanBrowse: true, CanWrite: false, CanPreview: false, DisplayLabel: "JMX"}}, + }, nil + }) + defer restore() + + res := app.JVMProbeCapabilities(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }) + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + items, ok := res.Data.([]jvm.Capability) + if !ok || len(items) != 1 { + t.Fatalf("expected one capability, got %#v", res.Data) + } +} +``` + +- [ ] **Step 2: 运行测试,确认 App 方法尚未定义** + +Run: `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` + +Expected: FAIL,提示 `TestJVMConnection` 或 `JVMProbeCapabilities` 未定义。 + +- [ ] **Step 3: 实现 Provider 接口、JMX/Endpoint 骨架和 App 方法** + +```go +package jvm + +import ( + "context" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +type Provider interface { + Mode() string + TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error + ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) + ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) + GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) + PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) + ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) +} + +var providerFactories = map[string]func() Provider{ + ModeJMX: func() Provider { return NewJMXProvider() }, + ModeEndpoint: func() Provider { return NewHTTPProvider() }, +} + +func NewProvider(mode string) (Provider, error) { + normalized := strings.ToLower(strings.TrimSpace(mode)) + factory, ok := providerFactories[normalized] + if !ok { + return nil, fmt.Errorf("unsupported jvm provider mode: %s", mode) + } + return factory(), nil +} + +type JMXProvider struct{} + +func NewJMXProvider() Provider { return &JMXProvider{} } +func (p *JMXProvider) Mode() string { return ModeJMX } +func (p *JMXProvider) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error { return nil } +func (p *JMXProvider) ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) { + return []Capability{{Mode: ModeJMX, CanBrowse: true, CanWrite: false, CanPreview: false, DisplayLabel: "JMX"}}, nil +} +func (p *JMXProvider) ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) { + return []ResourceSummary{}, nil +} +func (p *JMXProvider) GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) { + return ValueSnapshot{}, nil +} +func (p *JMXProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { + return ChangePreview{}, nil +} +func (p *JMXProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) { + return ApplyResult{}, nil +} + +type HTTPProvider struct{} + +func NewHTTPProvider() Provider { return &HTTPProvider{} } +func (p *HTTPProvider) Mode() string { return ModeEndpoint } +func (p *HTTPProvider) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error { return nil } +func (p *HTTPProvider) ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) { + return []Capability{{Mode: ModeEndpoint, CanBrowse: true, CanWrite: true, CanPreview: true, DisplayLabel: "Endpoint"}}, nil +} +func (p *HTTPProvider) ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) { + return []ResourceSummary{}, nil +} +func (p *HTTPProvider) GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) { + return ValueSnapshot{}, nil +} +func (p *HTTPProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { + return ChangePreview{}, nil +} +func (p *HTTPProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) { + return ApplyResult{}, nil +} +``` + +```go +package app + +import ( + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/jvm" + "path/filepath" + "strings" +) + +var newJVMProvider = jvm.NewProvider + +func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.QueryResult { + normalized, err := jvm.NormalizeConnectionConfig(cfg) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + provider, err := newJVMProvider(normalized.JVM.PreferredMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if err := provider.TestConnection(a.ctx, normalized); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Message: "JVM 连接成功"} +} + +func (a *App) JVMProbeCapabilities(cfg connection.ConnectionConfig) connection.QueryResult { + normalized, err := jvm.NormalizeConnectionConfig(cfg) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + items := make([]jvm.Capability, 0, len(normalized.JVM.AllowedModes)) + for _, mode := range normalized.JVM.AllowedModes { + provider, providerErr := newJVMProvider(mode) + if providerErr != nil { + items = append(items, jvm.Capability{Mode: mode, DisplayLabel: strings.ToUpper(mode), Reason: providerErr.Error()}) + continue + } + caps, probeErr := provider.ProbeCapabilities(a.ctx, normalized) + if probeErr != nil { + items = append(items, jvm.Capability{Mode: mode, DisplayLabel: strings.ToUpper(mode), Reason: probeErr.Error()}) + continue + } + items = append(items, caps...) + } + return connection.QueryResult{Success: true, Data: items} +} +``` + +- [ ] **Step 4: 刷新 Wails 绑定** + +Run: `wails build -clean` + +Expected: PASS,命令退出码为 0,同时刷新 `frontend/wailsjs/go/app/App.*` 与 `frontend/wailsjs/go/models.ts`。 + +- [ ] **Step 5: 运行后端测试,确认探测 API 可用** + +Run: `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` + +Expected: PASS,输出 `ok GoNavi-Wails/internal/app` + +- [ ] **Step 6: 提交 Provider 骨架** + +```bash +git add internal/jvm/provider.go internal/jvm/jmx_provider.go internal/jvm/http_provider.go internal/app/methods_jvm.go internal/app/methods_jvm_test.go frontend/wailsjs/go/app/App.d.ts frontend/wailsjs/go/app/App.js frontend/wailsjs/go/models.ts +git commit -m "feat(jvm): 增加连接测试与能力探测 API" +``` + +## Task 3: 接入 JVM 连接表单与图标 + +**Files:** +- Modify: `frontend/src/components/DatabaseIcons.tsx` +- Modify: `frontend/src/components/ConnectionModal.tsx` +- Create: `frontend/src/utils/jvmRuntimePresentation.ts` +- Create: `frontend/src/utils/jvmRuntimePresentation.test.ts` + +- [ ] **Step 1: 写展示层失败测试,锁定 JVM 模式标签和 tab 标题构造** + +```ts +import { describe, expect, it } from 'vitest'; +import { buildJVMTabTitle, resolveJVMModeMeta } from './jvmRuntimePresentation'; + +describe('jvmRuntimePresentation', () => { + it('renders readable mode meta', () => { + expect(resolveJVMModeMeta('jmx').label).toBe('JMX'); + expect(resolveJVMModeMeta('endpoint').label).toBe('Endpoint'); + }); + + it('builds overview title with provider suffix', () => { + expect(buildJVMTabTitle('Orders JVM', 'overview', 'jmx')).toBe('[Orders JVM] JVM 概览 · JMX'); + }); +}); +``` + +- [ ] **Step 2: 运行测试,确认展示帮助函数尚未实现** + +Run: `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts` + +Expected: FAIL,提示 `buildJVMTabTitle` / `resolveJVMModeMeta` 未定义。 + +- [ ] **Step 3: 实现 JVM 图标和展示帮助函数** + +```ts +export const resolveJVMModeMeta = (mode: string) => { + switch (mode) { + case 'endpoint': + return { label: 'Endpoint', color: 'blue' as const }; + case 'agent': + return { label: 'Agent', color: 'purple' as const }; + default: + return { label: 'JMX', color: 'gold' as const }; + } +}; + +export const buildJVMTabTitle = (connectionName: string, tabKind: 'overview' | 'resource' | 'audit', mode: string) => { + const modeLabel = resolveJVMModeMeta(mode).label; + if (tabKind === 'audit') return `[${connectionName}] JVM 审计 · ${modeLabel}`; + if (tabKind === 'resource') return `[${connectionName}] JVM 资源 · ${modeLabel}`; + return `[${connectionName}] JVM 概览 · ${modeLabel}`; +}; +``` + +```tsx +export const DB_ICON_TYPES = [ + 'mysql', + 'postgres', + 'oracle', + 'redis', + 'mongodb', + 'custom', + 'jvm', +] as const; +``` + +- [ ] **Step 4: 扩展 ConnectionModal,新增 JVM 连接类型与测试连接分发** + +```tsx +{ key: 'jvm', name: 'JVM', icon: } +``` + +```tsx +if (dbType === 'jvm') { + return ( + <> + + + + + + + + + + + 默认只读 + + + + + + ); +} +``` + +```tsx +const requestTest = async () => { + const values = form.getFieldsValue(true); + const config = values.type === 'jvm' + ? buildJVMConnectionConfig(values) + : await buildConfig(values, false); + const result = values.type === 'jvm' + ? await (window as any).go.app.App.TestJVMConnection(config as any) + : values.type === 'redis' + ? await RedisConnect(config as any) + : await TestConnection(config as any); + setTestResult(result.success ? { type: 'success', message: result.message || '连接成功' } : { type: 'error', message: result.message || '连接失败' }); +}; +``` + +- [ ] **Step 5: 运行前端纯函数测试与构建** + +Run: `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts` + +Expected: PASS + +Run: `cd frontend && npm run build` + +Expected: PASS,生成最新 `frontend/dist`。 + +- [ ] **Step 6: 提交连接体验改动** + +```bash +git add frontend/src/components/DatabaseIcons.tsx frontend/src/components/ConnectionModal.tsx frontend/src/utils/jvmRuntimePresentation.ts frontend/src/utils/jvmRuntimePresentation.test.ts +git commit -m "feat(jvm): 新增 JVM 连接表单与展示元数据" +``` + +## Task 4: 打通只读资源浏览与 JVM Tab + +**Files:** +- Modify: `frontend/src/types.ts` +- Modify: `frontend/src/components/Sidebar.tsx` +- Modify: `frontend/src/components/TabManager.tsx` +- Create: `frontend/src/components/JVMOverview.tsx` +- Create: `frontend/src/components/JVMResourceBrowser.tsx` +- Create: `frontend/src/components/jvm/JVMModeBadge.tsx` +- Modify: `internal/app/methods_jvm.go` +- Modify: `internal/app/methods_jvm_test.go` + +- [ ] **Step 1: 写后端失败测试,锁定资源列表和值读取接口** + +```go +func TestJVMListResourcesReturnsTreePayload(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + list: []jvm.ResourceSummary{ + {ID: "cache:orders", Kind: "cacheNamespace", Name: "orders", Path: "cache/orders", ProviderMode: "jmx", HasChildren: true, CanRead: true}, + }, + }, nil + }) + defer restore() + + res := app.JVMListResources(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, "") + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + items, ok := res.Data.([]jvm.ResourceSummary) + if !ok || len(items) != 1 { + t.Fatalf("expected one resource item, got %#v", res.Data) + } +} +``` + +- [ ] **Step 2: 运行测试,确认资源读取方法尚未实现** + +Run: `go test ./internal/app -run 'TestJVMListResources' -count=1` + +Expected: FAIL,提示 `JVMListResources` 未定义。 + +- [ ] **Step 3: 实现后端读接口并在 Sidebar 中新增 JVM 懒加载节点** + +```go +func (a *App) JVMListResources(cfg connection.ConnectionConfig, parentPath string) connection.QueryResult { + normalized, err := jvm.NormalizeConnectionConfig(cfg) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + provider, err := newJVMProvider(normalized.JVM.PreferredMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + items, err := provider.ListResources(a.ctx, normalized, parentPath) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: items} +} + +func (a *App) JVMGetValue(cfg connection.ConnectionConfig, resourcePath string) connection.QueryResult { + normalized, err := jvm.NormalizeConnectionConfig(cfg) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + provider, err := newJVMProvider(normalized.JVM.PreferredMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + value, err := provider.GetValue(a.ctx, normalized, resourcePath) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: value} +} +``` + +```tsx +type TreeNode = { + title: string; + key: string; + isLeaf?: boolean; + children?: TreeNode[]; + icon?: React.ReactNode; + dataRef?: any; + type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource'; +}; +``` + +```tsx +if (conn.config.type === 'jvm') { + const modeChildren = (caps as JVMCapability[]).map((cap) => ({ + title: ( + + {cap.displayLabel} + + + ), + key: `${conn.id}-jvm-mode-${cap.mode}`, + type: 'jvm-mode' as const, + dataRef: { ...conn, providerMode: cap.mode }, + isLeaf: false, + })); + setTreeData((origin) => updateTreeData(origin, conn.id, modeChildren)); + return; +} +``` + +- [ ] **Step 4: 新增 JVM 概览与资源浏览 Tab** + +```tsx +if (tab.type === 'jvm-overview') { + content = ; +} else if (tab.type === 'jvm-resource') { + content = ; +} else if (tab.type === 'jvm-audit') { + content = ; +} +``` + +```tsx +export interface TabData { + id: string; + title: string; + type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'redis-monitor' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview' | 'jvm-overview' | 'jvm-resource' | 'jvm-audit'; + connectionId: string; + dbName?: string; + tableName?: string; + providerMode?: 'jmx' | 'endpoint' | 'agent'; + resourcePath?: string; + resourceKind?: string; +} +``` + +- [ ] **Step 5: 运行后端与前端最小回归** + +Run: `go test ./internal/app -run 'TestJVMListResources' -count=1` + +Expected: PASS + +Run: `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts` + +Expected: PASS + +- [ ] **Step 6: 提交只读浏览链路** + +```bash +git add internal/app/methods_jvm.go internal/app/methods_jvm_test.go frontend/src/types.ts frontend/src/components/Sidebar.tsx frontend/src/components/TabManager.tsx frontend/src/components/JVMOverview.tsx frontend/src/components/JVMResourceBrowser.tsx frontend/src/components/jvm/JVMModeBadge.tsx +git commit -m "feat(jvm): 打通 JVM 只读资源浏览" +``` + +## Task 5: 加入写入预览、Guard 和审计记录 + +**Files:** +- Create: `internal/jvm/guard.go` +- Create: `internal/jvm/audit_store.go` +- Modify: `internal/jvm/types.go` +- Modify: `internal/app/methods_jvm.go` +- Modify: `internal/app/methods_jvm_test.go` +- Create: `frontend/src/components/jvm/JVMChangePreviewModal.tsx` +- Create: `frontend/src/components/JVMAuditViewer.tsx` +- Modify: `frontend/src/components/JVMResourceBrowser.tsx` + +- [ ] **Step 1: 写 Guard 失败测试,锁定只读/生产环境拦截** + +```go +func TestPreviewChangeBlocksReadOnlyConnection(t *testing.T) { + cfg := connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: true, + Environment: "prod", + PreferredMode: "endpoint", + AllowedModes: []string{"endpoint"}, + }, + } + + preview, err := jvm.BuildChangePreview(cfg, jvm.ChangeRequest{ + ProviderMode: "endpoint", + ResourceID: "cache/orders/user:1", + Action: "updateValue", + Reason: "修复错误缓存态", + Payload: map[string]any{"status": "ACTIVE"}, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error: %v", err) + } + if preview.Allowed { + t.Fatalf("expected readonly connection to block write preview") + } + if preview.BlockingReason == "" { + t.Fatalf("expected blocking reason") + } +} +``` + +- [ ] **Step 2: 运行测试,确认 Guard 逻辑尚未存在** + +Run: `go test ./internal/jvm -run TestPreviewChangeBlocksReadOnlyConnection -count=1` + +Expected: FAIL,提示 `BuildChangePreview` 未定义。 + +- [ ] **Step 3: 实现 Guard、预览和审计落盘** + +```go +package jvm + +import ( + "encoding/json" + "os" + "fmt" + "time" + + "GoNavi-Wails/internal/connection" +) + +func BuildChangePreview(cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { + normalized, err := NormalizeConnectionConfig(cfg) + if err != nil { + return ChangePreview{}, err + } + preview := ChangePreview{ + Allowed: true, + RiskLevel: "medium", + Summary: fmt.Sprintf("%s -> %s", req.ResourceID, req.Action), + } + if normalized.JVM.ReadOnly { + preview.Allowed = false + preview.RiskLevel = "high" + preview.BlockingReason = "当前连接为只读,禁止写入" + } + if normalized.JVM.Environment == EnvPROD { + preview.RequiresConfirmation = true + } + return preview, nil +} + +type AuditStore struct { + path string +} + +func NewAuditStore(path string) *AuditStore { return &AuditStore{path: path} } + +func (s *AuditStore) Append(record AuditRecord) error { + record.Timestamp = time.Now().UnixMilli() + file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer file.Close() + return json.NewEncoder(file).Encode(record) +} +``` + +```go +func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRequest) connection.QueryResult { + preview, err := jvm.BuildChangePreview(cfg, req) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: preview} +} + +func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequest) connection.QueryResult { + preview, err := jvm.BuildChangePreview(cfg, req) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if !preview.Allowed { + return connection.QueryResult{Success: false, Message: preview.BlockingReason} + } + provider, err := newJVMProvider(req.ProviderMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + result, err := provider.ApplyChange(a.ctx, cfg, req) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + _ = jvm.NewAuditStore(filepath.Join(a.configDir, "jvm_audit.jsonl")).Append(jvm.AuditRecord{ + ConnectionID: cfg.ID, + ProviderMode: req.ProviderMode, + ResourceID: req.ResourceID, + Action: req.Action, + Reason: req.Reason, + Result: result.Status, + }) + return connection.QueryResult{Success: true, Data: result} +} +``` + +- [ ] **Step 4: 实现前端预览弹窗与审计页签** + +```tsx +export const JVMChangePreviewModal: React.FC<{ + open: boolean; + preview: JVMChangePreview | null; + onCancel: () => void; + onConfirm: () => Promise; +}> = ({ open, preview, onCancel, onConfirm }) => ( + void onConfirm()} + okText="确认执行" + cancelText="取消" + okButtonProps={{ danger: preview?.riskLevel === 'high' }} + > + + {preview?.summary} + {preview?.riskLevel} + {preview?.blockingReason || '无'} + + + {JSON.stringify(preview?.before?.value ?? {}, null, 2)} + {JSON.stringify(preview?.after?.value ?? {}, null, 2)} + +); +``` + +```tsx +const handleApply = async () => { + const previewRes = await (window as any).go.app.App.JVMPreviewChange(config, draftPlan); + if (!previewRes.success) { + message.error(previewRes.message || '预览失败'); + return; + } + setPreview(previewRes.data); + setPreviewOpen(true); +}; +``` + +- [ ] **Step 5: 跑写入链路单测** + +Run: `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestJVMApplyChange' -count=1` + +Expected: PASS + +- [ ] **Step 6: 提交预览与审计链路** + +```bash +git add internal/jvm/guard.go internal/jvm/audit_store.go internal/jvm/types.go internal/app/methods_jvm.go internal/app/methods_jvm_test.go frontend/src/components/jvm/JVMChangePreviewModal.tsx frontend/src/components/JVMAuditViewer.tsx frontend/src/components/JVMResourceBrowser.tsx +git commit -m "feat(jvm): 增加 JVM 写入预览与审计" +``` + +## Task 6: 接入 AI 结构化变更计划 + +**Files:** +- Create: `frontend/src/utils/jvmAiPlan.ts` +- Create: `frontend/src/utils/jvmAiPlan.test.ts` +- Modify: `frontend/src/components/AIChatPanel.tsx` +- Modify: `frontend/src/components/ai/AIMessageBubble.tsx` +- Modify: `frontend/src/components/JVMResourceBrowser.tsx` + +- [ ] **Step 1: 写失败测试,锁定 AI 计划 JSON 解析规则** + +```ts +import { describe, expect, it } from 'vitest'; +import { extractJVMChangePlan } from './jvmAiPlan'; + +describe('extractJVMChangePlan', () => { + it('parses fenced json plan', () => { + const message = [ + '建议先预览再执行:', + '```json', + '{"targetType":"cacheEntry","selector":{"namespace":"orders","key":"user:1"},"action":"updateValue","payload":{"format":"json","value":{"status":"ACTIVE"}},"reason":"修复缓存脏值"}', + '```', + ].join('\n'); + + const plan = extractJVMChangePlan(message); + expect(plan?.action).toBe('updateValue'); + expect(plan?.selector.namespace).toBe('orders'); + }); + + it('returns null for malformed plan', () => { + expect(extractJVMChangePlan('```json\n{"action":1}\n```')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: 运行测试,确认 AI 计划解析器尚未存在** + +Run: `cd frontend && npm test -- src/utils/jvmAiPlan.test.ts` + +Expected: FAIL,提示 `extractJVMChangePlan` 未定义。 + +- [ ] **Step 3: 实现 AI 计划解析器** + +```ts +export type JVMAIChangePlan = { + targetType: 'cacheEntry' | 'managedBean'; + selector: { namespace?: string; key?: string; resourcePath?: string }; + action: 'updateValue' | 'evict' | 'clear'; + payload?: { format: 'json' | 'text'; value: unknown }; + reason: string; +}; + +export const extractJVMChangePlan = (content: string): JVMAIChangePlan | null => { + const match = String(content || '').match(/```json\s*([\s\S]*?)```/i); + if (!match) return null; + try { + const parsed = JSON.parse(match[1]); + if (!parsed || typeof parsed !== 'object') return null; + if (!parsed.targetType || !parsed.selector || !parsed.action || !parsed.reason) return null; + return parsed as JVMAIChangePlan; + } catch { + return null; + } +}; +``` + +- [ ] **Step 4: 在 AI 气泡里识别 JVM 计划并提供“应用到预览”按钮** + +```tsx +const jvmPlan = extractJVMChangePlan(msg.content || ''); + +{jvmPlan && ( + +)} +``` + +```tsx +useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail?.plan) return; + setDraftPlan({ + providerMode: tab.providerMode || 'endpoint', + resourceID: detail.plan.selector.resourcePath || `${detail.plan.selector.namespace}/${detail.plan.selector.key}`, + action: detail.plan.action, + payload: detail.plan.payload?.value ?? {}, + reason: detail.plan.reason, + }); + }; + window.addEventListener('gonavi:jvm-apply-ai-plan', handler as EventListener); + return () => window.removeEventListener('gonavi:jvm-apply-ai-plan', handler as EventListener); +}, [tab.providerMode]); +``` + +- [ ] **Step 5: 跑 AI 计划解析测试** + +Run: `cd frontend && npm test -- src/utils/jvmAiPlan.test.ts` + +Expected: PASS + +- [ ] **Step 6: 提交 AI 集成** + +```bash +git add frontend/src/utils/jvmAiPlan.ts frontend/src/utils/jvmAiPlan.test.ts frontend/src/components/AIChatPanel.tsx frontend/src/components/ai/AIMessageBubble.tsx frontend/src/components/JVMResourceBrowser.tsx +git commit -m "feat(jvm): 支持 AI 生成 JVM 变更计划" +``` + +## Task 7: 全量回归、文档回填与交付检查 + +**Files:** +- Modify: `docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md` +- Regenerate/Verify: `frontend/wailsjs/go/app/App.d.ts` +- Regenerate/Verify: `frontend/wailsjs/go/app/App.js` +- Regenerate/Verify: `frontend/wailsjs/go/models.ts` + +- [ ] **Step 1: 更新需求追踪文档,写入计划路径与实施阶段** + +```md +## 3. 里程碑与进度 +- [x] 阶段 1(需求澄清):完成 +- [x] 阶段 2(影响分析):完成 +- [x] 阶段 3(方案设计):完成 +- [x] 阶段 4(实施计划):完成 +- [ ] 阶段 5(实现与自检): + +## 7. 验证记录 +- 证据(日志/截图/链接): + - `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md` + - `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md` +``` + +- [ ] **Step 2: 运行后端全量测试** + +Run: `go test ./...` + +Expected: PASS,全仓 Go 测试通过。 + +- [ ] **Step 3: 运行前端全量测试** + +Run: `cd frontend && npm test` + +Expected: PASS,全量 Vitest 通过。 + +- [ ] **Step 4: 运行前端生产构建** + +Run: `cd frontend && npm run build` + +Expected: PASS,生成最新 `frontend/dist`。 + +- [ ] **Step 5: 运行 Wails 生产构建,确认绑定与嵌入资源完整** + +Run: `wails build -clean` + +Expected: PASS,命令退出码为 0。 + +- [ ] **Step 6: 提交最终计划内实现** + +```bash +git add docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md frontend/wailsjs/go/app/App.d.ts frontend/wailsjs/go/app/App.js frontend/wailsjs/go/models.ts +git commit -m "feat(jvm): 完成 JVM Connector MVP" +``` + +## Self-Review Notes + +- Spec coverage: + - `JMX + Management Endpoint`:Task 2 / Task 4 / Task 5 + - `统一连接入口`:Task 1 / Task 3 + - `资源浏览`:Task 4 + - `受控修改 + 预览 + 审计`:Task 5 + - `AI 生成修改计划`:Task 6 + - `验证与文档回填`:Task 7 +- Placeholder scan: + - 无 `TODO` / `TBD` / “后续补充” 占位语 +- Type consistency: + - 统一使用 `JVMConfig` / `Capability` / `ResourceSummary` / `ChangeRequest` / `ChangePreview` + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach?** diff --git a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md index afb3cc0..6929cec 100644 --- a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md +++ b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md @@ -25,7 +25,7 @@ - [x] 阶段 1(需求澄清):完成 - [x] 阶段 2(影响分析):完成 - [x] 阶段 3(方案设计):完成(已形成正式设计文档) -- [ ] 阶段 4(实施计划): +- [x] 阶段 4(实施计划):完成(已形成正式实施计划) - [ ] 阶段 5(实现与自检): - [ ] 阶段 6(评审与交付): - [ ] 阶段 7(发布与观察): @@ -39,10 +39,10 @@ - 用户已确认升级到完整模式,开始高风险架构评估 - 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach - 已形成 JVM 缓存可视化编辑正式设计文档 + - 已形成 JVM Connector MVP 正式实施计划文档 - 进行中: - - 等待用户审阅正式设计文档 + - 等待用户选择执行方式并进入实现 - 待处理: - - 基于设计文档输出实施计划 - 进入 MVP 分期实施与验证 ## 5. 风险与阻塞 @@ -70,9 +70,11 @@ - GoNavi 驱动代理机制核查 - GoNavi 现有 Redis/编辑器/UI 复用能力核查 - JVM Connector 正式设计文档自检 + - JVM Connector 实施计划文档自检 - 结果: - 已确认存在可复用的连接桥接与编辑器基础设施 - 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突 + - 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节 - 证据(日志/截图/链接): - `cmd/optional-driver-agent/main.go` - `internal/db/database.go` @@ -80,7 +82,8 @@ - `frontend/src/components/RedisCommandEditor.tsx` - `frontend/src/components/QueryEditor.tsx` - `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md` + - `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md` ## 8. 下一步 -- 下一步行动:请用户审阅正式设计文档,确认是否需要修改后再进入实施计划阶段 +- 下一步行动:请用户选择实施执行方式;推荐按 task 粒度执行并在每个 task 后做回归和提交 - 负责人:Codex From 15b1ad24d156389b5f652dfcf88417e8ba792da6 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 22 Apr 2026 17:20:00 +0800 Subject: [PATCH 03/32] =?UTF-8?q?=E2=9C=A8=20feat(jvm):=20=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=20JVM=20=E8=BF=9E=E6=8E=A5=E5=A5=91=E7=BA=A6=E4=B8=8E?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=BD=92=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 JVM 连接配置与共享 DTO,补齐 JMX 和 Endpoint 契约 - 实现后端归一化规则,支持默认只读、模式回退和 JMX 端口兜底 - 新增前端 JVM 默认值与配置构建工具,统一模式环境和端口收敛 - 补充 Go 与 Vitest 用例并更新需求追踪,记录 Task 1 验证证据 --- .../需求进度追踪-JVM缓存可视化编辑-20260422.md | 24 +++- frontend/src/types.ts | 68 +++++++++- .../src/utils/jvmConnectionConfig.test.ts | 61 +++++++++ frontend/src/utils/jvmConnectionConfig.ts | 123 ++++++++++++++++++ internal/connection/types.go | 29 +++++ internal/jvm/config.go | 82 ++++++++++++ internal/jvm/config_test.go | 93 +++++++++++++ internal/jvm/types.go | 74 +++++++++++ 8 files changed, 549 insertions(+), 5 deletions(-) create mode 100644 frontend/src/utils/jvmConnectionConfig.test.ts create mode 100644 frontend/src/utils/jvmConnectionConfig.ts create mode 100644 internal/jvm/config.go create mode 100644 internal/jvm/config_test.go create mode 100644 internal/jvm/types.go diff --git a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md index 6929cec..d578e78 100644 --- a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md +++ b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md @@ -26,7 +26,7 @@ - [x] 阶段 2(影响分析):完成 - [x] 阶段 3(方案设计):完成(已形成正式设计文档) - [x] 阶段 4(实施计划):完成(已形成正式实施计划) -- [ ] 阶段 5(实现与自检): +- [ ] 阶段 5(实现与自检):进行中(Task 1 已完成并通过回归) - [ ] 阶段 6(评审与交付): - [ ] 阶段 7(发布与观察): @@ -40,10 +40,11 @@ - 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach - 已形成 JVM 缓存可视化编辑正式设计文档 - 已形成 JVM Connector MVP 正式实施计划文档 +- - 已完成 Task 1:JVM 共享契约与配置归一化 - 进行中: - - 等待用户选择执行方式并进入实现 + - Task 2:建立后端 Provider 注册与连接探测 API - 待处理: - - 进入 MVP 分期实施与验证 + - Task 3+:Guard/Audit/App/UI/AI 结构化计划等后续任务 ## 5. 风险与阻塞 - 风险: @@ -71,10 +72,13 @@ - GoNavi 现有 Redis/编辑器/UI 复用能力核查 - JVM Connector 正式设计文档自检 - JVM Connector 实施计划文档自检 + - Task 1:JVM 共享契约与配置归一化 - 结果: - 已确认存在可复用的连接桥接与编辑器基础设施 - 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突 - 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节 + - 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测 + - Task 1 已完成规格审查与代码质量审查,结论均通过 - 证据(日志/截图/链接): - `cmd/optional-driver-agent/main.go` - `internal/db/database.go` @@ -83,7 +87,19 @@ - `frontend/src/components/QueryEditor.tsx` - `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md` - `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md` + - `internal/connection/types.go` + - `internal/jvm/types.go` + - `internal/jvm/config.go` + - `internal/jvm/config_test.go` + - `frontend/src/types.ts` + - `frontend/src/utils/jvmConnectionConfig.ts` + - `frontend/src/utils/jvmConnectionConfig.test.ts` + - `go test ./internal/jvm -count=1` + - `go test ./...` + - `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts` + - `cd frontend && npm test -- --run` + - `cd frontend && npm run build` ## 8. 下一步 -- 下一步行动:请用户选择实施执行方式;推荐按 task 粒度执行并在每个 task 后做回归和提交 +- 下一步行动:进入 Task 2,建立 JVM Provider 注册、连接测试与能力探测 API,并在完成后生成/校验 Wails 绑定代码 - 负责人:Codex diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9f575b0..97c61da 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -21,6 +21,72 @@ export interface HTTPTunnelConfig { password?: string; } +export interface JVMJMXConfig { + enabled?: boolean; + host?: string; + port?: number; + username?: string; + password?: string; + domainAllowlist?: string[]; +} + +export interface JVMEndpointConfig { + enabled?: boolean; + baseUrl?: string; + apiKey?: string; + timeoutSeconds?: number; +} + +export interface JVMConfig { + environment?: 'dev' | 'uat' | 'prod'; + readOnly?: boolean; + allowedModes?: Array<'jmx' | 'endpoint' | 'agent'>; + preferredMode?: 'jmx' | 'endpoint' | 'agent'; + jmx?: JVMJMXConfig; + endpoint?: JVMEndpointConfig; +} + +export interface JVMCapability { + mode: 'jmx' | 'endpoint' | 'agent'; + canBrowse: boolean; + canWrite: boolean; + canPreview: boolean; + reason?: string; + displayLabel: string; +} + +export interface JVMResourceSummary { + id: string; + parentId?: string; + kind: string; + name: string; + path: string; + providerMode: 'jmx' | 'endpoint' | 'agent'; + canRead: boolean; + canWrite: boolean; + hasChildren: boolean; + sensitive?: boolean; +} + +export interface JVMValueSnapshot { + resourceId: string; + kind: string; + format: string; + version?: string; + value: any; + metadata?: Record; +} + +export interface JVMChangePreview { + allowed: boolean; + requiresConfirmation?: boolean; + summary: string; + riskLevel: 'low' | 'medium' | 'high'; + blockingReason?: string; + before: JVMValueSnapshot; + after: JVMValueSnapshot; +} + export interface ConnectionConfig { id?: string; type: string; @@ -56,6 +122,7 @@ export interface ConnectionConfig { mongoAuthMechanism?: string; mongoReplicaUser?: string; mongoReplicaPassword?: string; + jvm?: JVMConfig; } export interface MongoMemberInfo { @@ -344,4 +411,3 @@ export interface SecurityUpdateStatus { lastError?: string; } - diff --git a/frontend/src/utils/jvmConnectionConfig.test.ts b/frontend/src/utils/jvmConnectionConfig.test.ts new file mode 100644 index 0000000..be247ca --- /dev/null +++ b/frontend/src/utils/jvmConnectionConfig.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { buildDefaultJVMConnectionValues, buildJVMConnectionConfig } from './jvmConnectionConfig'; + +describe('jvmConnectionConfig', () => { + it('defaults to readonly jmx mode', () => { + const values = buildDefaultJVMConnectionValues(); + expect(values.type).toBe('jvm'); + expect(values.jvmReadOnly).toBe(true); + expect(values.jvmAllowedModes).toEqual(['jmx']); + expect(values.jvmPreferredMode).toBe('jmx'); + }); + + it('builds nested jvm config payload', () => { + const config = buildJVMConnectionConfig({ + name: 'Orders JVM', + type: 'jvm', + host: 'orders.internal', + port: 9010, + jvmReadOnly: true, + jvmAllowedModes: ['jmx', 'endpoint'], + jvmPreferredMode: 'endpoint', + jvmEnvironment: 'prod', + jvmEndpointEnabled: true, + jvmEndpointBaseUrl: 'https://orders.internal/manage/jvm', + jvmEndpointApiKey: 'token-1', + }); + expect(config.jvm?.preferredMode).toBe('endpoint'); + expect(config.jvm?.endpoint?.baseUrl).toBe('https://orders.internal/manage/jvm'); + }); + + it('normalizes allowed modes and falls back preferred mode to first allowed mode', () => { + const config = buildJVMConnectionConfig({ + host: 'cache.internal', + port: 9010, + jvmAllowedModes: [' Endpoint ', 'invalid', 'JMX', 'endpoint'], + jvmPreferredMode: 'AGENT', + }); + + expect(config.jvm?.allowedModes).toEqual(['endpoint', 'jmx']); + expect(config.jvm?.preferredMode).toBe('endpoint'); + expect(config.jvm?.jmx?.enabled).toBe(true); + }); + + it('normalizes environment and port defaults when input is invalid', () => { + const config = buildJVMConnectionConfig({ + host: 'orders.internal', + port: 0, + jvmJmxPort: '', + jvmEnvironment: ' PROD ', + jvmReadOnly: false, + jvmAllowedModes: ['JMX'], + jvmPreferredMode: 'jmx', + }); + + expect(config.port).toBe(9010); + expect(config.jvm?.jmx?.port).toBe(9010); + expect(config.jvm?.environment).toBe('prod'); + expect(config.jvm?.readOnly).toBe(false); + }); +}); diff --git a/frontend/src/utils/jvmConnectionConfig.ts b/frontend/src/utils/jvmConnectionConfig.ts new file mode 100644 index 0000000..7d0f6d3 --- /dev/null +++ b/frontend/src/utils/jvmConnectionConfig.ts @@ -0,0 +1,123 @@ +import type { ConnectionConfig } from '../types'; + +const DEFAULT_JMX_PORT = 9010; +const DEFAULT_TIMEOUT_SECONDS = 30; +const DEFAULT_ENVIRONMENT = 'dev'; +const JVM_MODES = ['jmx', 'endpoint', 'agent'] as const; + +type JVMMode = typeof JVM_MODES[number]; +type JVMEnvironment = 'dev' | 'uat' | 'prod'; +type JVMConnectionFormValues = Record; + +const isJVMMode = (value: string): value is JVMMode => JVM_MODES.includes(value as JVMMode); + +const toStringValue = (value: unknown): string => { + if (typeof value === 'string') { + return value.trim(); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value).trim(); + } + return ''; +}; + +const toInteger = (value: unknown, fallback: number): number => { + if (value === undefined || value === null || value === '') { + return fallback; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return fallback; + } + const intValue = Math.trunc(parsed); + return intValue > 0 ? intValue : fallback; +}; + +const normalizeModes = (value: unknown): JVMMode[] => { + if (!Array.isArray(value)) { + return ['jmx']; + } + + const result: JVMMode[] = []; + const seen = new Set(); + for (const item of value) { + const mode = toStringValue(item).toLowerCase(); + if (!isJVMMode(mode) || seen.has(mode)) { + continue; + } + seen.add(mode); + result.push(mode); + } + return result.length > 0 ? result : ['jmx']; +}; + +const normalizePreferredMode = (value: unknown, allowedModes: JVMMode[]): JVMMode => { + const preferred = toStringValue(value).toLowerCase(); + if (isJVMMode(preferred) && allowedModes.includes(preferred)) { + return preferred; + } + return allowedModes[0]; +}; + +const normalizeEnvironment = (value: unknown): JVMEnvironment => { + const env = toStringValue(value).toLowerCase(); + if (env === 'uat' || env === 'prod') { + return env; + } + return DEFAULT_ENVIRONMENT; +}; + +const normalizeReadOnly = (value: unknown): boolean => { + if (typeof value === 'boolean') { + return value; + } + return true; +}; + +export const buildDefaultJVMConnectionValues = () => ({ + type: 'jvm', + host: 'localhost', + port: DEFAULT_JMX_PORT, + jvmReadOnly: true, + jvmAllowedModes: ['jmx'], + jvmPreferredMode: 'jmx', + jvmEnvironment: DEFAULT_ENVIRONMENT, + jvmEndpointEnabled: false, + jvmEndpointBaseUrl: '', + jvmEndpointApiKey: '', +}); + +export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): ConnectionConfig => { + const allowedModes = normalizeModes(values.jvmAllowedModes); + const preferredMode = normalizePreferredMode(values.jvmPreferredMode, allowedModes); + const port = toInteger(values.port, DEFAULT_JMX_PORT); + const timeout = toInteger(values.timeout, DEFAULT_TIMEOUT_SECONDS); + + return { + type: 'jvm', + host: toStringValue(values.host), + port, + user: '', + password: '', + timeout, + jvm: { + environment: normalizeEnvironment(values.jvmEnvironment), + readOnly: normalizeReadOnly(values.jvmReadOnly), + allowedModes, + preferredMode, + jmx: { + enabled: allowedModes.includes('jmx'), + host: toStringValue(values.jvmJmxHost) || toStringValue(values.host), + port: toInteger(values.jvmJmxPort, port), + username: toStringValue(values.jvmJmxUsername), + password: toStringValue(values.jvmJmxPassword), + }, + endpoint: { + enabled: values.jvmEndpointEnabled === true, + baseUrl: toStringValue(values.jvmEndpointBaseUrl), + apiKey: toStringValue(values.jvmEndpointApiKey), + timeoutSeconds: toInteger(values.jvmEndpointTimeoutSeconds, timeout), + }, + }, + }; +}; diff --git a/internal/connection/types.go b/internal/connection/types.go index e6e770e..469c091 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -26,6 +26,34 @@ type HTTPTunnelConfig struct { Password string `json:"password,omitempty"` } +// JVMJMXConfig 存储 JVM JMX 连接配置。 +type JVMJMXConfig struct { + Enabled bool `json:"enabled,omitempty"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + DomainAllowlist []string `json:"domainAllowlist,omitempty"` +} + +// JVMEndpointConfig 存储 JVM Management Endpoint 连接配置。 +type JVMEndpointConfig struct { + Enabled bool `json:"enabled,omitempty"` + BaseURL string `json:"baseUrl,omitempty"` + APIKey string `json:"apiKey,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` +} + +// JVMConfig 存储 JVM 连接的协议与能力偏好配置。 +type JVMConfig struct { + Environment string `json:"environment,omitempty"` + ReadOnly *bool `json:"readOnly,omitempty"` + AllowedModes []string `json:"allowedModes,omitempty"` + PreferredMode string `json:"preferredMode,omitempty"` + JMX JVMJMXConfig `json:"jmx,omitempty"` + Endpoint JVMEndpointConfig `json:"endpoint,omitempty"` +} + // ConnectionConfig 存储数据库连接的完整配置,包括 SSH、代理、SSL 等网络层设置。 type ConnectionConfig struct { ID string `json:"id,omitempty"` @@ -62,6 +90,7 @@ type ConnectionConfig struct { MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password + JVM JVMConfig `json:"jvm,omitempty"` // JVM connector config } // ResultSetData 表示一个查询结果集(行 + 列名),用于多结果集场景。 diff --git a/internal/jvm/config.go b/internal/jvm/config.go new file mode 100644 index 0000000..381f690 --- /dev/null +++ b/internal/jvm/config.go @@ -0,0 +1,82 @@ +package jvm + +import ( + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +const defaultJMXPort = 9010 + +func NormalizeConnectionConfig(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + cfg := raw + if strings.ToLower(strings.TrimSpace(cfg.Type)) != "jvm" { + return connection.ConnectionConfig{}, fmt.Errorf("unexpected connection type: %s", cfg.Type) + } + + cfg.Type = "jvm" + cfg.JVM.Environment = strings.ToLower(strings.TrimSpace(cfg.JVM.Environment)) + if cfg.JVM.ReadOnly == nil { + cfg.JVM.ReadOnly = boolPtr(true) + } + if cfg.JVM.JMX.Port <= 0 { + if cfg.Port > 0 { + cfg.JVM.JMX.Port = cfg.Port + } else { + cfg.JVM.JMX.Port = defaultJMXPort + } + } + + cfg.JVM.AllowedModes = normalizeModes(cfg.JVM.AllowedModes) + + preferredMode := strings.ToLower(strings.TrimSpace(cfg.JVM.PreferredMode)) + if preferredMode == "" || !containsMode(cfg.JVM.AllowedModes, preferredMode) { + cfg.JVM.PreferredMode = cfg.JVM.AllowedModes[0] + } else { + cfg.JVM.PreferredMode = preferredMode + } + + return cfg, nil +} + +func normalizeModes(input []string) []string { + if len(input) == 0 { + return []string{ModeJMX} + } + + result := make([]string, 0, len(input)) + seen := make(map[string]struct{}, len(input)) + for _, item := range input { + mode := strings.ToLower(strings.TrimSpace(item)) + switch mode { + case ModeJMX, ModeEndpoint, ModeAgent: + default: + continue + } + if _, exists := seen[mode]; exists { + continue + } + seen[mode] = struct{}{} + result = append(result, mode) + } + + if len(result) == 0 { + return []string{ModeJMX} + } + return result +} + +func containsMode(items []string, target string) bool { + normalizedTarget := strings.ToLower(strings.TrimSpace(target)) + for _, item := range items { + if strings.ToLower(strings.TrimSpace(item)) == normalizedTarget { + return true + } + } + return false +} + +func boolPtr(value bool) *bool { + return &value +} diff --git a/internal/jvm/config_test.go b/internal/jvm/config_test.go new file mode 100644 index 0000000..a270d02 --- /dev/null +++ b/internal/jvm/config_test.go @@ -0,0 +1,93 @@ +package jvm + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestNormalizeConnectionConfigDefaultsToReadOnlyJMX(t *testing.T) { + raw := connection.ConnectionConfig{ + Type: "jvm", + Host: "orders-prod.internal", + Port: 9010, + } + + got, err := NormalizeConnectionConfig(raw) + if err != nil { + t.Fatalf("NormalizeConnectionConfig returned error: %v", err) + } + if got.JVM.ReadOnly == nil || !*got.JVM.ReadOnly { + t.Fatalf("expected JVM connection to default to readOnly") + } + if got.JVM.PreferredMode != ModeJMX { + t.Fatalf("expected preferred mode %q, got %q", ModeJMX, got.JVM.PreferredMode) + } + if len(got.JVM.AllowedModes) != 1 || got.JVM.AllowedModes[0] != ModeJMX { + t.Fatalf("expected allowed modes [jmx], got %#v", got.JVM.AllowedModes) + } + if got.JVM.JMX.Port != 9010 { + t.Fatalf("expected JMX port to inherit root port 9010, got %d", got.JVM.JMX.Port) + } +} + +func TestNormalizeConnectionConfigFallsBackToFirstAllowedMode(t *testing.T) { + raw := connection.ConnectionConfig{ + Type: "jvm", + Host: "cache-svc.internal", + JVM: connection.JVMConfig{ + AllowedModes: []string{ModeEndpoint, ModeJMX}, + PreferredMode: ModeAgent, + Endpoint: connection.JVMEndpointConfig{ + Enabled: true, + BaseURL: "https://cache-svc.internal/manage/jvm", + }, + }, + } + + got, err := NormalizeConnectionConfig(raw) + if err != nil { + t.Fatalf("NormalizeConnectionConfig returned error: %v", err) + } + if got.JVM.PreferredMode != ModeEndpoint { + t.Fatalf("expected preferred mode %q, got %q", ModeEndpoint, got.JVM.PreferredMode) + } +} + +func TestNormalizeConnectionConfigKeepsExplicitReadOnlyFalse(t *testing.T) { + readOnly := false + raw := connection.ConnectionConfig{ + Type: "jvm", + Port: 9010, + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + }, + } + + got, err := NormalizeConnectionConfig(raw) + if err != nil { + t.Fatalf("NormalizeConnectionConfig returned error: %v", err) + } + if got.JVM.ReadOnly == nil { + t.Fatalf("expected readOnly to remain explicitly configured") + } + if *got.JVM.ReadOnly { + t.Fatalf("expected explicit readOnly=false to be preserved") + } +} + +func TestNormalizeConnectionConfigDefaultsJMXPortTo9010WhenPortsMissing(t *testing.T) { + raw := connection.ConnectionConfig{ + Type: "jvm", + Host: "orders-prod.internal", + Port: 0, + } + + got, err := NormalizeConnectionConfig(raw) + if err != nil { + t.Fatalf("NormalizeConnectionConfig returned error: %v", err) + } + if got.JVM.JMX.Port != 9010 { + t.Fatalf("expected JMX port default 9010, got %d", got.JVM.JMX.Port) + } +} diff --git a/internal/jvm/types.go b/internal/jvm/types.go new file mode 100644 index 0000000..efbcf93 --- /dev/null +++ b/internal/jvm/types.go @@ -0,0 +1,74 @@ +package jvm + +const ( + ModeJMX = "jmx" + ModeEndpoint = "endpoint" + ModeAgent = "agent" + EnvPROD = "prod" +) + +type Capability struct { + Mode string `json:"mode"` + CanBrowse bool `json:"canBrowse"` + CanWrite bool `json:"canWrite"` + CanPreview bool `json:"canPreview"` + Reason string `json:"reason,omitempty"` + DisplayLabel string `json:"displayLabel"` +} + +type ResourceSummary struct { + ID string `json:"id"` + ParentID string `json:"parentId,omitempty"` + Kind string `json:"kind"` + Name string `json:"name"` + Path string `json:"path"` + ProviderMode string `json:"providerMode"` + CanRead bool `json:"canRead"` + CanWrite bool `json:"canWrite"` + HasChildren bool `json:"hasChildren"` + Sensitive bool `json:"sensitive,omitempty"` +} + +type ValueSnapshot struct { + ResourceID string `json:"resourceId"` + Kind string `json:"kind"` + Format string `json:"format"` + Version string `json:"version,omitempty"` + Value interface{} `json:"value"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type ChangeRequest struct { + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + ExpectedVersion string `json:"expectedVersion,omitempty"` + Payload map[string]any `json:"payload,omitempty"` +} + +type ChangePreview struct { + Allowed bool `json:"allowed"` + RequiresConfirmation bool `json:"requiresConfirmation,omitempty"` + Summary string `json:"summary"` + RiskLevel string `json:"riskLevel"` + BlockingReason string `json:"blockingReason,omitempty"` + Before ValueSnapshot `json:"before"` + After ValueSnapshot `json:"after"` +} + +type ApplyResult struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + UpdatedValue ValueSnapshot `json:"updatedValue"` +} + +type AuditRecord struct { + Timestamp int64 `json:"timestamp"` + ConnectionID string `json:"connectionId"` + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + Result string `json:"result"` +} From 03a1506686a6170848c88ebaa72c74aec9546cf6 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 22 Apr 2026 17:52:28 +0800 Subject: [PATCH 04/32] =?UTF-8?q?=E2=9C=A8=20feat(jvm):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=BF=9E=E6=8E=A5=E6=B5=8B=E8=AF=95=E4=B8=8E=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E6=8E=A2=E6=B5=8B=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 JVM provider 工厂与 JMX、Endpoint 骨架实现 - 暴露 TestJVMConnection 和 JVMProbeCapabilities 并统一 QueryResult 返回 - 刷新 Wails 绑定与 JVM 连接模型,补齐前后端方法签名 - 补充 App 编排测试与 provider 契约测试,避免假成功和静默成功 - 更新需求追踪,记录 Task 2 审查结论与验证证据 --- .../需求进度追踪-JVM缓存可视化编辑-20260422.md | 26 +- frontend/wailsjs/go/app/App.d.ts | 12 +- frontend/wailsjs/go/app/App.js | 24 +- frontend/wailsjs/go/models.ts | 85 +++++++ internal/app/methods_jvm.go | 60 +++++ internal/app/methods_jvm_test.go | 240 ++++++++++++++++++ internal/jvm/http_provider.go | 93 +++++++ internal/jvm/jmx_provider.go | 64 +++++ internal/jvm/provider.go | 57 +++++ internal/jvm/provider_contract_test.go | 102 ++++++++ 10 files changed, 746 insertions(+), 17 deletions(-) create mode 100644 internal/app/methods_jvm.go create mode 100644 internal/app/methods_jvm_test.go create mode 100644 internal/jvm/http_provider.go create mode 100644 internal/jvm/jmx_provider.go create mode 100644 internal/jvm/provider.go create mode 100644 internal/jvm/provider_contract_test.go diff --git a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md index d578e78..09213a8 100644 --- a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md +++ b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md @@ -26,7 +26,7 @@ - [x] 阶段 2(影响分析):完成 - [x] 阶段 3(方案设计):完成(已形成正式设计文档) - [x] 阶段 4(实施计划):完成(已形成正式实施计划) -- [ ] 阶段 5(实现与自检):进行中(Task 1 已完成并通过回归) +- [ ] 阶段 5(实现与自检):进行中(Task 1、Task 2 已完成并通过回归) - [ ] 阶段 6(评审与交付): - [ ] 阶段 7(发布与观察): @@ -40,11 +40,12 @@ - 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach - 已形成 JVM 缓存可视化编辑正式设计文档 - 已形成 JVM Connector MVP 正式实施计划文档 -- - 已完成 Task 1:JVM 共享契约与配置归一化 + - 已完成 Task 1:JVM 共享契约与配置归一化 + - 已完成 Task 2:Provider 注册、连接测试与能力探测 API - 进行中: - - Task 2:建立后端 Provider 注册与连接探测 API + - Task 3:接入 JVM 连接表单与图标 - 待处理: - - Task 3+:Guard/Audit/App/UI/AI 结构化计划等后续任务 + - Task 4+:只读资源浏览、Guard/Audit、AI 结构化计划等后续任务 ## 5. 风险与阻塞 - 风险: @@ -73,12 +74,15 @@ - JVM Connector 正式设计文档自检 - JVM Connector 实施计划文档自检 - Task 1:JVM 共享契约与配置归一化 + - Task 2:Provider 注册、连接测试与能力探测 API - 结果: - 已确认存在可复用的连接桥接与编辑器基础设施 - 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突 - 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节 - 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测 - Task 1 已完成规格审查与代码质量审查,结论均通过 + - 已完成 JVM Provider 工厂、JMX/Endpoint provider 骨架、App 层连接测试与能力探测 API + - Task 2 已完成规格审查与代码质量审查,结论均通过 - 证据(日志/截图/链接): - `cmd/optional-driver-agent/main.go` - `internal/db/database.go` @@ -99,7 +103,19 @@ - `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts` - `cd frontend && npm test -- --run` - `cd frontend && npm run build` + - `internal/jvm/provider.go` + - `internal/jvm/jmx_provider.go` + - `internal/jvm/http_provider.go` + - `internal/jvm/provider_contract_test.go` + - `internal/app/methods_jvm.go` + - `internal/app/methods_jvm_test.go` + - `frontend/wailsjs/go/app/App.d.ts` + - `frontend/wailsjs/go/app/App.js` + - `frontend/wailsjs/go/models.ts` + - `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` + - `go test ./internal/jvm ./internal/app -count=1` + - `wails build -clean` ## 8. 下一步 -- 下一步行动:进入 Task 2,建立 JVM Provider 注册、连接测试与能力探测 API,并在完成后生成/校验 Wails 绑定代码 +- 下一步行动:进入 Task 3,接入 JVM 连接表单、图标与展示文案,并在前端完成最小交互闭环 - 负责人:Codex diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 30f7e6b..1fd4fc9 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -127,6 +127,10 @@ export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string,ar export function InstallUpdateAndRestart():Promise; +export function JVMProbeCapabilities(arg1:connection.ConnectionConfig):Promise; + +export function ListSQLDirectory(arg1:string):Promise; + export function LogWindowDiagnostic(arg1:string,arg2:string):Promise; export function MongoDiscoverMembers(arg1:connection.ConnectionConfig):Promise; @@ -149,8 +153,6 @@ export function OpenDriverDownloadDirectory(arg1:string):Promise; -export function ListSQLDirectory(arg1:string):Promise; - export function PreviewImportFile(arg1:string):Promise; export function ReadSQLFile(arg1:string):Promise; @@ -223,8 +225,6 @@ export function RetrySecurityUpdateCurrentRound(arg1:app.RetrySecurityUpdateRequ export function SaveConnection(arg1:connection.SavedConnectionInput):Promise; -export function SelectSQLDirectory(arg1:string):Promise; - export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise; export function SelectDataRootDirectory(arg1:string):Promise; @@ -237,6 +237,8 @@ export function SelectDriverPackageDirectory(arg1:string):Promise; +export function SelectSQLDirectory(arg1:string):Promise; + export function SelectSSHKeyFile(arg1:string):Promise; export function SetMacNativeWindowControls(arg1:boolean):Promise; @@ -247,4 +249,6 @@ export function StartSecurityUpdate(arg1:app.StartSecurityUpdateRequest):Promise export function TestConnection(arg1:connection.ConnectionConfig):Promise; +export function TestJVMConnection(arg1:connection.ConnectionConfig):Promise; + export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index e610187..f461f22 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -246,6 +246,14 @@ export function InstallUpdateAndRestart() { return window['go']['app']['App']['InstallUpdateAndRestart'](); } +export function JVMProbeCapabilities(arg1) { + return window['go']['app']['App']['JVMProbeCapabilities'](arg1); +} + +export function ListSQLDirectory(arg1) { + return window['go']['app']['App']['ListSQLDirectory'](arg1); +} + export function LogWindowDiagnostic(arg1, arg2) { return window['go']['app']['App']['LogWindowDiagnostic'](arg1, arg2); } @@ -290,10 +298,6 @@ export function OpenSQLFile() { return window['go']['app']['App']['OpenSQLFile'](); } -export function ListSQLDirectory(arg1) { - return window['go']['app']['App']['ListSQLDirectory'](arg1); -} - export function PreviewImportFile(arg1) { return window['go']['app']['App']['PreviewImportFile'](arg1); } @@ -438,10 +442,6 @@ export function SaveConnection(arg1) { return window['go']['app']['App']['SaveConnection'](arg1); } -export function SelectSQLDirectory(arg1) { - return window['go']['app']['App']['SelectSQLDirectory'](arg1); -} - export function SaveGlobalProxy(arg1) { return window['go']['app']['App']['SaveGlobalProxy'](arg1); } @@ -466,6 +466,10 @@ export function SelectDriverPackageFile(arg1) { return window['go']['app']['App']['SelectDriverPackageFile'](arg1); } +export function SelectSQLDirectory(arg1) { + return window['go']['app']['App']['SelectSQLDirectory'](arg1); +} + export function SelectSSHKeyFile(arg1) { return window['go']['app']['App']['SelectSSHKeyFile'](arg1); } @@ -486,6 +490,10 @@ export function TestConnection(arg1) { return window['go']['app']['App']['TestConnection'](arg1); } +export function TestJVMConnection(arg1) { + return window['go']['app']['App']['TestJVMConnection'](arg1); +} + export function TruncateTables(arg1, arg2, arg3) { return window['go']['app']['App']['TruncateTables'](arg1, arg2, arg3); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index bd26995..eea5834 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -456,6 +456,86 @@ export namespace connection { return a; } } + export class JVMEndpointConfig { + enabled?: boolean; + baseUrl?: string; + apiKey?: string; + timeoutSeconds?: number; + + static createFrom(source: any = {}) { + return new JVMEndpointConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.baseUrl = source["baseUrl"]; + this.apiKey = source["apiKey"]; + this.timeoutSeconds = source["timeoutSeconds"]; + } + } + export class JVMJMXConfig { + enabled?: boolean; + host?: string; + port?: number; + username?: string; + password?: string; + domainAllowlist?: string[]; + + static createFrom(source: any = {}) { + return new JVMJMXConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.host = source["host"]; + this.port = source["port"]; + this.username = source["username"]; + this.password = source["password"]; + this.domainAllowlist = source["domainAllowlist"]; + } + } + export class JVMConfig { + environment?: string; + readOnly?: boolean; + allowedModes?: string[]; + preferredMode?: string; + jmx?: JVMJMXConfig; + endpoint?: JVMEndpointConfig; + + static createFrom(source: any = {}) { + return new JVMConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.environment = source["environment"]; + this.readOnly = source["readOnly"]; + this.allowedModes = source["allowedModes"]; + this.preferredMode = source["preferredMode"]; + this.jmx = this.convertValues(source["jmx"], JVMJMXConfig); + this.endpoint = this.convertValues(source["endpoint"], JVMEndpointConfig); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class HTTPTunnelConfig { host: string; port: number; @@ -549,6 +629,7 @@ export namespace connection { mongoAuthMechanism?: string; mongoReplicaUser?: string; mongoReplicaPassword?: string; + jvm?: JVMConfig; static createFrom(source: any = {}) { return new ConnectionConfig(source); @@ -590,6 +671,7 @@ export namespace connection { this.mongoAuthMechanism = source["mongoAuthMechanism"]; this.mongoReplicaUser = source["mongoReplicaUser"]; this.mongoReplicaPassword = source["mongoReplicaPassword"]; + this.jvm = this.convertValues(source["jvm"], JVMConfig); } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -638,6 +720,9 @@ export namespace connection { } + + + export class QueryResult { success: boolean; message: string; diff --git a/internal/app/methods_jvm.go b/internal/app/methods_jvm.go new file mode 100644 index 0000000..89d329e --- /dev/null +++ b/internal/app/methods_jvm.go @@ -0,0 +1,60 @@ +package app + +import ( + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/jvm" +) + +var newJVMProvider = jvm.NewProvider + +func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.QueryResult { + normalized, err := jvm.NormalizeConnectionConfig(cfg) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + provider, err := newJVMProvider(normalized.JVM.PreferredMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + if err := provider.TestConnection(a.ctx, normalized); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + return connection.QueryResult{Success: true, Message: "JVM 连接成功"} +} + +func (a *App) JVMProbeCapabilities(cfg connection.ConnectionConfig) connection.QueryResult { + normalized, err := jvm.NormalizeConnectionConfig(cfg) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + items := make([]jvm.Capability, 0, len(normalized.JVM.AllowedModes)) + for _, mode := range normalized.JVM.AllowedModes { + provider, providerErr := newJVMProvider(mode) + if providerErr != nil { + items = append(items, jvm.Capability{ + Mode: mode, + DisplayLabel: jvm.ModeDisplayLabel(mode), + Reason: providerErr.Error(), + }) + continue + } + + caps, probeErr := provider.ProbeCapabilities(a.ctx, normalized) + if probeErr != nil { + items = append(items, jvm.Capability{ + Mode: mode, + DisplayLabel: jvm.ModeDisplayLabel(mode), + Reason: probeErr.Error(), + }) + continue + } + + items = append(items, caps...) + } + + return connection.QueryResult{Success: true, Data: items} +} diff --git a/internal/app/methods_jvm_test.go b/internal/app/methods_jvm_test.go new file mode 100644 index 0000000..3bd20cd --- /dev/null +++ b/internal/app/methods_jvm_test.go @@ -0,0 +1,240 @@ +package app + +import ( + "context" + "errors" + "strings" + "testing" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/jvm" +) + +type fakeJVMProvider struct { + testErr error + probe []jvm.Capability + probeErr error + list []jvm.ResourceSummary + value jvm.ValueSnapshot + apply jvm.ApplyResult +} + +func (f fakeJVMProvider) Mode() string { return jvm.ModeJMX } +func (f fakeJVMProvider) TestConnection(context.Context, connection.ConnectionConfig) error { + return f.testErr +} +func (f fakeJVMProvider) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]jvm.Capability, error) { + return f.probe, f.probeErr +} +func (f fakeJVMProvider) ListResources(context.Context, connection.ConnectionConfig, string) ([]jvm.ResourceSummary, error) { + return f.list, nil +} +func (f fakeJVMProvider) GetValue(context.Context, connection.ConnectionConfig, string) (jvm.ValueSnapshot, error) { + return f.value, nil +} +func (f fakeJVMProvider) PreviewChange(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ChangePreview, error) { + return jvm.ChangePreview{Allowed: true, Summary: "preview"}, nil +} +func (f fakeJVMProvider) ApplyChange(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) { + return f.apply, nil +} + +func swapJVMProviderFactory(factory func(mode string) (jvm.Provider, error)) func() { + prev := newJVMProvider + newJVMProvider = factory + return func() { newJVMProvider = prev } +} + +func TestTestJVMConnectionUsesPreferredProvider(t *testing.T) { + app := NewAppWithSecretStore(nil) + var gotMode string + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + gotMode = mode + return fakeJVMProvider{}, nil + }) + defer restore() + + res := app.TestJVMConnection(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "endpoint", + AllowedModes: []string{"jmx", "endpoint"}, + }, + }) + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + if gotMode != "endpoint" { + t.Fatalf("expected provider mode endpoint, got %q", gotMode) + } + if res.Message != "JVM 连接成功" { + t.Fatalf("expected success message %q, got %q", "JVM 连接成功", res.Message) + } +} + +func TestTestJVMConnectionReturnsProviderError(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{testErr: errors.New("dial failed")}, nil + }) + defer restore() + + res := app.TestJVMConnection(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }) + + if res.Success { + t.Fatalf("expected failure, got %+v", res) + } + if res.Message != "dial failed" { + t.Fatalf("expected message %q, got %q", "dial failed", res.Message) + } +} + +func TestTestJVMConnectionReturnsProviderFactoryError(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return nil, errors.New("factory unavailable") + }) + defer restore() + + res := app.TestJVMConnection(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "endpoint", + AllowedModes: []string{"endpoint"}, + }, + }) + + if res.Success { + t.Fatalf("expected failure, got %+v", res) + } + if res.Message != "factory unavailable" { + t.Fatalf("expected message %q, got %q", "factory unavailable", res.Message) + } +} + +func TestJVMProbeCapabilitiesReturnsCapabilityArray(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + probe: []jvm.Capability{{Mode: jvm.ModeJMX, CanBrowse: true, CanWrite: false, CanPreview: false, DisplayLabel: "JMX"}}, + }, nil + }) + defer restore() + + res := app.JVMProbeCapabilities(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }) + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + items, ok := res.Data.([]jvm.Capability) + if !ok || len(items) != 1 { + t.Fatalf("expected one capability, got %#v", res.Data) + } +} + +func TestJVMProbeCapabilitiesIncludesReasonWhenProbeFails(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + probeErr: errors.New("probe failed"), + }, nil + }) + defer restore() + + res := app.JVMProbeCapabilities(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }) + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + items, ok := res.Data.([]jvm.Capability) + if !ok || len(items) != 1 { + t.Fatalf("expected one capability, got %#v", res.Data) + } + if items[0].Reason != "probe failed" { + t.Fatalf("expected reason %q, got %#v", "probe failed", items[0]) + } +} + +func TestJVMProbeCapabilitiesIncludesReasonWhenProviderFactoryFails(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return nil, errors.New("provider disabled") + }) + defer restore() + + res := app.JVMProbeCapabilities(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "endpoint", + AllowedModes: []string{"endpoint"}, + }, + }) + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + items, ok := res.Data.([]jvm.Capability) + if !ok || len(items) != 1 { + t.Fatalf("expected one capability, got %#v", res.Data) + } + if items[0].Reason != "provider disabled" { + t.Fatalf("expected reason %q, got %#v", "provider disabled", items[0]) + } + if items[0].DisplayLabel != "Endpoint" { + t.Fatalf("expected display label %q, got %#v", "Endpoint", items[0]) + } +} + +func TestJVMProbeCapabilitiesUsesReadableLabelForUnsupportedMode(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(jvm.NewProvider) + defer restore() + + res := app.JVMProbeCapabilities(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "agent", + AllowedModes: []string{"agent"}, + }, + }) + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + items, ok := res.Data.([]jvm.Capability) + if !ok || len(items) != 1 { + t.Fatalf("expected one capability, got %#v", res.Data) + } + if items[0].DisplayLabel != "Agent" { + t.Fatalf("expected display label %q, got %#v", "Agent", items[0]) + } + if !strings.Contains(items[0].Reason, "unsupported jvm provider mode") { + t.Fatalf("expected unsupported mode error, got %#v", items[0]) + } +} diff --git a/internal/jvm/http_provider.go b/internal/jvm/http_provider.go new file mode 100644 index 0000000..a629ab5 --- /dev/null +++ b/internal/jvm/http_provider.go @@ -0,0 +1,93 @@ +package jvm + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "GoNavi-Wails/internal/connection" +) + +type HTTPProvider struct{} + +func NewHTTPProvider() Provider { return &HTTPProvider{} } + +func (p *HTTPProvider) Mode() string { return ModeEndpoint } + +func (p *HTTPProvider) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error { + baseURL := strings.TrimSpace(cfg.JVM.Endpoint.BaseURL) + if baseURL == "" { + return fmt.Errorf("endpoint baseURL is required") + } + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("endpoint baseURL is invalid: %s", baseURL) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("endpoint scheme is unsupported: %s", parsed.Scheme) + } + + timeout := time.Duration(cfg.JVM.Endpoint.TimeoutSeconds) * time.Second + if timeout <= 0 { + timeout = time.Duration(cfg.Timeout) * time.Second + } + if timeout <= 0 { + timeout = 5 * time.Second + } + client := &http.Client{Timeout: timeout} + resp, err := doEndpointProbe(ctx, client, baseURL, http.MethodHead) + if err != nil { + return err + } + if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented { + _ = resp.Body.Close() + resp, err = doEndpointProbe(ctx, client, baseURL, http.MethodGet) + if err != nil { + return err + } + } + defer resp.Body.Close() + if isReachableStatus(resp.StatusCode) { + return nil + } + return fmt.Errorf("endpoint returned unexpected status: %d", resp.StatusCode) +} + +func (p *HTTPProvider) ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) { + return []Capability{{Mode: ModeEndpoint, CanBrowse: true, CanWrite: true, CanPreview: true, DisplayLabel: "Endpoint"}}, nil +} + +func (p *HTTPProvider) ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) { + return nil, errProviderNotImplemented(p.Mode(), "list resources") +} + +func (p *HTTPProvider) GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) { + return ValueSnapshot{}, errProviderNotImplemented(p.Mode(), "get value") +} + +func (p *HTTPProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { + return ChangePreview{}, errProviderNotImplemented(p.Mode(), "preview change") +} + +func (p *HTTPProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) { + return ApplyResult{}, errProviderNotImplemented(p.Mode(), "apply change") +} + +func doEndpointProbe(ctx context.Context, client *http.Client, baseURL string, method string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, baseURL, nil) + if err != nil { + return nil, fmt.Errorf("endpoint request build failed: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("endpoint request failed: %w", err) + } + return resp, nil +} + +func isReachableStatus(statusCode int) bool { + return (statusCode >= 200 && statusCode < 400) || statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden +} diff --git a/internal/jvm/jmx_provider.go b/internal/jvm/jmx_provider.go new file mode 100644 index 0000000..d9c6064 --- /dev/null +++ b/internal/jvm/jmx_provider.go @@ -0,0 +1,64 @@ +package jvm + +import ( + "context" + "fmt" + "net" + "strconv" + "strings" + "time" + + "GoNavi-Wails/internal/connection" +) + +type JMXProvider struct{} + +func NewJMXProvider() Provider { return &JMXProvider{} } + +func (p *JMXProvider) Mode() string { return ModeJMX } + +func (p *JMXProvider) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error { + host := strings.TrimSpace(cfg.JVM.JMX.Host) + if host == "" { + host = strings.TrimSpace(cfg.Host) + } + if host == "" { + return fmt.Errorf("jmx host is required") + } + port := cfg.JVM.JMX.Port + if port <= 0 { + return fmt.Errorf("jmx port is invalid: %d", port) + } + + timeout := time.Duration(cfg.Timeout) * time.Second + if timeout <= 0 { + timeout = 5 * time.Second + } + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + return fmt.Errorf("jmx tcp connect failed: %w", err) + } + _ = conn.Close() + return nil +} + +func (p *JMXProvider) ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) { + return []Capability{{Mode: ModeJMX, CanBrowse: true, CanWrite: false, CanPreview: false, DisplayLabel: "JMX"}}, nil +} + +func (p *JMXProvider) ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) { + return nil, errProviderNotImplemented(p.Mode(), "list resources") +} + +func (p *JMXProvider) GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) { + return ValueSnapshot{}, errProviderNotImplemented(p.Mode(), "get value") +} + +func (p *JMXProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { + return ChangePreview{}, errProviderNotImplemented(p.Mode(), "preview change") +} + +func (p *JMXProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) { + return ApplyResult{}, errProviderNotImplemented(p.Mode(), "apply change") +} diff --git a/internal/jvm/provider.go b/internal/jvm/provider.go new file mode 100644 index 0000000..1fea1fe --- /dev/null +++ b/internal/jvm/provider.go @@ -0,0 +1,57 @@ +package jvm + +import ( + "context" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +type Provider interface { + Mode() string + TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error + ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) + ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) + GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) + PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) + ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) +} + +var providerFactories = map[string]func() Provider{ + ModeJMX: func() Provider { return NewJMXProvider() }, + ModeEndpoint: func() Provider { return NewHTTPProvider() }, +} + +func NewProvider(mode string) (Provider, error) { + normalized := strings.ToLower(strings.TrimSpace(mode)) + factory, ok := providerFactories[normalized] + if !ok { + return nil, fmt.Errorf("unsupported jvm provider mode: %s", mode) + } + return factory(), nil +} + +func ModeDisplayLabel(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case ModeJMX: + return "JMX" + case ModeEndpoint: + return "Endpoint" + case ModeAgent: + return "Agent" + default: + normalized := strings.TrimSpace(mode) + if normalized == "" { + return "Unknown" + } + if len(normalized) == 1 { + return strings.ToUpper(normalized) + } + return strings.ToUpper(normalized[:1]) + strings.ToLower(normalized[1:]) + } +} + +func errProviderNotImplemented(mode string, action string) error { + return fmt.Errorf("%s provider does not implement %s yet", ModeDisplayLabel(mode), action) +} diff --git a/internal/jvm/provider_contract_test.go b/internal/jvm/provider_contract_test.go new file mode 100644 index 0000000..56bc0cb --- /dev/null +++ b/internal/jvm/provider_contract_test.go @@ -0,0 +1,102 @@ +package jvm + +import ( + "context" + "strings" + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestJMXProviderTestConnectionReturnsErrorWhenHostMissing(t *testing.T) { + provider := NewJMXProvider() + + err := provider.TestConnection(context.Background(), connection.ConnectionConfig{ + Type: "jvm", + JVM: connection.JVMConfig{ + JMX: connection.JVMJMXConfig{ + Port: 9010, + }, + }, + }) + + if err == nil { + t.Fatal("expected error when jmx host is missing") + } + if !strings.Contains(err.Error(), "jmx host is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestJMXProviderTestConnectionReturnsErrorWhenPortInvalid(t *testing.T) { + provider := NewJMXProvider() + + err := provider.TestConnection(context.Background(), connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + JMX: connection.JVMJMXConfig{ + Port: 0, + }, + }, + }) + + if err == nil { + t.Fatal("expected error when jmx port is invalid") + } + if !strings.Contains(err.Error(), "jmx port is invalid") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestHTTPProviderTestConnectionReturnsErrorWhenBaseURLMissing(t *testing.T) { + provider := NewHTTPProvider() + + err := provider.TestConnection(context.Background(), connection.ConnectionConfig{ + Type: "jvm", + JVM: connection.JVMConfig{ + Endpoint: connection.JVMEndpointConfig{ + BaseURL: "", + }, + }, + }) + + if err == nil { + t.Fatal("expected error when endpoint baseURL is missing") + } + if !strings.Contains(err.Error(), "endpoint baseURL is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestHTTPProviderTestConnectionReturnsErrorWhenBaseURLInvalid(t *testing.T) { + provider := NewHTTPProvider() + + err := provider.TestConnection(context.Background(), connection.ConnectionConfig{ + Type: "jvm", + JVM: connection.JVMConfig{ + Endpoint: connection.JVMEndpointConfig{ + BaseURL: "://bad-url", + }, + }, + }) + + if err == nil { + t.Fatal("expected error when endpoint baseURL is invalid") + } + if !strings.Contains(err.Error(), "endpoint baseURL is invalid") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestJMXProviderListResourcesReturnsNotImplementedError(t *testing.T) { + provider := NewJMXProvider() + + _, err := provider.ListResources(context.Background(), connection.ConnectionConfig{}, "") + if err == nil { + t.Fatal("expected not implemented error") + } + if !strings.Contains(strings.ToLower(err.Error()), "does not implement") { + t.Fatalf("unexpected error: %v", err) + } +} From 177dafacc9ae53fdca3111ba59fa67b0bfc6590b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 23 Apr 2026 09:23:28 +0800 Subject: [PATCH 05/32] =?UTF-8?q?=E2=9C=A8=20feat(frontend):=E6=8E=A5?= =?UTF-8?q?=E5=85=A5JVM=E8=BF=9E=E6=8E=A5=E8=A1=A8=E5=8D=95=E4=B8=8E?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=85=83=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/ConnectionModal.tsx | 262 +++++++++++++++++- frontend/src/components/DatabaseIcons.tsx | 10 +- .../src/utils/jvmRuntimePresentation.test.ts | 14 + frontend/src/utils/jvmRuntimePresentation.ts | 74 +++++ 4 files changed, 343 insertions(+), 17 deletions(-) create mode 100644 frontend/src/utils/jvmRuntimePresentation.test.ts create mode 100644 frontend/src/utils/jvmRuntimePresentation.ts diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index ff1b4b6..f1ae3ae 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd'; -import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined, BgColorsOutlined } from '@ant-design/icons'; +import { DatabaseOutlined, FileTextOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined, BgColorsOutlined } from '@ant-design/icons'; import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICON_COLORS } from './DatabaseIcons'; import { useStore } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; @@ -14,10 +14,11 @@ import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft'; import { getCustomConnectionDsnValidationMessage } from '../utils/customConnectionDsn'; import { CUSTOM_CONNECTION_DRIVER_HELP } from '../utils/driverImportGuidance'; import { applyNoAutoCapAttributes, noAutoCapInputProps } from '../utils/inputAutoCap'; -import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App'; +import { buildDefaultJVMConnectionValues, buildJVMConnectionConfig } from '../utils/jvmConnectionConfig'; +import { JVM_RUNTIME_MODES, resolveJVMModeMeta } from '../utils/jvmRuntimePresentation'; +import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile, TestJVMConnection } from '../../wailsjs/go/app/App'; import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types'; -const { Meta } = Card; const { Text } = Typography; const MAX_URI_LENGTH = 4096; const MAX_URI_HOSTS = 32; @@ -51,6 +52,7 @@ const createEmptyConnectionSecretClearState = (): ConnectionSecretClearState => const getDefaultPortByType = (type: string) => { switch (type) { + case 'jvm': return 9010; case 'mysql': return 3306; case 'doris': case 'diros': return 9030; @@ -169,6 +171,16 @@ const ConnectionModal: React.FC<{ const mongoTopology = Form.useWatch('mongoTopology', form) || 'single'; const mongoSrv = Form.useWatch('mongoSrv', form) || false; const redisTopology = Form.useWatch('redisTopology', form) || 'single'; + const jvmAllowedModes = Form.useWatch('jvmAllowedModes', form); + const jvmPreferredMode = Form.useWatch('jvmPreferredMode', form) || 'jmx'; + const normalizedJvmAllowedModes = useMemo(() => { + const modes = Array.isArray(jvmAllowedModes) + ? jvmAllowedModes + .map((mode) => String(mode || '').trim().toLowerCase()) + .filter((mode) => JVM_RUNTIME_MODES.includes(mode as typeof JVM_RUNTIME_MODES[number])) + : []; + return modes.length > 0 ? Array.from(new Set(modes)) : ['jmx']; + }, [jvmAllowedModes]); const isMySQLLike = dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx'; const isSSLType = supportsSSLForType(dbType); const sslHintText = isMySQLLike @@ -1187,8 +1199,10 @@ const ConnectionModal: React.FC<{ setStep(2); const config: any = initialValues.config || {}; const configType = String(config.type || 'mysql'); + const isJvmConfigType = configType === 'jvm'; const defaultPort = getDefaultPortByType(configType); const isFileDbConfigType = isFileDatabaseType(configType); + const jvmDefaultValues = buildDefaultJVMConnectionValues(); const normalizedHosts = isFileDbConfigType ? [] : normalizeAddressList(config.hosts, defaultPort); const primaryAddress = isFileDbConfigType ? null @@ -1208,6 +1222,18 @@ const ConnectionModal: React.FC<{ const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0; const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet; const redisIsCluster = String(config.topology || '').toLowerCase() === 'cluster' || redisHosts.length > 0; + const jvmAllowedModes = Array.isArray(config.jvm?.allowedModes) && config.jvm.allowedModes.length > 0 + ? config.jvm.allowedModes.map((mode: string) => String(mode || '').trim().toLowerCase()) + : jvmDefaultValues.jvmAllowedModes; + const normalizedJvmAllowedModes = jvmAllowedModes.filter((mode: string) => + JVM_RUNTIME_MODES.includes(mode as typeof JVM_RUNTIME_MODES[number]), + ); + const resolvedJvmAllowedModes = normalizedJvmAllowedModes.length > 0 + ? Array.from(new Set(normalizedJvmAllowedModes)) + : jvmDefaultValues.jvmAllowedModes; + const resolvedJvmPreferredMode = resolvedJvmAllowedModes.includes(String(config.jvm?.preferredMode || '').trim().toLowerCase()) + ? String(config.jvm?.preferredMode || '').trim().toLowerCase() + : resolvedJvmAllowedModes[0]; const hasHttpTunnel = !!config.useHttpTunnel; const hasProxy = !hasHttpTunnel && !!config.useProxy; form.setFieldsValue({ @@ -1261,7 +1287,27 @@ const ConnectionModal: React.FC<{ savePassword: config.savePassword !== false, redisDB: Number.isFinite(Number(config.redisDB)) ? Number(config.redisDB) : 0, mongoReplicaUser: config.mongoReplicaUser || '', - mongoReplicaPassword: config.mongoReplicaPassword || '' + mongoReplicaPassword: config.mongoReplicaPassword || '', + jvmReadOnly: isJvmConfigType ? config.jvm?.readOnly ?? jvmDefaultValues.jvmReadOnly : jvmDefaultValues.jvmReadOnly, + jvmAllowedModes: isJvmConfigType ? resolvedJvmAllowedModes : jvmDefaultValues.jvmAllowedModes, + jvmPreferredMode: isJvmConfigType ? resolvedJvmPreferredMode : jvmDefaultValues.jvmPreferredMode, + jvmEnvironment: isJvmConfigType ? config.jvm?.environment || jvmDefaultValues.jvmEnvironment : jvmDefaultValues.jvmEnvironment, + jvmEndpointEnabled: isJvmConfigType + ? config.jvm?.endpoint?.enabled ?? resolvedJvmAllowedModes.includes('endpoint') + : jvmDefaultValues.jvmEndpointEnabled, + jvmEndpointBaseUrl: isJvmConfigType ? config.jvm?.endpoint?.baseUrl || '' : jvmDefaultValues.jvmEndpointBaseUrl, + jvmEndpointApiKey: isJvmConfigType ? config.jvm?.endpoint?.apiKey || '' : jvmDefaultValues.jvmEndpointApiKey, + jvmEndpointTimeoutSeconds: isJvmConfigType + ? Number(config.jvm?.endpoint?.timeoutSeconds || config.timeout || 30) + : Number(config.timeout || 30), + jvmJmxHost: isJvmConfigType && config.jvm?.jmx?.host && config.jvm.jmx.host !== primaryHost + ? config.jvm.jmx.host + : '', + jvmJmxPort: isJvmConfigType && Number(config.jvm?.jmx?.port) > 0 && Number(config.jvm.jmx.port) !== Number(primaryPort || defaultPort) + ? Number(config.jvm.jmx.port) + : undefined, + jvmJmxUsername: isJvmConfigType ? config.jvm?.jmx?.username || '' : '', + jvmJmxPassword: isJvmConfigType ? config.jvm?.jmx?.password || '' : '' }); setUseSSL(!!config.useSSL); setCustomIconType(initialValues.iconType); @@ -1559,10 +1605,13 @@ const ConnectionModal: React.FC<{ : 30; const rpcTimeoutMs = (timeoutSeconds + 5) * 1000; - // Use different API for Redis + // Use different API for Redis / JVM const isRedisType = values.type === 'redis'; + const isJVMType = values.type === 'jvm'; const res = await withClientTimeout( - isRedisType + isJVMType + ? TestJVMConnection(config as any) + : isRedisType ? RedisConnect(config as any) : TestConnection(config as any), rpcTimeoutMs, @@ -1574,7 +1623,7 @@ const ConnectionModal: React.FC<{ setTestResult({ type: 'success', message: res.message }); if (isRedisType) { setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); - } else { + } else if (!isJVMType) { // Other databases: fetch database list const dbRes = await withClientTimeout( DBGetDatabases(config as any), @@ -1675,6 +1724,26 @@ const ConnectionModal: React.FC<{ const buildConfig = async (values: any, forPersist: boolean): Promise => { const mergedValues = { ...values }; + if (String(mergedValues.type || '').trim().toLowerCase() === 'jvm') { + const nextJvmAllowedModes = Array.isArray(mergedValues.jvmAllowedModes) + ? mergedValues.jvmAllowedModes + .map((mode: string) => String(mode || '').trim().toLowerCase()) + .filter((mode: string) => JVM_RUNTIME_MODES.includes(mode as typeof JVM_RUNTIME_MODES[number])) + : []; + const resolvedJvmAllowedModes = nextJvmAllowedModes.length > 0 + ? Array.from(new Set(nextJvmAllowedModes)) + : buildDefaultJVMConnectionValues().jvmAllowedModes; + return buildJVMConnectionConfig({ + ...buildDefaultJVMConnectionValues(), + ...mergedValues, + jvmAllowedModes: resolvedJvmAllowedModes, + jvmPreferredMode: resolvedJvmAllowedModes.includes(String(mergedValues.jvmPreferredMode || '').trim().toLowerCase()) + ? String(mergedValues.jvmPreferredMode || '').trim().toLowerCase() + : resolvedJvmAllowedModes[0], + jvmEndpointEnabled: resolvedJvmAllowedModes.includes('endpoint'), + jvmEndpointTimeoutSeconds: Number(mergedValues.jvmEndpointTimeoutSeconds || mergedValues.timeout || 30), + }); + } const parsedUriValues = parseUriToValues(mergedValues.uri, mergedValues.type); const isEmptyField = (value: unknown) => ( value === undefined @@ -1905,7 +1974,66 @@ const ConnectionModal: React.FC<{ form.setFieldsValue({ type: type }); const defaultPort = getDefaultPortByType(type); - if (isFileDatabaseType(type)) { + if (type === 'jvm') { + const jvmDefaultValues = buildDefaultJVMConnectionValues(); + setUseSSL(false); + setUseSSH(false); + setUseProxy(false); + setUseHttpTunnel(false); + form.setFieldsValue({ + ...jvmDefaultValues, + user: '', + password: '', + database: '', + useSSL: false, + sslMode: undefined, + sslCertPath: undefined, + sslKeyPath: undefined, + useSSH: false, + sshHost: '', + sshPort: 22, + sshUser: '', + sshPassword: '', + sshKeyPath: '', + useProxy: false, + proxyType: 'socks5', + proxyHost: '', + proxyPort: 1080, + proxyUser: '', + proxyPassword: '', + useHttpTunnel: false, + httpTunnelHost: '', + httpTunnelPort: 8080, + httpTunnelUser: '', + httpTunnelPassword: '', + timeout: 30, + uri: '', + includeDatabases: undefined, + includeRedisDatabases: undefined, + mysqlTopology: 'single', + redisTopology: 'single', + mongoTopology: 'single', + mongoSrv: false, + mongoReadPreference: 'primary', + mongoReplicaSet: '', + mongoAuthSource: '', + mongoAuthMechanism: '', + savePassword: true, + mysqlReplicaHosts: [], + redisHosts: [], + mongoHosts: [], + mysqlReplicaUser: '', + mysqlReplicaPassword: '', + mongoReplicaUser: '', + mongoReplicaPassword: '', + redisDB: 0, + jvmEndpointTimeoutSeconds: 30, + jvmJmxHost: '', + jvmJmxPort: undefined, + jvmJmxUsername: '', + jvmJmxPassword: '', + }); + } else if (isFileDatabaseType(type)) { setUseSSL(false); setUseSSH(false); setUseProxy(false); @@ -2008,6 +2136,7 @@ const ConnectionModal: React.FC<{ const isFileDb = isFileDatabaseType(dbType); const isCustom = dbType === 'custom'; const isRedis = dbType === 'redis'; + const isJVM = dbType === 'jvm'; const currentDriverType = normalizeDriverType(dbType); const currentDriverSnapshot = driverStatusMap[currentDriverType]; const currentDriverUnavailableReason = currentDriverType !== 'custom' @@ -2044,6 +2173,7 @@ const ConnectionModal: React.FC<{ { key: 'tdengine', name: 'TDengine', icon: getDbIcon('tdengine', undefined, 36) }, ]}, { label: '其他', items: [ + { key: 'jvm', name: 'JVM Runtime', icon: getDbIcon('jvm', undefined, 36) }, { key: 'custom', name: 'Custom (自定义)', icon: getDbIcon('custom', undefined, 36) }, ]}, ]; @@ -2128,7 +2258,7 @@ const ConnectionModal: React.FC<{ - {!isCustom && ( + {!isCustom && !isJVM && ( <> + ) : isJVM ? ( + <> +
+ + + + + + +
+ + + ({ + value: mode, + label: resolveJVMModeMeta(mode).label, + }))} + /> + + + + 只读模式 + + + + + + + + + + ) : ( <>
@@ -2428,7 +2635,7 @@ const ConnectionModal: React.FC<{ )} - {!isFileDb && !isRedis && ( + {!isFileDb && !isRedis && !isJVM && ( <>
)} - {!isFileDb && !isRedis && ( + {!isFileDb && !isRedis && !isJVM && ( ({ value: item, label: `最近 ${item} 条` }))} + style={{ width: 128 }} + /> + + + {connection.name} + {connection.id} + + + + + + + {error ? : null} + + rowKey={(record) => `${record.timestamp}-${record.resourceId}-${record.action}`} + loading={loading} + columns={columns} + dataSource={records} + pagination={false} + locale={{ + emptyText: error ? '当前无法加载审计记录' : '暂无审计记录', + }} + scroll={{ x: 960 }} + size="small" + /> + + +
+ ); +}; + +export default JVMAuditViewer; diff --git a/frontend/src/components/JVMResourceBrowser.tsx b/frontend/src/components/JVMResourceBrowser.tsx index d5c7efe..abb93f5 100644 --- a/frontend/src/components/JVMResourceBrowser.tsx +++ b/frontend/src/components/JVMResourceBrowser.tsx @@ -1,13 +1,24 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Alert, Button, Card, Descriptions, Empty, Skeleton, Space, Typography } from 'antd'; -import { ReloadOutlined } from '@ant-design/icons'; +import { Alert, Button, Card, Descriptions, Empty, Input, Skeleton, Space, Tag, Typography } from 'antd'; +import { FileSearchOutlined, ReloadOutlined } from '@ant-design/icons'; import { useStore } from '../store'; -import type { JVMValueSnapshot, SavedConnection, TabData } from '../types'; +import type { + JVMApplyResult, + JVMChangePreview, + JVMChangeRequest, + JVMValueSnapshot, + SavedConnection, + TabData, +} from '../types'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation'; import JVMModeBadge from './jvm/JVMModeBadge'; +import JVMChangePreviewModal from './jvm/JVMChangePreviewModal'; const { Paragraph, Text } = Typography; +const { TextArea } = Input; +const DEFAULT_PAYLOAD_TEXT = '{\n \n}'; type JVMResourceBrowserProps = { tab: TabData; @@ -35,12 +46,44 @@ const formatValue = (value: unknown): string => { } }; +const normalizePreviewResult = (value: any): JVMChangePreview | null => { + if (value && typeof value === 'object' && typeof value.allowed === 'boolean') { + return value as JVMChangePreview; + } + if (value?.data && typeof value.data.allowed === 'boolean') { + return value.data as JVMChangePreview; + } + return null; +}; + +const normalizeApplyResult = (value: any): JVMApplyResult | null => { + if (value && typeof value === 'object' && typeof value.status === 'string') { + return value as JVMApplyResult; + } + if (value?.data && typeof value.data.status === 'string') { + return value.data as JVMApplyResult; + } + return null; +}; + const JVMResourceBrowser: React.FC = ({ tab }) => { const connection = useStore((state) => state.connections.find((item) => item.id === tab.connectionId)); - const providerMode = tab.providerMode || connection?.config.jvm?.preferredMode || 'jmx'; + const addTab = useStore((state) => state.addTab); + const providerMode = (tab.providerMode || connection?.config.jvm?.preferredMode || 'jmx') as 'jmx' | 'endpoint' | 'agent'; + const resourcePath = String(tab.resourcePath || '').trim(); + const readOnly = connection?.config.jvm?.readOnly !== false; const [loading, setLoading] = useState(true); const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(''); + const [action, setAction] = useState('update'); + const [reason, setReason] = useState(''); + const [payloadText, setPayloadText] = useState(DEFAULT_PAYLOAD_TEXT); + const [draftError, setDraftError] = useState(''); + const [applyMessage, setApplyMessage] = useState(''); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewOpen, setPreviewOpen] = useState(false); + const [previewResult, setPreviewResult] = useState(null); + const [applyLoading, setApplyLoading] = useState(false); const displayValue = useMemo(() => formatValue(snapshot?.value), [snapshot]); @@ -52,7 +95,6 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { return; } - const resourcePath = String(tab.resourcePath || '').trim(); if (!resourcePath) { setLoading(false); setSnapshot(null); @@ -91,77 +133,306 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { useEffect(() => { void loadSnapshot(); - }, [connection, providerMode, tab.connectionId, tab.resourcePath]); + }, [connection, providerMode, resourcePath, tab.connectionId]); + + useEffect(() => { + setAction('update'); + setReason(''); + setPayloadText(DEFAULT_PAYLOAD_TEXT); + setDraftError(''); + setApplyMessage(''); + setPreviewOpen(false); + setPreviewResult(null); + }, [providerMode, resourcePath, tab.connectionId]); + + const buildDraftPlan = (): JVMChangeRequest => { + const trimmedAction = String(action || '').trim() || 'update'; + const trimmedReason = String(reason || '').trim(); + if (!trimmedReason) { + throw new Error('请填写变更原因'); + } + + const rawPayload = String(payloadText || '').trim(); + let payload: Record = {}; + if (rawPayload) { + const parsed = JSON.parse(rawPayload); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Payload 必须是 JSON 对象'); + } + payload = parsed as Record; + } + + const resourceId = String(snapshot?.resourceId || resourcePath).trim(); + if (!resourceId) { + throw new Error('资源 ID 为空,无法生成变更草稿'); + } + + return { + providerMode, + resourceId, + action: trimmedAction, + reason: trimmedReason, + expectedVersion: snapshot?.version || undefined, + payload, + }; + }; + + const handleOpenAudit = () => { + if (!connection) { + return; + } + + addTab({ + id: `jvm-audit-${connection.id}-${providerMode}`, + title: buildJVMTabTitle(connection.name, 'audit', providerMode), + type: 'jvm-audit', + connectionId: connection.id, + providerMode, + }); + }; + + const handlePreview = async () => { + if (!connection) { + setDraftError('连接不存在或已被删除'); + return; + } + + const backendApp = (window as any).go?.app?.App; + if (typeof backendApp?.JVMPreviewChange !== 'function') { + setDraftError('JVMPreviewChange 后端方法不可用'); + return; + } + + let draftPlan: JVMChangeRequest; + try { + draftPlan = buildDraftPlan(); + } catch (err: any) { + setDraftError(err?.message || '变更草稿不合法'); + return; + } + + setPreviewLoading(true); + setDraftError(''); + setApplyMessage(''); + try { + const result = await backendApp.JVMPreviewChange( + buildJVMRuntimeConfig(connection, providerMode), + draftPlan, + ); + if (result?.success === false) { + setPreviewResult(null); + setPreviewOpen(false); + setDraftError(String(result?.message || '预览 JVM 变更失败')); + return; + } + + const preview = normalizePreviewResult(result); + if (!preview) { + setPreviewResult(null); + setPreviewOpen(false); + setDraftError('预览结果格式不正确'); + return; + } + + setPreviewResult(preview); + setPreviewOpen(true); + } catch (err: any) { + setPreviewResult(null); + setPreviewOpen(false); + setDraftError(err?.message || '预览 JVM 变更失败'); + } finally { + setPreviewLoading(false); + } + }; + + const handleApply = async () => { + if (!connection) { + setDraftError('连接不存在或已被删除'); + return; + } + + const backendApp = (window as any).go?.app?.App; + if (typeof backendApp?.JVMApplyChange !== 'function') { + setDraftError('JVMApplyChange 后端方法不可用'); + return; + } + + let draftPlan: JVMChangeRequest; + try { + draftPlan = buildDraftPlan(); + } catch (err: any) { + setDraftError(err?.message || '变更草稿不合法'); + return; + } + + setApplyLoading(true); + setDraftError(''); + setApplyMessage(''); + try { + const result = await backendApp.JVMApplyChange( + buildJVMRuntimeConfig(connection, providerMode), + draftPlan, + ); + if (result?.success === false) { + setDraftError(String(result?.message || '执行 JVM 变更失败')); + return; + } + + const applyResult = normalizeApplyResult(result); + if (applyResult?.updatedValue) { + setSnapshot(applyResult.updatedValue); + } + + setPreviewOpen(false); + setPreviewResult(null); + setApplyMessage(applyResult?.message || result?.message || 'JVM 变更已执行'); + await loadSnapshot(); + } catch (err: any) { + setDraftError(err?.message || '执行 JVM 变更失败'); + } finally { + setApplyLoading(false); + } + }; if (!connection) { return ; } return ( -
- - - - - + <> +
+ + + + + {readOnly ? '只读连接' : '可写连接'} + + + + + {connection.name} + + {resourcePath || '-'} - - {connection.name} - - {tab.resourcePath} - - + - - {loading ? ( - - ) : ( - - {error ? : null} - {snapshot ? ( - <> - - {snapshot.resourceId || '-'} - {snapshot.kind || tab.resourceKind || '-'} - {snapshot.format || '-'} - {snapshot.version || '-'} - -
-                  {displayValue}
-                
- {snapshot.metadata && Object.keys(snapshot.metadata).length > 0 ? ( + + {loading ? ( + + ) : ( + + {error ? : null} + {snapshot ? ( + <> + + {snapshot.resourceId || '-'} + {snapshot.kind || tab.resourceKind || '-'} + {snapshot.format || '-'} + {snapshot.version || '-'} +
-                    {JSON.stringify(snapshot.metadata, null, 2)}
+                    {displayValue}
                   
- ) : null} - - ) : error ? null : } + {snapshot.metadata && Object.keys(snapshot.metadata).length > 0 ? ( +
+                      {JSON.stringify(snapshot.metadata, null, 2)}
+                    
+ ) : null} + + ) : error ? null : } +
+ )} +
+ + + + {readOnly ? ( + + ) : null} + {draftError ? : null} + {applyMessage ? : null} + + {resourcePath || '-'} + {snapshot?.version || '-'} + + + Action + setAction(event.target.value)} + placeholder="例如 update" + maxLength={64} + /> + + + 变更原因 + setReason(event.target.value)} + placeholder="填写本次 JVM 资源变更原因" + maxLength={200} + /> + + + Payload(JSON) + 需要输入 JSON 对象,预览和执行都会直接使用这份 payload。 +