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 index e808b64..2c2011b 100644 --- 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 @@ -33,7 +33,7 @@ GoNavi 现有已具备三类可复用基础: - 不承诺“任意 JVM 内任意对象均可直接读写” - 不在首期支持任意 Java 表达式执行、任意反射路径写值或任意 classloader 深度探测 - 不把 JVM 功能强行塞进现有 SQL `Database` / driver-agent 抽象 -- 不在首期承诺通过 Agent 模式支持所有缓存框架 +- 不承诺通过 Agent 模式支持所有缓存框架或任意深层对象写入 - 不绕过目标服务现有认证、鉴权和网络边界 ## 4. 需求与约束 @@ -102,12 +102,11 @@ JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector ## 7. 选型 -采用方案 B,首期 MVP 聚焦: +采用方案 B。当前已落地: - `JMX Provider` - `Management Endpoint Provider` - -`Agent Provider` 只保留扩展位与接口约束,不纳入首期交付承诺。 +- `Agent Provider`(高级可选模式,要求目标 Java 服务显式预埋 GoNavi Java Agent) ## 8. 目标架构 @@ -169,11 +168,12 @@ JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector ### 9.3 Agent Provider - 负责: - - 在特定环境下提供更深层的运行时对象探测与写入能力 + - 在特定环境下通过 GoNavi Java Agent 暴露受控管理端口 + - 提供比 JMX 更贴近缓存资源模型的结构化浏览、预览与写入能力 - 定位: - 高级模式 - 不默认启用 - - 不在首期交付 + - 需要目标 Java 服务以 `-javaagent` 方式显式启动 ## 10. 统一资源模型 @@ -283,7 +283,7 @@ AI 不直接输出脚本,而输出结构化变更计划,例如: - `JMX` - `Endpoint` -- `Agent(禁用)` +- `Agent` - `只读` - `可写` @@ -415,10 +415,10 @@ JVM 审计日志不应复用 SQL 日志数据结构,但可以复用现有 LogP - AI 回滚建议 - 仍默认不允许 AI 自动执行 -### Phase 4:高级模式预留 +### Phase 4:高级模式 -- Agent Provider SPI -- 更深层 runtime 能力 +- Agent Provider +- 预埋 Java Agent 的 runtime 资源治理能力 - 仅在特殊环境启用 ## 16. 验证策略 @@ -476,7 +476,7 @@ JVM 缓存可视化编辑能力在 GoNavi 中具备落地基础,但必须采 - 新增独立的 `JVM Connector` 子系统 - 首期支持 `JMX + Management Endpoint` -- `Agent` 仅保留扩展位 +- `Agent` 作为高级可选模式交付 - AI 首期支持分析与生成修改计划,不默认开放自动执行 - 所有修改必须经过预览、确认、审计和回读验证 diff --git a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md index b4624ff..0aa46fc 100644 --- a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md +++ b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md @@ -4,22 +4,26 @@ - 需求名称:JVM缓存可视化编辑 - 提出日期:2026-04-22 - 负责人:Codex -- 目标:评估并设计 GoNavi 连接 Java JVM、可视化查看并修改 JVM 内缓存/对象值的能力,降低“改缓存只能写接口或重启应用”的运维与排障成本 -- 非目标:不直接承诺覆盖所有 Java 框架/所有对象类型,不在需求未澄清前进入实现,不绕过目标应用现有安全控制 +- 目标:完成 GoNavi 连接 Java JVM、可视化查看并修改 JVM 内缓存/对象值的通用能力交付,降低“改缓存只能写接口或重启应用”的运维与排障成本 +- 非目标:不承诺覆盖所有 Java 框架/所有对象类型,不绕过目标应用现有安全控制,不在首期开放脚本式任意表达式执行 ## 2. 范围与验收 - 范围: - - 评估 GoNavi 现有驱动代理模型是否可扩展到 JVM 运行时连接 - - 明确 JVM 缓存可视化编辑的接入方式、权限边界与最小可行产品范围 - - 输出推荐方案、关键风险与后续实施方向 + - 交付 JVM 共享契约、连接配置、provider 注册、连接测试与能力探测 + - 交付 Endpoint / JMX / Agent 三种接入模式及其资源浏览、读值、预览、执行链路 + - 交付 JVM 资源页、预览弹窗、审计查看、AI 草稿生成与回填能力 + - 交付 Guard、审计、来源标记、真实集成测试与构建验证 - 验收标准: - - 明确该需求在 GoNavi 内“能不能做” - - 明确推荐接入模式、前后端分层与安全边界 - - 明确首期建议支持的缓存类型/Java 技术栈范围 + - 可以在 GoNavi 中新增 JVM 连接并完成连接测试 + - 可以按资源树浏览 JVM 对象并查看结构化快照 + - 可以对支持写入的资源执行预览和确认写入,且带 Guard 与审计 + - 可以通过 AI 生成结构化修改草稿,但不会跳过人工确认直接执行 + - 可以通过真实 JMX 与真实 HTTP contract 完成端到端验证,并通过前后端构建回归 - 依赖与约束: - 需复用 GoNavi 当前 Wails + React + driver-agent 架构 - 新能力不得破坏现有数据库/Redis 工作流 - 高风险写操作必须具备明确鉴权、审计与回滚思路 + - JMX 模式要求 GoNavi 运行机器本地可用 `java` 可执行文件 ## 3. 里程碑与进度 - [x] 阶段 1(需求澄清):完成 @@ -50,9 +54,17 @@ - 已完成 JVM AI 计划解析、资源定位解析、AI 计划到当前 JVM 变更草稿的显式映射,避免把 `payload.format/value` 包装层直接透传到现有 JVM 写入契约 - 已完成 AI 聊天面板 JVM 上下文注入、AI 气泡“应用到 JVM 预览”入口以及 JVM 资源页草稿回填闭环 - 已完成 JVM AI 计划来源上下文绑定:消息现在绑定生成时的 `tabId + connectionId + providerMode + resourcePath`,避免切换 JVM 页签后误投递到当前激活页 + - 已完成 Endpoint provider 真实 HTTP contract 与补测,支持资源浏览、读值、预览和执行 + - 已完成可手工启动的 Java Endpoint fixture 与真实集成补测,可直接验证 Endpoint 模式端到端行为 + - 已完成 JMX provider 真实 helper 接入与补测,支持 `domain -> mbean -> attribute/operation` 浏览、attribute `set`、operation `invoke` + - 已完成 JMX helper 预编译 runtime jar 内嵌分发,运行时不再依赖仓库源码目录,也不再要求本地 `javac` + - 已完成 JVM 快照动作提示与 payload 模板回填,前端可直接根据 `supportedActions` 生成草稿 + - 已完成 AI 参与来源写入 JVM 审计记录,审计页可区分“手工”与“AI 辅助” + - 已完成 Agent provider、Agent 连接表单与概览展示,支持通过独立 Agent Base URL 接入 GoNavi Java Agent + - 已完成真实 Java Agent fixture 与集成验证,可通过 `-javaagent` 方式真实验证 Agent 模式资源浏览、预览与执行 + - 已完成 JVM 收口优化:Endpoint 能力探测遵循只读配置,概览页能力矩阵补齐模式能力探测与多行错误展示,能力探测失败与风险/结果状态文案统一收口为中文业务语义 - 待处理: - - 真实 provider 的 `PreviewChange` / `ApplyChange` 端到端能力验证 - - AI 参与信息写入 JVM 审计记录 + - 无阻塞性交付项;后续仅保留复杂对象参数、`CompositeData` / `TabularData` 等高级类型写入扩展作为增强项 ## 5. 风险与阻塞 - 风险: @@ -61,12 +73,12 @@ - 若依赖 attach agent 或表达式执行,需严格控制安全边界与可观测性 - 若目标 JVM 不允许预埋或动态注入 Agent,则“通用型”能力边界会明显收缩 - 多接入模式会带来能力不一致问题,UI 与权限模型必须显式展示“当前模式支持什么/不支持什么” - - 当前 JMX / Endpoint provider 的资源浏览与值读取仍是骨架实现,Task 4 已打通接口与 UI 链路,但真实资源展开会返回 `not implemented` - - Task 5 已打通 Guard/Preview/Audit 主链路,但真实 provider 的 `PreviewChange` / `ApplyChange` 能力仍依赖后续 provider 细化实现 - 当前 AI 能力边界仍是“分析 + 生成结构化计划 + 回填预览草稿”,不直接执行 JVM 写入,真实执行仍取决于 Guard、人工确认和 provider 能力 - - 当前 MVP 中 `updateValue` 会映射为现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象;非对象值、复杂二进制值和 provider 专有写法不在本期承诺范围 - - AI 计划若只提供 `namespace + key`,当前仅按 `namespace/key` 推导资源 ID,属于 MVP 级资源定位约定,未承诺适配所有 provider 的资源 ID 规则 - - JVM 审计记录当前未显式记录“AI 是否参与”,与理想审计模型仍有差距 + - 当前 AI 计划若只提供 `namespace + key`,仍更适合 endpoint/cache 风格资源;JMX 复杂 target 仍建议优先使用 `resourcePath` + - JMX helper 已改为内嵌 jar 分发,但操作者机器仍需本地存在可用 `java` + - Agent 模式要求目标 Java 服务显式以 `-javaagent` 方式启动 GoNavi Java Agent,并额外暴露管理端口 + - JMX operation preview 仅做参数/签名校验和预览快照,不预测真实副作用 + - JMX 参数转换当前覆盖基础类型、`ObjectName` 和部分数组;复杂对象写入仍是后续扩展项 - 历史旧 AI 消息不包含 JVM 来源上下文,若需要应用到预览,需在目标 JVM 资源页重新生成计划 - 阻塞: - 当前开发收口阶段无新增阻塞 @@ -74,8 +86,9 @@ - 优先收敛到标准接入面(JMX / Spring Actuator / Java Agent 三选一) - 首期只支持白名单对象类型与受控写操作 - 要求变更审计、预览、确认与失败回滚路径 - - 在交付说明中明确“AI 只生成草稿,不代表 provider 已具备真实写能力” - - 后续如进入真实接入阶段,优先补齐 provider 端到端验证与 AI 审计字段 + - 在交付说明中明确“AI 只生成草稿,不直接执行 JVM 写入” + - JMX helper 改为内嵌 runtime jar,默认写入用户缓存目录;必要时允许通过 `GONAVI_JMX_HELPER_CLASSPATH` 覆盖 classpath + - 对复杂参数调用保持白名单和人工确认,不开放脚本式自由执行 ## 6. 决策记录 - 决策 1:先做可行性评估与方案设计,不直接进入实现 @@ -86,6 +99,8 @@ - 决策 6:AI 计划应用入口只回填 JVM 预览草稿,后续仍必须经过 `JVMPreviewChange`、Guard 校验和人工确认 - 决策 7:当前 MVP 中 `updateValue` 会映射到现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象 - 决策 8:JVM AI 计划必须绑定生成时的 JVM 上下文,只允许投递到匹配的 `tabId + connectionId + providerMode + resourcePath` +- 决策 9:JMX helper 采用 Java 8 兼容的预编译 runtime jar 内嵌分发,运行时只依赖本地 `java` +- 决策 10:Agent 模式按“预埋 GoNavi Java Agent + 独立 Agent Base URL 接入”落地,不在当前版本实现动态 attach ## 7. 验证记录 - 验证项: @@ -96,6 +111,9 @@ - Task 1:JVM 共享契约与配置归一化 - Task 2:Provider 注册、连接测试与能力探测 API - Task 6:AI 计划解析、资源定位解析、契约映射与页签上下文隔离 + - Task 7:Java Endpoint fixture 真实集成验证 + - Task 7:JMX helper 内嵌分发与运行时缓存验证 + - Task 7:Agent provider 与真实 Java Agent 集成验证 - Task 7:后端全量测试 - Task 7:前端全量测试 - Task 7:前端生产构建 @@ -117,11 +135,24 @@ - 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤 - 已完成 Task 6:AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测 - 已完成 Task 6:AI 聊天消息与 JVM 来源页签绑定,AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递 - - `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` 通过(10 tests) + - 已完成 Task 7:Java Endpoint fixture,可真实验证 `resources / value / preview / apply` 四个 endpoint contract + - `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 通过 + - 已完成 Task 7:JMX helper 改为预编译 jar 内嵌分发,并补齐 classpath 覆盖与缓存落盘单测 + - `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` 通过 + - 已完成 Task 7:Agent provider、Java agent fixture 与真实 `-javaagent` 集成测试 + - `go test ./internal/jvm -run 'TestAgentProvider' -count=1` 通过 + - `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` 通过(11 tests) - `go test ./... -count=1` 通过 - - `cd frontend && npm test -- --run` 通过(61 files,258 tests) + - `cd frontend && npm test -- --run` 通过(61 files,259 tests) - `cd frontend && npm run build` 通过;构建中存在既有 chunk size / dynamic import 警告,但未阻塞产物生成 - `wails build -clean` 通过,成功生成 macOS 应用包 + - 已完成 JVM 收口优化:模式能力探测现在按当前 mode 做业务化错误翻译,避免概览页继续回显 `non-JRMP server`、`baseURL is required` 这类原始报错 + - `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 再次通过(Endpoint 能力探测只读语义回归) + - `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` 再次通过(能力探测模式透传与中文错误翻译回归) + - `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx` 通过(JVM 资源页布局回归) + - `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts` 通过(风险等级、审计结果等本地化展示回归) + - `cd frontend && npm run build` 再次通过 + - `wails build -clean` 再次通过,成功生成最新可验收桌面包 - 证据(日志/截图/链接): - `cmd/optional-driver-agent/main.go` - `internal/db/database.go` @@ -145,7 +176,15 @@ - `internal/jvm/provider.go` - `internal/jvm/jmx_provider.go` - `internal/jvm/http_provider.go` + - `internal/jvm/http_provider_test.go` + - `internal/jvm/jmx_helper.go` + - `internal/jvm/jmx_helper_test.go` - `internal/jvm/provider_contract_test.go` + - `internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar` + - `internal/jvm/jmxhelper_assets/README.md` + - `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/EndpointTestServer.java` + - `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/MiniJson.java` + - `tools/jmx-helper/src/com/gonavi/jmxhelper/JmxHelperMain.java` - `internal/app/methods_jvm.go` - `internal/app/methods_jvm_test.go` - `frontend/wailsjs/go/app/App.d.ts` @@ -195,9 +234,13 @@ - `frontend/src/types.ts` - `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` - `go test ./... -count=1` + - `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` + - `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` + - `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx` + - `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts` - `cd frontend && npm test -- --run` - `wails build -clean` ## 8. 下一步 -- 下一步行动:如进入真实接入阶段,优先补齐 provider 端到端写入验证、AI 参与审计字段和生产环境灰度验证 +- 下一步行动:由用户按真实 JVM / endpoint 场景执行验收验证;若验收通过,再决定是否提交、推送或继续扩展高级类型写入 - 负责人:Codex diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 0fa7d3e..8f722b0 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -4,7 +4,12 @@ import { useStore, loadAISessionsFromBackend, loadAISessionFromBackend } from '. import { EventsOn, EventsOff } from '../../wailsjs/runtime'; import { DBGetDatabases, DBGetTables } from '../../wailsjs/go/app/App'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; -import type { AIChatMessage, AIToolCall, JVMAIPlanContext } from '../types'; +import type { + AIChatMessage, + AIToolCall, + JVMAIPlanContext, + JVMDiagnosticPlanContext, +} from '../types'; import { DownOutlined } from '@ant-design/icons'; import './AIChatPanel.css'; @@ -232,6 +237,7 @@ export const AIChatPanel: React.FC = ({ const panelRef = useRef(null); // 面板 DOM ref,用于拖拽时直接操作宽度 const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染) const pendingJVMPlanContextRef = useRef(undefined); + const pendingJVMDiagnosticPlanContextRef = useRef(undefined); const aiChatHistory = useStore(state => state.aiChatHistory); const aiActiveSessionId = useStore(state => state.aiActiveSessionId); @@ -274,6 +280,25 @@ export const AIChatPanel: React.FC = ({ }; }, []); + const getCurrentJVMDiagnosticPlanContext = useCallback((): JVMDiagnosticPlanContext | undefined => { + const state = useStore.getState(); + const activeTab = state.tabs.find(t => t.id === state.activeTabId); + if (!activeTab || activeTab.type !== 'jvm-diagnostic') { + return undefined; + } + + const activeConnection = state.connections.find(c => c.id === activeTab.connectionId); + if (activeConnection?.config?.type !== 'jvm') { + return undefined; + } + + return { + tabId: activeTab.id, + connectionId: activeTab.connectionId, + transport: activeConnection.config.jvm?.diagnostic?.transport || 'agent-bridge', + }; + }, []); + // Auto-Context Injection Hook useEffect(() => { if (!aiPanelVisible) return; @@ -532,6 +557,7 @@ export const AIChatPanel: React.FC = ({ rawError: rawErr, timestamp: Date.now(), jvmPlanContext: pendingJVMPlanContextRef.current, + jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, }); } assistantMsgId = ''; @@ -553,6 +579,7 @@ export const AIChatPanel: React.FC = ({ timestamp: Date.now(), loading: true, jvmPlanContext: pendingJVMPlanContextRef.current, + jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, }); } } @@ -570,6 +597,7 @@ export const AIChatPanel: React.FC = ({ timestamp: Date.now(), loading: true, jvmPlanContext: pendingJVMPlanContextRef.current, + jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, }); if (sending) setSending(false); } else { @@ -589,6 +617,7 @@ export const AIChatPanel: React.FC = ({ timestamp: Date.now(), loading: true, jvmPlanContext: pendingJVMPlanContextRef.current, + jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, }); setSending(false); const currentHistory = useStore.getState().aiChatHistory[sid] || []; @@ -649,7 +678,10 @@ export const AIChatPanel: React.FC = ({ if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id; return mapped; }); - const sysMessages = await buildSystemContextMessages(existing.jvmPlanContext); + const sysMessages = await buildSystemContextMessages( + existing.jvmPlanContext, + existing.jvmDiagnosticPlanContext, + ); // 追加催促消息 messagesPayload.push({ role: 'user', content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。' }); const allMsg = [...sysMessages, ...messagesPayload]; @@ -751,7 +783,10 @@ export const AIChatPanel: React.FC = ({ totalToolRoundRef.current = 0; nudgeCountRef.current = 0; const retryJVMPlanContext = msg.jvmPlanContext || getCurrentJVMPlanContext(); + const retryJVMDiagnosticPlanContext = + msg.jvmDiagnosticPlanContext || getCurrentJVMDiagnosticPlanContext(); pendingJVMPlanContextRef.current = retryJVMPlanContext; + pendingJVMDiagnosticPlanContextRef.current = retryJVMDiagnosticPlanContext; setSending(true); @@ -760,6 +795,7 @@ export const AIChatPanel: React.FC = ({ id: genId(), role: 'assistant', phase: 'connecting', content: '', timestamp: Date.now(), loading: true, jvmPlanContext: retryJVMPlanContext, + jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext, }; addAIChatMessage(sid, connectingMsg); @@ -767,7 +803,10 @@ export const AIChatPanel: React.FC = ({ const messagesPayload = truncatedHistory.map(m => ({ role: m.role, content: m.content, images: m.images })); try { - const sysMessages = await buildSystemContextMessages(retryJVMPlanContext); + const sysMessages = await buildSystemContextMessages( + retryJVMPlanContext, + retryJVMDiagnosticPlanContext, + ); const allMessages = [...sysMessages, ...messagesPayload]; const Service = (window as any).go?.aiservice?.Service; @@ -783,6 +822,7 @@ export const AIChatPanel: React.FC = ({ rawError: (!result?.success && errClean !== errRaw) ? errRaw : undefined, timestamp: Date.now(), jvmPlanContext: retryJVMPlanContext, + jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext, }); setSending(false); } else { @@ -798,35 +838,93 @@ export const AIChatPanel: React.FC = ({ rawError: cleanE !== rawE ? rawE : undefined, timestamp: Date.now(), jvmPlanContext: retryJVMPlanContext, + jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext, }); setSending(false); } } - }, [sid, truncateAIChatMessages, addAIChatMessage, getCurrentJVMPlanContext]); + }, [ + sid, + truncateAIChatMessages, + addAIChatMessage, + getCurrentJVMPlanContext, + getCurrentJVMDiagnosticPlanContext, + ]); - const buildSystemContextMessages = useCallback(async (overrideJVMPlanContext?: JVMAIPlanContext) => { + const buildSystemContextMessages = useCallback(async ( + overrideJVMPlanContext?: JVMAIPlanContext, + overrideJVMDiagnosticPlanContext?: JVMDiagnosticPlanContext, + ) => { // 🔧 性能优化:从 store 实时读取,避免闭包捕获导致的依赖链式重建 const { activeContext: ctx, aiContexts: ctxMap, connections: conns, tabs: allTabs, activeTabId: tabId } = useStore.getState(); const connectionKey = ctx?.connectionId ? `${ctx.connectionId}:${ctx.dbName || ''}` : 'default'; const activeContextItems = ctxMap[connectionKey] || []; const systemMessages: { role: string; content: string; images?: string[] }[] = []; - const activeTab = overrideJVMPlanContext + const matchesDiagnosticContext = (tab: typeof allTabs[number]) => { + if (!overrideJVMDiagnosticPlanContext || tab.type !== 'jvm-diagnostic') { + return false; + } + const tabConnection = conns.find(c => c.id === tab.connectionId); + const tabTransport = tabConnection?.config?.jvm?.diagnostic?.transport || 'agent-bridge'; + return ( + tab.connectionId === overrideJVMDiagnosticPlanContext.connectionId && + tabTransport === overrideJVMDiagnosticPlanContext.transport + ); + }; + const activeTab = overrideJVMDiagnosticPlanContext ? ( - allTabs.find(t => t.id === overrideJVMPlanContext.tabId) || - allTabs.find( - t => - t.type === 'jvm-resource' && - t.connectionId === overrideJVMPlanContext.connectionId && - t.providerMode === overrideJVMPlanContext.providerMode && - String(t.resourcePath || '').trim() === overrideJVMPlanContext.resourcePath, - ) + allTabs.find(t => t.id === overrideJVMDiagnosticPlanContext.tabId && matchesDiagnosticContext(t)) || + allTabs.find(t => matchesDiagnosticContext(t)) ) - : allTabs.find(t => t.id === tabId); + : overrideJVMPlanContext + ? ( + allTabs.find(t => t.id === overrideJVMPlanContext.tabId) || + allTabs.find( + t => + t.type === 'jvm-resource' && + t.connectionId === overrideJVMPlanContext.connectionId && + t.providerMode === overrideJVMPlanContext.providerMode && + String(t.resourcePath || '').trim() === overrideJVMPlanContext.resourcePath, + ) + ) + : allTabs.find(t => t.id === tabId); const activeConnection = activeTab?.connectionId ? conns.find(c => c.id === activeTab.connectionId) : undefined; + if ( + activeTab && + activeTab.type === 'jvm-diagnostic' && + activeConnection?.config?.type === 'jvm' + ) { + const diagnostic = activeConnection.config.jvm?.diagnostic; + const diagnosticTransport = overrideJVMDiagnosticPlanContext?.transport || diagnostic?.transport || 'agent-bridge'; + const readOnly = activeConnection.config.jvm?.readOnly !== false; + const environment = activeConnection.config.jvm?.environment || 'unknown'; + systemMessages.push({ + role: 'system', + content: `你是 GoNavi 的 JVM 诊断助手。当前页签是 Arthas 兼容诊断工作台,目标是输出可回填到诊断控制台的结构化诊断计划。 + +当前连接:${activeConnection.name} +目标主机:${activeConnection.config.host || '-'} +诊断 transport:${diagnosticTransport} +运行环境:${environment} +连接策略:${readOnly ? '默认按只读诊断思路回答,只生成观察、trace、排障命令,不要假设已经执行。' : '允许生成诊断命令,但仍然必须先给计划,再由用户决定是否执行。'} +命令权限:observe=${diagnostic?.allowObserveCommands !== false ? '允许' : '禁止'},trace=${diagnostic?.allowTraceCommands === true ? '允许' : '禁止'},mutating=${diagnostic?.allowMutatingCommands === true ? '允许' : '禁止'} + +回答规则: +1. 可以先给一小段分析,但必须包含且只包含一个 \`\`\`json 代码块。 +2. JSON 字段严格限定为 intent、transport、command、riskLevel、reason、expectedSignals。 +3. transport 必须填写当前值 ${diagnosticTransport},不要编造其他 transport。 +4. command 必须是单条诊断命令,不要带 shell 提示符、换行拼接、多条命令或代码围栏。 +5. riskLevel 只能是 low、medium、high。 +6. expectedSignals 必须是字符串数组,描述执行后需要重点观察的信号。 +7. 如果命令权限不允许某类操作,就不要输出该类命令;无法满足时直接说明限制。`, + }); + return systemMessages; + } + if ( activeTab && (activeTab.type === 'jvm-resource' || activeTab.type === 'jvm-overview' || activeTab.type === 'jvm-audit') && @@ -850,9 +948,9 @@ ${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体 回答规则: 1. 你可以解释资源结构、风险、修改建议和回滚建议。 2. 如果用户要求生成 JVM 修改方案,必须输出一个唯一的 \`\`\`json 代码块,并且 JSON 字段严格限定为 targetType、selector、action、payload、reason。 -3. action 只能使用 updateValue、evict、clear 之一。 +3. action 优先使用当前资源快照或元数据里已经声明的 supportedActions;如果当前资源没有声明,再基于快照内容谨慎推断。 4. selector.resourcePath 优先使用当前资源路径;如果当前路径未知,就明确说明无法精确定位,不要编造路径。 -5. 当前 JVM 预览 MVP 只支持 JSON 对象变更:如果 action=updateValue,则 payload 必须是 {"format":"json","value":{...}},且 value 必须是 JSON 对象;evict/clear 时 payload 可以省略。 +5. payload 只能使用 {"format":"json","value":{...}} 或 {"format":"text","value":"..."} 这两种包装形式,不要输出脚本、命令或裸值。 6. 不要输出脚本、命令或“已经执行成功”之类的表述。` }); return systemMessages; @@ -927,7 +1025,10 @@ SELECT * FROM users WHERE status = 1; const executeLocalTools = useCallback(async (toolCalls: AIToolCall[], currentAsstMsgId: string) => { const currentAsstMsg = (useStore.getState().aiChatHistory[sid] || []).find(m => m.id === currentAsstMsgId); const inheritedJVMPlanContext = currentAsstMsg?.jvmPlanContext || pendingJVMPlanContextRef.current; + const inheritedJVMDiagnosticPlanContext = + currentAsstMsg?.jvmDiagnosticPlanContext || pendingJVMDiagnosticPlanContextRef.current; pendingJVMPlanContextRef.current = inheritedJVMPlanContext; + pendingJVMDiagnosticPlanContextRef.current = inheritedJVMDiagnosticPlanContext; // 【全局轮次熔断】防止模型(如 DeepSeek)在已生成答案后仍无限循环调用工具 const MAX_TOOL_CALL_ROUNDS = 15; @@ -939,6 +1040,7 @@ SELECT * FROM users WHERE status = 1; content: `⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`, timestamp: Date.now(), jvmPlanContext: inheritedJVMPlanContext, + jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, }); setSending(false); return; @@ -1128,6 +1230,7 @@ SELECT * FROM users WHERE status = 1; content: '⚠️ 探针连续 3 轮执行失败,自动终止。请检查连接状态后重试。', timestamp: Date.now(), jvmPlanContext: inheritedJVMPlanContext, + jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, }); setSending(false); return; @@ -1143,6 +1246,7 @@ SELECT * FROM users WHERE status = 1; content: '汇总探针执行结果中', timestamp: Date.now(), loading: true, jvmPlanContext: inheritedJVMPlanContext, + jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, }; useStore.getState().addAIChatMessage(sid, chainConnectingMsg); @@ -1169,7 +1273,10 @@ SELECT * FROM users WHERE status = 1; if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id; return mapped; }); - const sysMessages = await buildSystemContextMessages(inheritedJVMPlanContext); + const sysMessages = await buildSystemContextMessages( + inheritedJVMPlanContext, + inheritedJVMDiagnosticPlanContext, + ); let finalMessagesPayload = messagesPayload; // 在这里加入长度检查和自动摘要(带上动态限额) @@ -1208,6 +1315,7 @@ SELECT * FROM users WHERE status = 1; rawError: (!result?.success && errC !== errR) ? errR : undefined, timestamp: Date.now(), jvmPlanContext: inheritedJVMPlanContext, + jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, }); setSending(false); } @@ -1236,7 +1344,9 @@ SELECT * FROM users WHERE status = 1; totalToolRoundRef.current = 0; // 重置总轮次计数 nudgeCountRef.current = 0; // 重置催促计数 const currentJVMPlanContext = getCurrentJVMPlanContext(); + const currentJVMDiagnosticPlanContext = getCurrentJVMDiagnosticPlanContext(); pendingJVMPlanContextRef.current = currentJVMPlanContext; + pendingJVMDiagnosticPlanContextRef.current = currentJVMDiagnosticPlanContext; const currentImages = [...draftImages]; setInput(''); @@ -1257,10 +1367,14 @@ SELECT * FROM users WHERE status = 1; id: genId(), role: 'assistant', phase: 'connecting', content: '', timestamp: Date.now(), loading: true, jvmPlanContext: currentJVMPlanContext, + jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, }; addAIChatMessage(sid, connectingMsg); - const systemMessages = await buildSystemContextMessages(); + const systemMessages = await buildSystemContextMessages( + currentJVMPlanContext, + currentJVMDiagnosticPlanContext, + ); // 【过渡状态 2】上下文已组装完成,即将接入模型 updateAIChatMessage(sid, connectingMsg.id, { content: '模型接入中' }); @@ -1309,6 +1423,7 @@ SELECT * FROM users WHERE status = 1; rawError: (!result?.success && errC2 !== errR2) ? errR2 : undefined, timestamp: Date.now(), jvmPlanContext: currentJVMPlanContext, + jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, }; addAIChatMessage(sid, assistantMsg); setSending(false); @@ -1318,16 +1433,42 @@ SELECT * FROM users WHERE status = 1; generateTitleForSession(sid); } } else { - addAIChatMessage(sid, { id: genId(), role: 'assistant', content: '❌ AI Service 未就绪', timestamp: Date.now() }); + addAIChatMessage(sid, { + id: genId(), + role: 'assistant', + content: '❌ AI Service 未就绪', + timestamp: Date.now(), + jvmPlanContext: currentJVMPlanContext, + jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, + }); setSending(false); } } catch (e: any) { const rawE2 = e?.message || String(e); const cleanE2 = sanitizeErrorMsg(rawE2); - addAIChatMessage(sid, { id: genId(), role: 'assistant', content: `❌ 发送失败: ${cleanE2}`, rawError: cleanE2 !== rawE2 ? rawE2 : undefined, timestamp: Date.now() }); + addAIChatMessage(sid, { + id: genId(), + role: 'assistant', + content: `❌ 发送失败: ${cleanE2}`, + rawError: cleanE2 !== rawE2 ? rawE2 : undefined, + timestamp: Date.now(), + jvmPlanContext: currentJVMPlanContext, + jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, + }); setSending(false); } - }, [input, draftImages, sending, messages, addAIChatMessage, sid, activeProvider, getCurrentJVMPlanContext]); + }, [ + input, + draftImages, + sending, + messages, + addAIChatMessage, + sid, + activeProvider, + buildSystemContextMessages, + getCurrentJVMPlanContext, + getCurrentJVMDiagnosticPlanContext, + ]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 64a0a8f..6cfb37d 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1,30 +1,84 @@ -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, 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'; -import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +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, + Switch, +} from "antd"; +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"; +import { + isMacLikePlatform, + normalizeOpacityForPlatform, + resolveAppearanceValues, +} from "../utils/appearance"; import { getStoredSecretPlaceholder, normalizeConnectionSecretErrorMessage, resolveConnectionTestFailureFeedback, -} from '../utils/connectionModalPresentation'; -import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft'; -import { getCustomConnectionDsnValidationMessage } from '../utils/customConnectionDsn'; -import { CUSTOM_CONNECTION_DRIVER_HELP } from '../utils/driverImportGuidance'; -import { applyNoAutoCapAttributes, noAutoCapInputProps } from '../utils/inputAutoCap'; + summarizeConnectionTestFailureMessage, +} from "../utils/connectionModalPresentation"; +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 { buildDefaultJVMConnectionValues, buildJVMConnectionConfig, + hasUnsupportedJVMDiagnosticTransport, hasUnsupportedJVMEditableModes, JVM_EDITABLE_MODES, normalizeEditableJVMModes, resolveEditableJVMModeSelection, -} from '../utils/jvmConnectionConfig'; -import { resolveJVMModeMeta } from '../utils/jvmRuntimePresentation'; -import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile, TestJVMConnection } from '../../wailsjs/go/app/App'; -import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types'; +} from "../utils/jvmConnectionConfig"; +import { 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 { Text } = Typography; const MAX_URI_LENGTH = 4096; @@ -32,90 +86,116 @@ const MAX_URI_HOSTS = 32; const MAX_TIMEOUT_SECONDS = 3600; const CONNECTION_MODAL_WIDTH = 960; const CONNECTION_MODAL_BODY_HEIGHT = 620; -const STEP1_SIDEBAR_DIVIDER_DARK = 'rgba(255, 255, 255, 0.16)'; -const STEP1_SIDEBAR_DIVIDER_LIGHT = 'rgba(0, 0, 0, 0.08)'; +const STEP1_SIDEBAR_DIVIDER_DARK = "rgba(255, 255, 255, 0.16)"; +const STEP1_SIDEBAR_DIVIDER_LIGHT = "rgba(0, 0, 0, 0.08)"; type ConnectionSecretKey = - | 'primaryPassword' - | 'sshPassword' - | 'proxyPassword' - | 'httpTunnelPassword' - | 'mysqlReplicaPassword' - | 'mongoReplicaPassword' - | 'opaqueURI' - | 'opaqueDSN'; + | "primaryPassword" + | "sshPassword" + | "proxyPassword" + | "httpTunnelPassword" + | "mysqlReplicaPassword" + | "mongoReplicaPassword" + | "opaqueURI" + | "opaqueDSN"; type ConnectionSecretClearState = Record; -const createEmptyConnectionSecretClearState = (): ConnectionSecretClearState => ({ - primaryPassword: false, - sshPassword: false, - proxyPassword: false, - httpTunnelPassword: false, - mysqlReplicaPassword: false, - mongoReplicaPassword: false, - opaqueURI: false, - opaqueDSN: false, -}); +const createEmptyConnectionSecretClearState = + (): ConnectionSecretClearState => ({ + primaryPassword: false, + sshPassword: false, + proxyPassword: false, + httpTunnelPassword: false, + mysqlReplicaPassword: false, + mongoReplicaPassword: false, + opaqueURI: false, + opaqueDSN: false, + }); const getDefaultPortByType = (type: string) => { switch (type) { - case 'jvm': return 9010; - case 'mysql': return 3306; - case 'doris': - case 'diros': return 9030; - case 'sphinx': return 9306; - case 'clickhouse': return 9000; - case 'postgres': return 5432; - case 'redis': return 6379; - case 'tdengine': return 6041; - case 'oracle': return 1521; - case 'dameng': return 5236; - case 'kingbase': return 54321; - case 'sqlserver': return 1433; - case 'mongodb': return 27017; - case 'highgo': return 5866; - case 'mariadb': return 3306; - case 'vastbase': return 5432; - case 'sqlite': return 0; - case 'duckdb': return 0; - default: return 3306; + case "jvm": + return 9010; + case "mysql": + return 3306; + case "doris": + case "diros": + return 9030; + case "sphinx": + return 9306; + case "clickhouse": + return 9000; + case "postgres": + return 5432; + case "redis": + return 6379; + case "tdengine": + return 6041; + case "oracle": + return 1521; + case "dameng": + return 5236; + case "kingbase": + return 54321; + case "sqlserver": + return 1433; + case "mongodb": + return 27017; + case "highgo": + return 5866; + case "mariadb": + return 3306; + case "vastbase": + return 5432; + case "sqlite": + return 0; + case "duckdb": + return 0; + default: + return 3306; } }; const singleHostUriSchemesByType: Record = { - postgres: ['postgresql', 'postgres'], - clickhouse: ['clickhouse'], - oracle: ['oracle'], - sqlserver: ['sqlserver'], - redis: ['redis'], - tdengine: ['tdengine'], - dameng: ['dameng', 'dm'], - kingbase: ['kingbase'], - highgo: ['highgo'], - vastbase: ['vastbase'], + postgres: ["postgresql", "postgres"], + clickhouse: ["clickhouse"], + oracle: ["oracle"], + sqlserver: ["sqlserver"], + redis: ["redis"], + tdengine: ["tdengine"], + dameng: ["dameng", "dm"], + kingbase: ["kingbase"], + highgo: ["highgo"], + vastbase: ["vastbase"], }; const sslSupportedTypes = new Set([ - 'mysql', - 'mariadb', - 'diros', - 'sphinx', - 'dameng', - 'clickhouse', - 'postgres', - 'sqlserver', - 'oracle', - 'kingbase', - 'highgo', - 'vastbase', - 'mongodb', - 'redis', - 'tdengine', + "mysql", + "mariadb", + "diros", + "sphinx", + "dameng", + "clickhouse", + "postgres", + "sqlserver", + "oracle", + "kingbase", + "highgo", + "vastbase", + "mongodb", + "redis", + "tdengine", ]); -const supportsSSLForType = (type: string) => sslSupportedTypes.has(String(type || '').trim().toLowerCase()); +const supportsSSLForType = (type: string) => + sslSupportedTypes.has( + String(type || "") + .trim() + .toLowerCase(), + ); -const isFileDatabaseType = (type: string) => type === 'sqlite' || type === 'duckdb'; +const isFileDatabaseType = (type: string) => + type === "sqlite" || type === "duckdb"; type DriverStatusSnapshot = { type: string; @@ -125,9 +205,11 @@ type DriverStatusSnapshot = { }; const normalizeDriverType = (value: string): string => { - const normalized = String(value || '').trim().toLowerCase(); - if (normalized === 'postgresql') return 'postgres'; - if (normalized === 'doris') return 'diros'; + const normalized = String(value || "") + .trim() + .toLowerCase(); + if (normalized === "postgresql") return "postgres"; + if (normalized === "doris") return "diros"; return normalized; }; @@ -144,1326 +226,1785 @@ const ConnectionModal: React.FC<{ const [useSSH, setUseSSH] = useState(false); const [useProxy, setUseProxy] = useState(false); const [useHttpTunnel, setUseHttpTunnel] = useState(false); - const [dbType, setDbType] = useState('mysql'); + const [dbType, setDbType] = useState("mysql"); const [step, setStep] = useState(1); // 1: Select Type, 2: Configure const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1 - const [activeConfigSection, setActiveConfigSection] = useState<'basic' | 'network' | 'appearance'>('basic'); - const [customIconType, setCustomIconType] = useState(undefined); - const [customIconColor, setCustomIconColor] = useState(undefined); - const [activeNetworkConfig, setActiveNetworkConfig] = useState<'ssl' | 'ssh' | 'proxy' | 'httpTunnel'>('ssl'); - const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null); + const [activeConfigSection, setActiveConfigSection] = useState< + "basic" | "network" | "appearance" + >("basic"); + const [customIconType, setCustomIconType] = useState( + undefined, + ); + const [customIconColor, setCustomIconColor] = useState( + undefined, + ); + const [activeNetworkConfig, setActiveNetworkConfig] = useState< + "ssl" | "ssh" | "proxy" | "httpTunnel" + >("ssl"); + const [testResult, setTestResult] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); const [testErrorLogOpen, setTestErrorLogOpen] = useState(false); const [dbList, setDbList] = useState([]); const [redisDbList, setRedisDbList] = useState([]); // Redis databases 0-15 const [mongoMembers, setMongoMembers] = useState([]); const [discoveringMembers, setDiscoveringMembers] = useState(false); - const [uriFeedback, setUriFeedback] = useState<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null); - const [typeSelectWarning, setTypeSelectWarning] = useState<{ driverName: string; reason: string } | null>(null); - const [driverStatusMap, setDriverStatusMap] = useState>({}); + const [uriFeedback, setUriFeedback] = useState<{ + type: "success" | "warning" | "error"; + message: string; + } | null>(null); + const [typeSelectWarning, setTypeSelectWarning] = useState<{ + driverName: string; + reason: string; + } | null>(null); + const [driverStatusMap, setDriverStatusMap] = useState< + Record + >({}); const [driverStatusLoaded, setDriverStatusLoaded] = useState(false); const [selectingDbFile, setSelectingDbFile] = useState(false); const [selectingSSHKey, setSelectingSSHKey] = useState(false); - const [clearSecrets, setClearSecrets] = useState(createEmptyConnectionSecretClearState); + const [clearSecrets, setClearSecrets] = useState( + createEmptyConnectionSecretClearState, + ); const testInFlightRef = useRef(false); const testTimerRef = useRef(null); const addConnection = useStore((state) => state.addConnection); const updateConnection = useStore((state) => state.updateConnection); const theme = useStore((state) => state.theme); const appearance = useStore((state) => state.appearance); - const darkMode = theme === 'dark'; + const darkMode = theme === "dark"; const resolvedAppearance = resolveAppearanceValues(appearance); - const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); + const effectiveOpacity = normalizeOpacityForPlatform( + resolvedAppearance.opacity, + ); const disableLocalBackdropFilter = isMacLikePlatform(); - const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single'; - 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(() => normalizeEditableJVMModes(jvmAllowedModes), [jvmAllowedModes]); - const hasUnsupportedJvmModeSelection = useMemo(() => hasUnsupportedJVMEditableModes({ - allowedModes: jvmAllowedModes, - preferredMode: jvmPreferredMode, - }), [jvmAllowedModes, jvmPreferredMode]); - const isMySQLLike = dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx'; + const mysqlTopology = Form.useWatch("mysqlTopology", form) || "single"; + 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 jvmDiagnosticEnabled = + Form.useWatch("jvmDiagnosticEnabled", form) || false; + const jvmDiagnosticTransport = + Form.useWatch("jvmDiagnosticTransport", form) || "agent-bridge"; + const normalizedJvmAllowedModes = useMemo( + () => normalizeEditableJVMModes(jvmAllowedModes), + [jvmAllowedModes], + ); + const hasUnsupportedJvmModeSelection = useMemo( + () => + hasUnsupportedJVMEditableModes({ + allowedModes: jvmAllowedModes, + preferredMode: jvmPreferredMode, + }), + [jvmAllowedModes, jvmPreferredMode], + ); + const isMySQLLike = + dbType === "mysql" || + dbType === "mariadb" || + dbType === "diros" || + dbType === "sphinx"; const isSSLType = supportsSSLForType(dbType); const sslHintText = isMySQLLike - ? '当 MySQL/MariaDB/Doris/Sphinx 开启安全传输策略时,请启用 SSL;本地自签证书场景可先用 Preferred 或 Skip Verify。' - : dbType === 'dameng' - ? '达梦驱动启用 SSL 需要客户端证书与私钥路径(sslCertPath / sslKeyPath)。' - : dbType === 'sqlserver' - ? 'SQL Server 推荐在生产环境使用 Required,并关闭 TrustServerCertificate。' - : dbType === 'mongodb' - ? 'MongoDB 可通过 TLS 保护连接,证书校验异常时可先用 Skip Verify 验证连通性。' - : '建议优先使用 Required;仅在测试环境或自签证书场景使用 Skip Verify。'; + ? "当 MySQL/MariaDB/Doris/Sphinx 开启安全传输策略时,请启用 SSL;本地自签证书场景可先用 Preferred 或 Skip Verify。" + : dbType === "dameng" + ? "达梦驱动启用 SSL 需要客户端证书与私钥路径(sslCertPath / sslKeyPath)。" + : dbType === "sqlserver" + ? "SQL Server 推荐在生产环境使用 Required,并关闭 TrustServerCertificate。" + : dbType === "mongodb" + ? "MongoDB 可通过 TLS 保护连接,证书校验异常时可先用 Skip Verify 验证连通性。" + : "建议优先使用 Required;仅在测试环境或自签证书场景使用 Skip Verify。"; const getSectionBg = (darkHex: string) => { - if (!darkMode) { - return `rgba(245, 245, 245, ${Math.max(effectiveOpacity, 0.92)})`; - } - const hex = darkHex.replace('#', ''); - const r = parseInt(hex.substring(0, 2), 16); - const g = parseInt(hex.substring(2, 4), 16); - const b = parseInt(hex.substring(4, 6), 16); - return `rgba(${r}, ${g}, ${b}, ${Math.max(effectiveOpacity, 0.82)})`; + if (!darkMode) { + return `rgba(245, 245, 245, ${Math.max(effectiveOpacity, 0.92)})`; + } + const hex = darkHex.replace("#", ""); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${Math.max(effectiveOpacity, 0.82)})`; }; - const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT; - const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff'; - const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff'; + const step1SidebarDividerColor = darkMode + ? STEP1_SIDEBAR_DIVIDER_DARK + : STEP1_SIDEBAR_DIVIDER_LIGHT; + const step1SidebarActiveBg = darkMode + ? "rgba(246, 196, 83, 0.20)" + : "#e6f4ff"; + const step1SidebarActiveColor = darkMode ? "#ffd666" : "#1677ff"; const overlayTheme = useMemo( - () => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }), - [darkMode, disableLocalBackdropFilter], + () => + buildOverlayWorkbenchTheme(darkMode, { + disableBackdropFilter: disableLocalBackdropFilter, + }), + [darkMode, disableLocalBackdropFilter], ); const tunnelSectionStyle: React.CSSProperties = { - padding: '12px', - background: getSectionBg('#2a2a2a'), - borderRadius: 6, - marginTop: 12, - border: darkMode ? '1px solid rgba(255, 255, 255, 0.16)' : '1px solid rgba(0, 0, 0, 0.06)', + padding: "12px", + background: getSectionBg("#2a2a2a"), + borderRadius: 6, + marginTop: 12, + border: darkMode + ? "1px solid rgba(255, 255, 255, 0.16)" + : "1px solid rgba(0, 0, 0, 0.06)", }; useEffect(() => { - if (!open) return; - const applyForConnectionModal = () => { - document - .querySelectorAll('.connection-modal-wrap input, .connection-modal-wrap textarea') - .forEach(applyNoAutoCapAttributes); - }; + if (!open) return; + const applyForConnectionModal = () => { + document + .querySelectorAll( + ".connection-modal-wrap input, .connection-modal-wrap textarea", + ) + .forEach(applyNoAutoCapAttributes); + }; + applyForConnectionModal(); + const observer = new MutationObserver(() => { applyForConnectionModal(); - const observer = new MutationObserver(() => { - applyForConnectionModal(); - }); - observer.observe(document.body, { childList: true, subtree: true }); - return () => { - observer.disconnect(); - }; + }); + observer.observe(document.body, { childList: true, subtree: true }); + return () => { + observer.disconnect(); + }; }, [open]); - - const modalShellStyle = useMemo(() => ({ + const modalShellStyle = useMemo( + () => ({ background: overlayTheme.shellBg, border: overlayTheme.shellBorder, boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter, - }), [overlayTheme]); + }), + [overlayTheme], + ); - const modalInnerSectionStyle = useMemo(() => ({ + const modalInnerSectionStyle = useMemo( + () => ({ padding: 14, borderRadius: 14, border: overlayTheme.sectionBorder, background: overlayTheme.sectionBg, - }), [overlayTheme]); + }), + [overlayTheme], + ); - const modalMutedTextStyle = useMemo(() => ({ + const modalMutedTextStyle = useMemo( + () => ({ color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6, - }), [overlayTheme]); - - const renderStoredSecretControls = ({ - fieldName, - clearKey, - hasStoredSecret, - clearLabel, - description, - }: { - fieldName: string; - clearKey: ConnectionSecretKey; - hasStoredSecret?: boolean; - clearLabel: string; - description: string; - }) => { - if (!initialValues || !hasStoredSecret) { - return null; - } - return ( - prev[fieldName] !== next[fieldName]}> - {({ getFieldValue }) => { - const draftValue = getFieldValue(fieldName); - const hasDraftValue = String(draftValue ?? '') !== ''; - const cardBorder = darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(16,24,40,0.08)'; - const cardBg = darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.03)'; - const effectiveChecked = clearSecrets[clearKey] && !hasDraftValue; - return ( -
-
- {hasDraftValue ? '已输入新值,保存时会替换当前已保存内容。' : description} -
- { - const checked = event.target.checked; - setClearSecrets((prev) => ({ ...prev, [clearKey]: checked })); - }} - > - {clearLabel} - -
- ); - }} -
- ); - }; - const renderConnectionModalTitle = (icon: React.ReactNode, title: string, description: string) => ( -
-
- {icon} -
-
-
{title}
-
{description}
-
-
+ }), + [overlayTheme], ); + const renderStoredSecretControls = ({ + fieldName, + clearKey, + hasStoredSecret, + clearLabel, + description, + }: { + fieldName: string; + clearKey: ConnectionSecretKey; + hasStoredSecret?: boolean; + clearLabel: string; + description: string; + }) => { + if (!initialValues || !hasStoredSecret) { + return null; + } + return ( + prev[fieldName] !== next[fieldName]} + > + {({ getFieldValue }) => { + const draftValue = getFieldValue(fieldName); + const hasDraftValue = String(draftValue ?? "") !== ""; + const cardBorder = darkMode + ? "1px solid rgba(255,255,255,0.12)" + : "1px solid rgba(16,24,40,0.08)"; + const cardBg = darkMode + ? "rgba(255,255,255,0.03)" + : "rgba(16,24,40,0.03)"; + const effectiveChecked = clearSecrets[clearKey] && !hasDraftValue; + return ( +
+
+ {hasDraftValue + ? "已输入新值,保存时会替换当前已保存内容。" + : description} +
+ { + const checked = event.target.checked; + setClearSecrets((prev) => ({ ...prev, [clearKey]: checked })); + }} + > + {clearLabel} + +
+ ); + }} +
+ ); + }; + const renderConnectionModalTitle = ( + icon: React.ReactNode, + title: string, + description: string, + ) => ( +
+
+ {icon} +
+
+
+ {title} +
+
+ {description} +
+
+
+ ); - const getConnectionOptionCardStyle = (_enabled: boolean): React.CSSProperties => ({ - padding: '12px 14px', - borderRadius: 14, - border: '1px solid transparent', - background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)', - boxShadow: darkMode - ? 'inset 0 0 0 1px rgba(255,255,255,0.028)' - : 'inset 0 0 0 1px rgba(16,24,40,0.03)', - transition: 'all 120ms ease', + const getConnectionOptionCardStyle = ( + _enabled: boolean, + ): React.CSSProperties => ({ + padding: "12px 14px", + borderRadius: 14, + border: "1px solid transparent", + background: darkMode ? "rgba(255,255,255,0.02)" : "rgba(255,255,255,0.72)", + boxShadow: darkMode + ? "inset 0 0 0 1px rgba(255,255,255,0.028)" + : "inset 0 0 0 1px rgba(16,24,40,0.03)", + transition: "all 120ms ease", }); - const fetchDriverStatusMap = async (): Promise> => { - const result: Record = {}; - const res = await GetDriverStatusList('', ''); - if (!res?.success) { - return result; - } - const data = (res?.data || {}) as any; - const drivers = Array.isArray(data.drivers) ? data.drivers : []; - drivers.forEach((item: any) => { - const type = normalizeDriverType(String(item.type || '').trim()); - if (!type) return; - result[type] = { - type, - name: String(item.name || item.type || type).trim(), - connectable: !!item.connectable, - message: String(item.message || '').trim() || undefined, - }; - }); + const fetchDriverStatusMap = async (): Promise< + Record + > => { + const result: Record = {}; + const res = await GetDriverStatusList("", ""); + if (!res?.success) { return result; + } + const data = (res?.data || {}) as any; + const drivers = Array.isArray(data.drivers) ? data.drivers : []; + drivers.forEach((item: any) => { + const type = normalizeDriverType(String(item.type || "").trim()); + if (!type) return; + result[type] = { + type, + name: String(item.name || item.type || type).trim(), + connectable: !!item.connectable, + message: String(item.message || "").trim() || undefined, + }; + }); + return result; }; const refreshDriverStatus = async () => { - try { - const next = await fetchDriverStatusMap(); - setDriverStatusMap(next); - } catch { - setDriverStatusMap({}); - } finally { - setDriverStatusLoaded(true); - } + try { + const next = await fetchDriverStatusMap(); + setDriverStatusMap(next); + } catch { + setDriverStatusMap({}); + } finally { + setDriverStatusLoaded(true); + } }; - const resolveDriverUnavailableReason = async (type: string): Promise => { - const normalized = normalizeDriverType(type); - if (!normalized || normalized === 'custom') { - return ''; - } - let snapshot = driverStatusMap; - if (!snapshot[normalized]) { - snapshot = await fetchDriverStatusMap(); - setDriverStatusMap(snapshot); - } - const status = snapshot[normalized]; - if (!status || status.connectable) { - return ''; - } - return status.message || `${status.name || normalized} 驱动未安装启用,请先在驱动管理中安装`; + const resolveDriverUnavailableReason = async ( + type: string, + ): Promise => { + const normalized = normalizeDriverType(type); + if (!normalized || normalized === "custom") { + return ""; + } + let snapshot = driverStatusMap; + if (!snapshot[normalized]) { + snapshot = await fetchDriverStatusMap(); + setDriverStatusMap(snapshot); + } + const status = snapshot[normalized]; + if (!status || status.connectable) { + return ""; + } + return ( + status.message || + `${status.name || normalized} 驱动未安装启用,请先在驱动管理中安装` + ); }; const promptInstallDriver = (driverType: string, reason: string) => { - const normalized = normalizeDriverType(driverType); - const snapshot = driverStatusMap[normalized]; - const driverName = snapshot?.name || normalized || '当前'; - Modal.confirm({ - title: `${driverName} 驱动不可用`, - content: reason || `${driverName} 驱动未安装启用,请先在驱动管理中安装`, - okText: '去驱动管理安装', - cancelText: '取消', - onOk: () => { - onOpenDriverManager?.(); - }, - }); + const normalized = normalizeDriverType(driverType); + const snapshot = driverStatusMap[normalized]; + const driverName = snapshot?.name || normalized || "当前"; + Modal.confirm({ + title: `${driverName} 驱动不可用`, + content: reason || `${driverName} 驱动未安装启用,请先在驱动管理中安装`, + okText: "去驱动管理安装", + cancelText: "取消", + onOk: () => { + onOpenDriverManager?.(); + }, + }); }; - const parseHostPort = (raw: string, defaultPort: number): { host: string; port: number } | null => { - const text = String(raw || '').trim(); - if (!text) { - return null; - } - if (text.startsWith('[')) { - const closingBracket = text.indexOf(']'); - if (closingBracket > 0) { - const host = text.slice(1, closingBracket).trim(); - const portText = text.slice(closingBracket + 1).trim().replace(/^:/, ''); - const parsedPort = Number(portText); - return { - host: host || 'localhost', - port: Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : defaultPort, - }; - } + const parseHostPort = ( + raw: string, + defaultPort: number, + ): { host: string; port: number } | null => { + const text = String(raw || "").trim(); + if (!text) { + return null; + } + if (text.startsWith("[")) { + const closingBracket = text.indexOf("]"); + if (closingBracket > 0) { + const host = text.slice(1, closingBracket).trim(); + const portText = text + .slice(closingBracket + 1) + .trim() + .replace(/^:/, ""); + const parsedPort = Number(portText); + return { + host: host || "localhost", + port: + Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535 + ? parsedPort + : defaultPort, + }; } + } - const colonCount = (text.match(/:/g) || []).length; - if (colonCount === 1) { - const splitIndex = text.lastIndexOf(':'); - const host = text.slice(0, splitIndex).trim(); - const portText = text.slice(splitIndex + 1).trim(); - const parsedPort = Number(portText); - return { - host: host || 'localhost', - port: Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : defaultPort, - }; - } + const colonCount = (text.match(/:/g) || []).length; + if (colonCount === 1) { + const splitIndex = text.lastIndexOf(":"); + const host = text.slice(0, splitIndex).trim(); + const portText = text.slice(splitIndex + 1).trim(); + const parsedPort = Number(portText); + return { + host: host || "localhost", + port: + Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535 + ? parsedPort + : defaultPort, + }; + } - return { host: text, port: defaultPort }; + return { host: text, port: defaultPort }; }; const toAddress = (host: string, port: number, defaultPort: number) => { - const safeHost = String(host || '').trim() || 'localhost'; - const safePort = Number.isFinite(Number(port)) && Number(port) > 0 ? Number(port) : defaultPort; - return `${safeHost}:${safePort}`; + const safeHost = String(host || "").trim() || "localhost"; + const safePort = + Number.isFinite(Number(port)) && Number(port) > 0 + ? Number(port) + : defaultPort; + return `${safeHost}:${safePort}`; }; - const normalizeAddressList = (rawList: unknown, defaultPort: number): string[] => { - const list = Array.isArray(rawList) ? rawList : []; - const seen = new Set(); - const result: string[] = []; - list.forEach((entry) => { - const parsed = parseHostPort(String(entry || ''), defaultPort); - if (!parsed) { - return; - } - const normalized = toAddress(parsed.host, parsed.port, defaultPort); - if (seen.has(normalized)) { - return; - } - seen.add(normalized); - result.push(normalized); - }); - return result; + const normalizeAddressList = ( + rawList: unknown, + defaultPort: number, + ): string[] => { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const result: string[] = []; + list.forEach((entry) => { + const parsed = parseHostPort(String(entry || ""), defaultPort); + if (!parsed) { + return; + } + const normalized = toAddress(parsed.host, parsed.port, defaultPort); + if (seen.has(normalized)) { + return; + } + seen.add(normalized); + result.push(normalized); + }); + return result; }; const isValidUriHostEntry = (entry: string): boolean => { - const text = String(entry || '').trim(); - if (!text) return false; - if (text.length > 255) return false; - // 拒绝明显的 DSN 片段或路径/空白,避免把非 URI 主机段误判为合法地址。 - if (/[()\\/\s]/.test(text)) return false; - return true; + const text = String(entry || "").trim(); + if (!text) return false; + if (text.length > 255) return false; + // 拒绝明显的 DSN 片段或路径/空白,避免把非 URI 主机段误判为合法地址。 + if (/[()\\/\s]/.test(text)) return false; + return true; }; - const normalizeMongoSrvHostList = (rawList: unknown, defaultPort: number): string[] => { - const list = Array.isArray(rawList) ? rawList : []; - const seen = new Set(); - const result: string[] = []; - list.forEach((entry) => { - const parsed = parseHostPort(String(entry || ''), defaultPort); - if (!parsed?.host) { - return; - } - const host = String(parsed.host).trim(); - if (!host || seen.has(host)) { - return; - } - seen.add(host); - result.push(host); - }); - return result; + const normalizeMongoSrvHostList = ( + rawList: unknown, + defaultPort: number, + ): string[] => { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const result: string[] = []; + list.forEach((entry) => { + const parsed = parseHostPort(String(entry || ""), defaultPort); + if (!parsed?.host) { + return; + } + const host = String(parsed.host).trim(); + if (!host || seen.has(host)) { + return; + } + seen.add(host); + result.push(host); + }); + return result; }; const safeDecode = (text: string) => { - try { - return decodeURIComponent(text); - } catch { - return text; - } + try { + return decodeURIComponent(text); + } catch { + return text; + } }; const normalizeFileDbPath = (rawPath: string): string => { - let pathText = String(rawPath || '').trim(); - if (!pathText) { - return ''; - } - // 兼容 sqlite:///C:/... 或 sqlite:///C:\... 解析后多出的前导斜杠。 - if (/^\/[a-zA-Z]:[\\/]/.test(pathText)) { - pathText = pathText.slice(1); - } - // 兼容历史版本把 Windows 文件路径误拼成 :3306:3306。 - const legacyMatch = pathText.match(/^([a-zA-Z]:[\\/].*?)(?::\d+)+$/); - if (legacyMatch?.[1]) { - return legacyMatch[1]; - } - return pathText; + let pathText = String(rawPath || "").trim(); + if (!pathText) { + return ""; + } + // 兼容 sqlite:///C:/... 或 sqlite:///C:\... 解析后多出的前导斜杠。 + if (/^\/[a-zA-Z]:[\\/]/.test(pathText)) { + pathText = pathText.slice(1); + } + // 兼容历史版本把 Windows 文件路径误拼成 :3306:3306。 + const legacyMatch = pathText.match(/^([a-zA-Z]:[\\/].*?)(?::\d+)+$/); + if (legacyMatch?.[1]) { + return legacyMatch[1]; + } + return pathText; }; const parseMultiHostUri = (uriText: string, expectedScheme: string) => { - const prefix = `${expectedScheme}://`; - if (!uriText.toLowerCase().startsWith(prefix)) { - return null; - } - let rest = uriText.slice(prefix.length); - const hashIndex = rest.indexOf('#'); - if (hashIndex >= 0) { - rest = rest.slice(0, hashIndex); - } - let queryText = ''; - const queryIndex = rest.indexOf('?'); - if (queryIndex >= 0) { - queryText = rest.slice(queryIndex + 1); - rest = rest.slice(0, queryIndex); - } + const prefix = `${expectedScheme}://`; + if (!uriText.toLowerCase().startsWith(prefix)) { + return null; + } + let rest = uriText.slice(prefix.length); + const hashIndex = rest.indexOf("#"); + if (hashIndex >= 0) { + rest = rest.slice(0, hashIndex); + } + let queryText = ""; + const queryIndex = rest.indexOf("?"); + if (queryIndex >= 0) { + queryText = rest.slice(queryIndex + 1); + rest = rest.slice(0, queryIndex); + } - let pathText = ''; - const slashIndex = rest.indexOf('/'); - if (slashIndex >= 0) { - pathText = rest.slice(slashIndex + 1); - rest = rest.slice(0, slashIndex); + let pathText = ""; + const slashIndex = rest.indexOf("/"); + if (slashIndex >= 0) { + pathText = rest.slice(slashIndex + 1); + rest = rest.slice(0, slashIndex); + } + + let hostText = rest; + let username = ""; + let password = ""; + const atIndex = rest.lastIndexOf("@"); + if (atIndex >= 0) { + const userInfo = rest.slice(0, atIndex); + hostText = rest.slice(atIndex + 1); + const colonIndex = userInfo.indexOf(":"); + if (colonIndex >= 0) { + username = safeDecode(userInfo.slice(0, colonIndex)); + password = safeDecode(userInfo.slice(colonIndex + 1)); + } else { + username = safeDecode(userInfo); } + } - let hostText = rest; - let username = ''; - let password = ''; - const atIndex = rest.lastIndexOf('@'); - if (atIndex >= 0) { - const userInfo = rest.slice(0, atIndex); - hostText = rest.slice(atIndex + 1); - const colonIndex = userInfo.indexOf(':'); - if (colonIndex >= 0) { - username = safeDecode(userInfo.slice(0, colonIndex)); - password = safeDecode(userInfo.slice(colonIndex + 1)); - } else { - username = safeDecode(userInfo); - } - } + const hosts = hostText + .split(",") + .map((item) => item.trim()) + .filter(Boolean); - const hosts = hostText - .split(',') - .map((item) => item.trim()) - .filter(Boolean); - - return { - username, - password, - hosts, - database: safeDecode(pathText), - params: new URLSearchParams(queryText), - }; + return { + username, + password, + hosts, + database: safeDecode(pathText), + params: new URLSearchParams(queryText), + }; }; const parseSingleHostUri = ( - uriText: string, - expectedSchemes: string[], - defaultPort: number, - ): { host: string; port: number; username: string; password: string; database: string; params: URLSearchParams } | null => { - let parsed: ReturnType | null = null; - for (const scheme of expectedSchemes) { - parsed = parseMultiHostUri(uriText, scheme); - if (parsed) { - break; - } + uriText: string, + expectedSchemes: string[], + defaultPort: number, + ): { + host: string; + port: number; + username: string; + password: string; + database: string; + params: URLSearchParams; + } | null => { + let parsed: ReturnType | null = null; + for (const scheme of expectedSchemes) { + parsed = parseMultiHostUri(uriText, scheme); + if (parsed) { + break; } + } + if (!parsed) { + return null; + } + if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { + return null; + } + if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { + return null; + } + const hostList = normalizeAddressList(parsed.hosts, defaultPort); + if (!hostList.length) { + return null; + } + const primary = parseHostPort( + hostList[0] || `localhost:${defaultPort}`, + defaultPort, + ); + return { + host: primary?.host || "localhost", + port: primary?.port || defaultPort, + username: parsed.username, + password: parsed.password, + database: parsed.database || "", + params: parsed.params, + }; + }; + + const parseUriToValues = ( + uriText: string, + type: string, + ): Record | null => { + const trimmedUri = String(uriText || "").trim(); + if (!trimmedUri) { + return null; + } + if (trimmedUri.length > MAX_URI_LENGTH) { + return null; + } + + if ( + type === "mysql" || + type === "mariadb" || + type === "diros" || + type === "sphinx" + ) { + const mysqlDefaultPort = getDefaultPortByType(type); + const parsed = + parseMultiHostUri(trimmedUri, "mysql") || + parseMultiHostUri(trimmedUri, "diros") || + parseMultiHostUri(trimmedUri, "doris"); if (!parsed) { - return null; + return null; } if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { - return null; + return null; } if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { - return null; + return null; } - const hostList = normalizeAddressList(parsed.hosts, defaultPort); + const hostList = normalizeAddressList(parsed.hosts, mysqlDefaultPort); if (!hostList.length) { - return null; + return null; } - const primary = parseHostPort(hostList[0] || `localhost:${defaultPort}`, defaultPort); + const primary = parseHostPort( + hostList[0] || `localhost:${mysqlDefaultPort}`, + mysqlDefaultPort, + ); + const timeoutValue = Number(parsed.params.get("timeout")); + const topology = String( + parsed.params.get("topology") || "", + ).toLowerCase(); + const tlsValue = String(parsed.params.get("tls") || "") + .trim() + .toLowerCase(); + const sslMode = + tlsValue === "true" + ? "required" + : tlsValue === "skip-verify" + ? "skip-verify" + : tlsValue === "preferred" + ? "preferred" + : "disable"; return { - host: primary?.host || 'localhost', - port: primary?.port || defaultPort, - username: parsed.username, - password: parsed.password, - database: parsed.database || '', - params: parsed.params, + host: primary?.host || "localhost", + port: primary?.port || mysqlDefaultPort, + user: parsed.username, + password: parsed.password, + database: parsed.database || "", + useSSL: sslMode !== "disable", + sslMode, + mysqlTopology: + hostList.length > 1 || topology === "replica" ? "replica" : "single", + mysqlReplicaHosts: hostList.slice(1), + timeout: + Number.isFinite(timeoutValue) && timeoutValue > 0 + ? Math.min(3600, Math.trunc(timeoutValue)) + : undefined, }; + } + + if (isFileDatabaseType(type)) { + const rawPath = trimmedUri + .replace(/^sqlite:\/\//i, "") + .replace(/^duckdb:\/\//i, "") + .trim(); + if (!rawPath) { + return null; + } + return { host: normalizeFileDbPath(safeDecode(rawPath)) }; + } + + if (type === "redis") { + const parsed = + parseMultiHostUri(trimmedUri, "redis") || + parseMultiHostUri(trimmedUri, "rediss"); + if (!parsed) { + return null; + } + if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { + return null; + } + if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { + return null; + } + const hostList = normalizeAddressList(parsed.hosts, 6379); + if (!hostList.length) { + return null; + } + const primary = parseHostPort(hostList[0] || "localhost:6379", 6379); + const topologyParam = String( + parsed.params.get("topology") || "", + ).toLowerCase(); + const dbText = String(parsed.database || "") + .trim() + .replace(/^\//, ""); + const dbIndex = Number(dbText); + const isRediss = trimmedUri.toLowerCase().startsWith("rediss://"); + const skipVerifyText = String(parsed.params.get("skip_verify") || "") + .trim() + .toLowerCase(); + const skipVerify = + skipVerifyText === "1" || + skipVerifyText === "true" || + skipVerifyText === "yes" || + skipVerifyText === "on"; + return { + host: primary?.host || "localhost", + port: primary?.port || 6379, + user: parsed.username || "", + password: parsed.password || "", + useSSL: isRediss, + sslMode: isRediss + ? skipVerify + ? "skip-verify" + : "required" + : "disable", + redisTopology: + hostList.length > 1 || topologyParam === "cluster" + ? "cluster" + : "single", + redisHosts: hostList.slice(1), + redisDB: + Number.isFinite(dbIndex) && dbIndex >= 0 && dbIndex <= 15 + ? Math.trunc(dbIndex) + : 0, + }; + } + + if (type === "mongodb") { + const parsed = + parseMultiHostUri(trimmedUri, "mongodb") || + parseMultiHostUri(trimmedUri, "mongodb+srv"); + if (!parsed) { + return null; + } + if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { + return null; + } + if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { + return null; + } + const isSrv = trimmedUri.toLowerCase().startsWith("mongodb+srv://"); + const hostList = isSrv + ? normalizeMongoSrvHostList(parsed.hosts, 27017) + : normalizeAddressList(parsed.hosts, 27017); + if (!hostList.length) { + return null; + } + const primary = isSrv + ? { host: hostList[0] || "localhost", port: 27017 } + : parseHostPort(hostList[0] || "localhost:27017", 27017); + const timeoutMs = Number( + parsed.params.get("connectTimeoutMS") || + parsed.params.get("serverSelectionTimeoutMS"), + ); + const tlsText = String( + parsed.params.get("tls") || parsed.params.get("ssl") || "", + ) + .trim() + .toLowerCase(); + const tlsInsecureText = String( + parsed.params.get("tlsInsecure") || + parsed.params.get("sslInsecure") || + "", + ) + .trim() + .toLowerCase(); + const tlsEnabled = + tlsText === "1" || + tlsText === "true" || + tlsText === "yes" || + tlsText === "on"; + const tlsInsecure = + tlsInsecureText === "1" || + tlsInsecureText === "true" || + tlsInsecureText === "yes" || + tlsInsecureText === "on"; + return { + host: primary?.host || "localhost", + port: primary?.port || 27017, + user: parsed.username, + password: parsed.password, + database: parsed.database || "", + useSSL: tlsEnabled, + sslMode: tlsEnabled + ? tlsInsecure + ? "skip-verify" + : "required" + : "disable", + mongoTopology: + hostList.length > 1 || !!parsed.params.get("replicaSet") + ? "replica" + : "single", + mongoHosts: hostList.slice(1), + mongoSrv: isSrv, + mongoReplicaSet: parsed.params.get("replicaSet") || "", + mongoAuthSource: parsed.params.get("authSource") || "", + mongoReadPreference: parsed.params.get("readPreference") || "primary", + mongoAuthMechanism: parsed.params.get("authMechanism") || "", + timeout: + Number.isFinite(timeoutMs) && timeoutMs > 0 + ? Math.min(MAX_TIMEOUT_SECONDS, Math.ceil(timeoutMs / 1000)) + : undefined, + savePassword: true, + }; + } + + const singleHostSchemes = singleHostUriSchemesByType[type]; + if (singleHostSchemes && singleHostSchemes.length > 0) { + const parsed = parseSingleHostUri( + trimmedUri, + singleHostSchemes, + getDefaultPortByType(type), + ); + if (!parsed) { + return null; + } + if (type === "oracle" && !String(parsed.database || "").trim()) { + // Oracle 需要显式 service name,避免 URI 解析后放过必填校验。 + return null; + } + const parsedValues: Record = { + host: parsed.host, + port: parsed.port, + user: parsed.username, + password: parsed.password, + database: parsed.database, + }; + + if (supportsSSLForType(type)) { + const normalizeBool = (raw: unknown) => { + const text = String(raw ?? "") + .trim() + .toLowerCase(); + return ( + text === "1" || text === "true" || text === "yes" || text === "on" + ); + }; + if ( + type === "postgres" || + type === "kingbase" || + type === "highgo" || + type === "vastbase" + ) { + const sslMode = String(parsed.params.get("sslmode") || "") + .trim() + .toLowerCase(); + if (sslMode) { + parsedValues.useSSL = sslMode !== "disable" && sslMode !== "false"; + parsedValues.sslMode = + sslMode === "disable" || sslMode === "false" + ? "disable" + : "required"; + } + } else if (type === "sqlserver") { + const encrypt = String(parsed.params.get("encrypt") || "") + .trim() + .toLowerCase(); + const trust = String( + parsed.params.get("TrustServerCertificate") || + parsed.params.get("trustservercertificate") || + "", + ) + .trim() + .toLowerCase(); + const encrypted = + encrypt === "true" || + encrypt === "mandatory" || + encrypt === "yes" || + encrypt === "1" || + encrypt === "strict"; + if (encrypted) { + parsedValues.useSSL = true; + parsedValues.sslMode = + trust === "true" || trust === "1" || trust === "yes" + ? "skip-verify" + : "required"; + } else if (encrypt) { + parsedValues.useSSL = false; + parsedValues.sslMode = "disable"; + } + } else if (type === "clickhouse") { + const secure = String( + parsed.params.get("secure") || parsed.params.get("tls") || "", + ) + .trim() + .toLowerCase(); + const skipVerify = normalizeBool(parsed.params.get("skip_verify")); + if (secure) { + parsedValues.useSSL = normalizeBool(secure); + parsedValues.sslMode = skipVerify + ? "skip-verify" + : parsedValues.useSSL + ? "required" + : "disable"; + } + } else if (type === "dameng") { + const certPath = String( + parsed.params.get("SSL_CERT_PATH") || + parsed.params.get("ssl_cert_path") || + parsed.params.get("sslCertPath") || + "", + ).trim(); + const keyPath = String( + parsed.params.get("SSL_KEY_PATH") || + parsed.params.get("ssl_key_path") || + parsed.params.get("sslKeyPath") || + "", + ).trim(); + parsedValues.sslCertPath = certPath; + parsedValues.sslKeyPath = keyPath; + if (certPath || keyPath) { + parsedValues.useSSL = true; + parsedValues.sslMode = "required"; + } + } else if (type === "oracle") { + const ssl = String( + parsed.params.get("SSL") || parsed.params.get("ssl") || "", + ) + .trim() + .toLowerCase(); + const sslVerify = String( + parsed.params.get("SSL VERIFY") || + parsed.params.get("ssl verify") || + parsed.params.get("SSL_VERIFY") || + parsed.params.get("ssl_verify") || + "", + ) + .trim() + .toLowerCase(); + if (ssl) { + parsedValues.useSSL = normalizeBool(ssl); + if (!parsedValues.useSSL) { + parsedValues.sslMode = "disable"; + } else { + parsedValues.sslMode = normalizeBool(sslVerify || "true") + ? "required" + : "skip-verify"; + } + } + } else if (type === "tdengine") { + const protocol = String(parsed.params.get("protocol") || "") + .trim() + .toLowerCase(); + const skipVerify = normalizeBool(parsed.params.get("skip_verify")); + if (protocol === "wss") { + parsedValues.useSSL = true; + parsedValues.sslMode = skipVerify ? "skip-verify" : "required"; + } else if (protocol === "ws") { + parsedValues.useSSL = false; + parsedValues.sslMode = "disable"; + } + } + } + return parsedValues; + } + + return null; }; - const parseUriToValues = (uriText: string, type: string): Record | null => { - const trimmedUri = String(uriText || '').trim(); - if (!trimmedUri) { - return null; - } - if (trimmedUri.length > MAX_URI_LENGTH) { - return null; - } - - if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') { - const mysqlDefaultPort = getDefaultPortByType(type); - const parsed = parseMultiHostUri(trimmedUri, 'mysql') - || parseMultiHostUri(trimmedUri, 'diros') - || parseMultiHostUri(trimmedUri, 'doris'); - if (!parsed) { - return null; - } - if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { - return null; - } - if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { - return null; - } - const hostList = normalizeAddressList(parsed.hosts, mysqlDefaultPort); - if (!hostList.length) { - return null; - } - const primary = parseHostPort(hostList[0] || `localhost:${mysqlDefaultPort}`, mysqlDefaultPort); - const timeoutValue = Number(parsed.params.get('timeout')); - const topology = String(parsed.params.get('topology') || '').toLowerCase(); - const tlsValue = String(parsed.params.get('tls') || '').trim().toLowerCase(); - const sslMode = tlsValue === 'true' - ? 'required' - : tlsValue === 'skip-verify' - ? 'skip-verify' - : tlsValue === 'preferred' - ? 'preferred' - : 'disable'; - return { - host: primary?.host || 'localhost', - port: primary?.port || mysqlDefaultPort, - user: parsed.username, - password: parsed.password, - database: parsed.database || '', - useSSL: sslMode !== 'disable', - sslMode, - mysqlTopology: hostList.length > 1 || topology === 'replica' ? 'replica' : 'single', - mysqlReplicaHosts: hostList.slice(1), - timeout: Number.isFinite(timeoutValue) && timeoutValue > 0 - ? Math.min(3600, Math.trunc(timeoutValue)) - : undefined, - }; - } - - if (isFileDatabaseType(type)) { - const rawPath = trimmedUri - .replace(/^sqlite:\/\//i, '') - .replace(/^duckdb:\/\//i, '') - .trim(); - if (!rawPath) { - return null; - } - return { host: normalizeFileDbPath(safeDecode(rawPath)) }; - } - - if (type === 'redis') { - const parsed = parseMultiHostUri(trimmedUri, 'redis') || parseMultiHostUri(trimmedUri, 'rediss'); - if (!parsed) { - return null; - } - if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { - return null; - } - if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { - return null; - } - const hostList = normalizeAddressList(parsed.hosts, 6379); - if (!hostList.length) { - return null; - } - const primary = parseHostPort(hostList[0] || 'localhost:6379', 6379); - const topologyParam = String(parsed.params.get('topology') || '').toLowerCase(); - const dbText = String(parsed.database || '').trim().replace(/^\//, ''); - const dbIndex = Number(dbText); - const isRediss = trimmedUri.toLowerCase().startsWith('rediss://'); - const skipVerifyText = String(parsed.params.get('skip_verify') || '').trim().toLowerCase(); - const skipVerify = skipVerifyText === '1' || skipVerifyText === 'true' || skipVerifyText === 'yes' || skipVerifyText === 'on'; - return { - host: primary?.host || 'localhost', - port: primary?.port || 6379, - user: parsed.username || '', - password: parsed.password || '', - useSSL: isRediss, - sslMode: isRediss ? (skipVerify ? 'skip-verify' : 'required') : 'disable', - redisTopology: hostList.length > 1 || topologyParam === 'cluster' ? 'cluster' : 'single', - redisHosts: hostList.slice(1), - redisDB: Number.isFinite(dbIndex) && dbIndex >= 0 && dbIndex <= 15 ? Math.trunc(dbIndex) : 0, - }; - } - - if (type === 'mongodb') { - const parsed = parseMultiHostUri(trimmedUri, 'mongodb') || parseMultiHostUri(trimmedUri, 'mongodb+srv'); - if (!parsed) { - return null; - } - if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { - return null; - } - if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { - return null; - } - const isSrv = trimmedUri.toLowerCase().startsWith('mongodb+srv://'); - const hostList = isSrv - ? normalizeMongoSrvHostList(parsed.hosts, 27017) - : normalizeAddressList(parsed.hosts, 27017); - if (!hostList.length) { - return null; - } - const primary = isSrv - ? { host: hostList[0] || 'localhost', port: 27017 } - : parseHostPort(hostList[0] || 'localhost:27017', 27017); - const timeoutMs = Number(parsed.params.get('connectTimeoutMS') || parsed.params.get('serverSelectionTimeoutMS')); - const tlsText = String(parsed.params.get('tls') || parsed.params.get('ssl') || '').trim().toLowerCase(); - const tlsInsecureText = String(parsed.params.get('tlsInsecure') || parsed.params.get('sslInsecure') || '').trim().toLowerCase(); - const tlsEnabled = tlsText === '1' || tlsText === 'true' || tlsText === 'yes' || tlsText === 'on'; - const tlsInsecure = tlsInsecureText === '1' || tlsInsecureText === 'true' || tlsInsecureText === 'yes' || tlsInsecureText === 'on'; - return { - host: primary?.host || 'localhost', - port: primary?.port || 27017, - user: parsed.username, - password: parsed.password, - database: parsed.database || '', - useSSL: tlsEnabled, - sslMode: tlsEnabled ? (tlsInsecure ? 'skip-verify' : 'required') : 'disable', - mongoTopology: hostList.length > 1 || !!parsed.params.get('replicaSet') ? 'replica' : 'single', - mongoHosts: hostList.slice(1), - mongoSrv: isSrv, - mongoReplicaSet: parsed.params.get('replicaSet') || '', - mongoAuthSource: parsed.params.get('authSource') || '', - mongoReadPreference: parsed.params.get('readPreference') || 'primary', - mongoAuthMechanism: parsed.params.get('authMechanism') || '', - timeout: Number.isFinite(timeoutMs) && timeoutMs > 0 - ? Math.min(MAX_TIMEOUT_SECONDS, Math.ceil(timeoutMs / 1000)) - : undefined, - savePassword: true, - }; - } - - const singleHostSchemes = singleHostUriSchemesByType[type]; - if (singleHostSchemes && singleHostSchemes.length > 0) { - const parsed = parseSingleHostUri(trimmedUri, singleHostSchemes, getDefaultPortByType(type)); - if (!parsed) { - return null; - } - if (type === 'oracle' && !String(parsed.database || '').trim()) { - // Oracle 需要显式 service name,避免 URI 解析后放过必填校验。 - return null; - } - const parsedValues: Record = { - host: parsed.host, - port: parsed.port, - user: parsed.username, - password: parsed.password, - database: parsed.database, - }; - - if (supportsSSLForType(type)) { - const normalizeBool = (raw: unknown) => { - const text = String(raw ?? '').trim().toLowerCase(); - return text === '1' || text === 'true' || text === 'yes' || text === 'on'; - }; - if (type === 'postgres' || type === 'kingbase' || type === 'highgo' || type === 'vastbase') { - const sslMode = String(parsed.params.get('sslmode') || '').trim().toLowerCase(); - if (sslMode) { - parsedValues.useSSL = sslMode !== 'disable' && sslMode !== 'false'; - parsedValues.sslMode = sslMode === 'disable' || sslMode === 'false' - ? 'disable' - : 'required'; - } - } else if (type === 'sqlserver') { - const encrypt = String(parsed.params.get('encrypt') || '').trim().toLowerCase(); - const trust = String(parsed.params.get('TrustServerCertificate') || parsed.params.get('trustservercertificate') || '').trim().toLowerCase(); - const encrypted = encrypt === 'true' || encrypt === 'mandatory' || encrypt === 'yes' || encrypt === '1' || encrypt === 'strict'; - if (encrypted) { - parsedValues.useSSL = true; - parsedValues.sslMode = trust === 'true' || trust === '1' || trust === 'yes' ? 'skip-verify' : 'required'; - } else if (encrypt) { - parsedValues.useSSL = false; - parsedValues.sslMode = 'disable'; - } - } else if (type === 'clickhouse') { - const secure = String(parsed.params.get('secure') || parsed.params.get('tls') || '').trim().toLowerCase(); - const skipVerify = normalizeBool(parsed.params.get('skip_verify')); - if (secure) { - parsedValues.useSSL = normalizeBool(secure); - parsedValues.sslMode = skipVerify ? 'skip-verify' : (parsedValues.useSSL ? 'required' : 'disable'); - } - } else if (type === 'dameng') { - const certPath = String( - parsed.params.get('SSL_CERT_PATH') - || parsed.params.get('ssl_cert_path') - || parsed.params.get('sslCertPath') - || '' - ).trim(); - const keyPath = String( - parsed.params.get('SSL_KEY_PATH') - || parsed.params.get('ssl_key_path') - || parsed.params.get('sslKeyPath') - || '' - ).trim(); - parsedValues.sslCertPath = certPath; - parsedValues.sslKeyPath = keyPath; - if (certPath || keyPath) { - parsedValues.useSSL = true; - parsedValues.sslMode = 'required'; - } - } else if (type === 'oracle') { - const ssl = String(parsed.params.get('SSL') || parsed.params.get('ssl') || '').trim().toLowerCase(); - const sslVerify = String( - parsed.params.get('SSL VERIFY') - || parsed.params.get('ssl verify') - || parsed.params.get('SSL_VERIFY') - || parsed.params.get('ssl_verify') - || '' - ).trim().toLowerCase(); - if (ssl) { - parsedValues.useSSL = normalizeBool(ssl); - if (!parsedValues.useSSL) { - parsedValues.sslMode = 'disable'; - } else { - parsedValues.sslMode = normalizeBool(sslVerify || 'true') ? 'required' : 'skip-verify'; - } - } - } else if (type === 'tdengine') { - const protocol = String(parsed.params.get('protocol') || '').trim().toLowerCase(); - const skipVerify = normalizeBool(parsed.params.get('skip_verify')); - if (protocol === 'wss') { - parsedValues.useSSL = true; - parsedValues.sslMode = skipVerify ? 'skip-verify' : 'required'; - } else if (protocol === 'ws') { - parsedValues.useSSL = false; - parsedValues.sslMode = 'disable'; - } - } - }; - return parsedValues; - } - - return null; - }; - - const createUriAwareRequiredRule = ( - messageText: string, - validateValue?: (value: unknown) => boolean - ) => ({ getFieldValue }: { getFieldValue: (name: string) => unknown }) => ({ + const createUriAwareRequiredRule = + (messageText: string, validateValue?: (value: unknown) => boolean) => + ({ getFieldValue }: { getFieldValue: (name: string) => unknown }) => ({ validator(_: unknown, value: unknown) { - const uriText = String(getFieldValue('uri') || '').trim(); - const type = String(getFieldValue('type') || dbType).trim().toLowerCase(); - if (uriText && parseUriToValues(uriText, type)) { - return Promise.resolve(); - } - const valid = validateValue - ? validateValue(value) - : String(value ?? '').trim() !== ''; - return valid ? Promise.resolve() : Promise.reject(new Error(messageText)); - } - }); + const uriText = String(getFieldValue("uri") || "").trim(); + const type = String(getFieldValue("type") || dbType) + .trim() + .toLowerCase(); + if (uriText && parseUriToValues(uriText, type)) { + return Promise.resolve(); + } + const valid = validateValue + ? validateValue(value) + : String(value ?? "").trim() !== ""; + return valid + ? Promise.resolve() + : Promise.reject(new Error(messageText)); + }, + }); const createCustomDsnRule = () => ({ - validator(_: unknown, value: unknown) { - const validationMessage = getCustomConnectionDsnValidationMessage({ - dsnInput: value, - hasStoredSecret: initialValues?.hasOpaqueDSN, - clearStoredSecret: clearSecrets.opaqueDSN, - }); - return validationMessage - ? Promise.reject(new Error(validationMessage)) - : Promise.resolve(); - } + validator(_: unknown, value: unknown) { + const validationMessage = getCustomConnectionDsnValidationMessage({ + dsnInput: value, + hasStoredSecret: initialValues?.hasOpaqueDSN, + clearStoredSecret: clearSecrets.opaqueDSN, + }); + return validationMessage + ? Promise.reject(new Error(validationMessage)) + : Promise.resolve(); + }, }); const getUriPlaceholder = () => { - if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') { - const defaultPort = getDefaultPortByType(dbType); - const scheme = dbType === 'diros' ? 'doris' : 'mysql'; - return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`; - } - if (isFileDatabaseType(dbType)) { - return dbType === 'duckdb' - ? 'duckdb:///Users/name/demo.duckdb' - : 'sqlite:///Users/name/demo.sqlite'; - } - if (dbType === 'mongodb') { - return 'mongodb+srv://user:pass@cluster0.example.com/db_name?authSource=admin&authMechanism=SCRAM-SHA-256'; - } - if (dbType === 'clickhouse') { - return 'clickhouse://default:pass@127.0.0.1:9000/default'; - } - if (dbType === 'redis') { - return 'redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster'; - } - if (dbType === 'oracle') { - return 'oracle://user:pass@127.0.0.1:1521/ORCLPDB1'; - } - return '例如: postgres://user:pass@127.0.0.1:5432/db_name'; + if ( + dbType === "mysql" || + dbType === "mariadb" || + dbType === "diros" || + dbType === "sphinx" + ) { + const defaultPort = getDefaultPortByType(dbType); + const scheme = dbType === "diros" ? "doris" : "mysql"; + return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`; + } + if (isFileDatabaseType(dbType)) { + return dbType === "duckdb" + ? "duckdb:///Users/name/demo.duckdb" + : "sqlite:///Users/name/demo.sqlite"; + } + if (dbType === "mongodb") { + return "mongodb+srv://user:pass@cluster0.example.com/db_name?authSource=admin&authMechanism=SCRAM-SHA-256"; + } + if (dbType === "clickhouse") { + return "clickhouse://default:pass@127.0.0.1:9000/default"; + } + if (dbType === "redis") { + return "redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster"; + } + if (dbType === "oracle") { + return "oracle://user:pass@127.0.0.1:1521/ORCLPDB1"; + } + return "例如: postgres://user:pass@127.0.0.1:5432/db_name"; }; const buildUriFromValues = (values: any) => { - const type = String(values.type || '').trim().toLowerCase(); - const defaultPort = getDefaultPortByType(type); - const host = String(values.host || 'localhost').trim(); - const port = Number(values.port || defaultPort); - const user = String(values.user || '').trim(); - const password = String(values.password || ''); - const database = String(values.database || '').trim(); - const timeout = Number(values.timeout || 30); - const encodedAuth = user - ? `${encodeURIComponent(user)}${password ? `:${encodeURIComponent(password)}` : ''}@` - : ''; + const type = String(values.type || "") + .trim() + .toLowerCase(); + const defaultPort = getDefaultPortByType(type); + const host = String(values.host || "localhost").trim(); + const port = Number(values.port || defaultPort); + const user = String(values.user || "").trim(); + const password = String(values.password || ""); + const database = String(values.database || "").trim(); + const timeout = Number(values.timeout || 30); + const encodedAuth = user + ? `${encodeURIComponent(user)}${password ? `:${encodeURIComponent(password)}` : ""}@` + : ""; - if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') { - const primary = toAddress(host, port, defaultPort); - const replicas = values.mysqlTopology === 'replica' - ? normalizeAddressList(values.mysqlReplicaHosts, defaultPort) - : []; - const hosts = normalizeAddressList([primary, ...replicas], defaultPort); - const params = new URLSearchParams(); - if (hosts.length > 1 || values.mysqlTopology === 'replica') { - params.set('topology', 'replica'); - } - if (values.useSSL) { - const mode = String(values.sslMode || 'preferred').trim().toLowerCase(); - if (mode === 'required') { - params.set('tls', 'true'); - } else if (mode === 'skip-verify') { - params.set('tls', 'skip-verify'); - } else { - params.set('tls', 'preferred'); - } - } - if (Number.isFinite(timeout) && timeout > 0) { - params.set('timeout', String(timeout)); - } - const dbPath = database ? `/${encodeURIComponent(database)}` : '/'; - const query = params.toString(); - const scheme = type === 'diros' ? 'doris' : 'mysql'; - return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; - } - - if (type === 'redis') { - const primary = toAddress(host, port, 6379); - const clusterHosts = values.redisTopology === 'cluster' - ? normalizeAddressList(values.redisHosts, 6379) - : []; - const hosts = normalizeAddressList([primary, ...clusterHosts], 6379); - const params = new URLSearchParams(); - if (hosts.length > 1 || values.redisTopology === 'cluster') { - params.set('topology', 'cluster'); - } - const redisUser = String(values.user || '').trim(); - const redisPassword = String(values.password || ''); - let redisAuth = ''; - if (redisUser || redisPassword) { - const encodedPassword = redisPassword ? encodeURIComponent(redisPassword) : ''; - redisAuth = redisUser - ? `${encodeURIComponent(redisUser)}${redisPassword ? `:${encodedPassword}` : ''}@` - : `:${encodedPassword}@`; - } - const redisDB = Number.isFinite(Number(values.redisDB)) - ? Math.max(0, Math.min(15, Math.trunc(Number(values.redisDB)))) - : 0; - const dbPath = `/${redisDB}`; - if (values.useSSL) { - const mode = String(values.sslMode || 'preferred').trim().toLowerCase(); - if (mode === 'skip-verify' || mode === 'preferred') { - params.set('skip_verify', 'true'); - } - } - const query = params.toString(); - const scheme = values.useSSL ? 'rediss' : 'redis'; - return `${scheme}://${redisAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; - } - - if (isFileDatabaseType(type)) { - const pathText = normalizeFileDbPath(String(values.host || '').trim()); - if (!pathText) { - return `${type}://`; - } - return `${type}://${encodeURI(pathText)}`; - } - - if (type === 'mongodb') { - const useSrv = !!values.mongoSrv; - const primaryAddress = useSrv - ? (parseHostPort(host, 27017)?.host || host || 'localhost') - : toAddress(host, port, 27017); - const extraNodes = values.mongoTopology === 'replica' - ? (useSrv ? normalizeMongoSrvHostList(values.mongoHosts, 27017) : normalizeAddressList(values.mongoHosts, 27017)) - : []; - const hosts = useSrv - ? normalizeMongoSrvHostList([primaryAddress, ...extraNodes], 27017) - : normalizeAddressList([primaryAddress, ...extraNodes], 27017); - const scheme = useSrv ? 'mongodb+srv' : 'mongodb'; - const params = new URLSearchParams(); - const authSource = String(values.mongoAuthSource || database || 'admin').trim(); - if (authSource) { - params.set('authSource', authSource); - } - const replicaSet = String(values.mongoReplicaSet || '').trim(); - if (replicaSet) { - params.set('replicaSet', replicaSet); - } - const readPreference = String(values.mongoReadPreference || '').trim(); - if (readPreference) { - params.set('readPreference', readPreference); - } - const authMechanism = String(values.mongoAuthMechanism || '').trim(); - if (authMechanism) { - params.set('authMechanism', authMechanism); - } - if (values.useSSL) { - const mode = String(values.sslMode || 'preferred').trim().toLowerCase(); - params.set('tls', 'true'); - if (mode === 'skip-verify' || mode === 'preferred') { - params.set('tlsInsecure', 'true'); - } else { - params.delete('tlsInsecure'); - } - } - if (Number.isFinite(timeout) && timeout > 0) { - params.set('connectTimeoutMS', String(timeout * 1000)); - params.set('serverSelectionTimeoutMS', String(timeout * 1000)); - } - const dbPath = database ? `/${encodeURIComponent(database)}` : '/'; - const query = params.toString(); - return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`; - } - - const scheme = type === 'postgres' ? 'postgresql' : type; - const dbPath = database ? `/${encodeURIComponent(database)}` : ''; + if ( + type === "mysql" || + type === "mariadb" || + type === "diros" || + type === "sphinx" + ) { + const primary = toAddress(host, port, defaultPort); + const replicas = + values.mysqlTopology === "replica" + ? normalizeAddressList(values.mysqlReplicaHosts, defaultPort) + : []; + const hosts = normalizeAddressList([primary, ...replicas], defaultPort); const params = new URLSearchParams(); - if (supportsSSLForType(type) && values.useSSL) { - const mode = String(values.sslMode || 'preferred').trim().toLowerCase(); - if (type === 'postgres' || type === 'kingbase' || type === 'highgo' || type === 'vastbase') { - params.set('sslmode', 'require'); - } else if (type === 'sqlserver') { - params.set('encrypt', 'true'); - params.set('TrustServerCertificate', mode === 'skip-verify' || mode === 'preferred' ? 'true' : 'false'); - } else if (type === 'clickhouse') { - params.set('secure', 'true'); - if (mode === 'skip-verify' || mode === 'preferred') { - params.set('skip_verify', 'true'); - } - } else if (type === 'dameng') { - const certPath = String(values.sslCertPath || '').trim(); - const keyPath = String(values.sslKeyPath || '').trim(); - if (certPath) params.set('SSL_CERT_PATH', certPath); - if (keyPath) params.set('SSL_KEY_PATH', keyPath); - } else if (type === 'oracle') { - params.set('SSL', 'TRUE'); - params.set('SSL VERIFY', mode === 'required' ? 'TRUE' : 'FALSE'); - } else if (type === 'tdengine') { - params.set('protocol', 'wss'); - if (mode === 'skip-verify' || mode === 'preferred') { - params.set('skip_verify', 'true'); - } - } - } else if (supportsSSLForType(type)) { - if (type === 'postgres' || type === 'kingbase' || type === 'highgo' || type === 'vastbase') { - params.set('sslmode', 'disable'); - } else if (type === 'sqlserver') { - params.set('encrypt', 'disable'); - params.set('TrustServerCertificate', 'true'); - } else if (type === 'tdengine') { - params.set('protocol', 'ws'); - } + if (hosts.length > 1 || values.mysqlTopology === "replica") { + params.set("topology", "replica"); + } + if (values.useSSL) { + const mode = String(values.sslMode || "preferred") + .trim() + .toLowerCase(); + if (mode === "required") { + params.set("tls", "true"); + } else if (mode === "skip-verify") { + params.set("tls", "skip-verify"); + } else { + params.set("tls", "preferred"); + } + } + if (Number.isFinite(timeout) && timeout > 0) { + params.set("timeout", String(timeout)); + } + const dbPath = database ? `/${encodeURIComponent(database)}` : "/"; + const query = params.toString(); + const scheme = type === "diros" ? "doris" : "mysql"; + return `${scheme}://${encodedAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`; + } + + if (type === "redis") { + const primary = toAddress(host, port, 6379); + const clusterHosts = + values.redisTopology === "cluster" + ? normalizeAddressList(values.redisHosts, 6379) + : []; + const hosts = normalizeAddressList([primary, ...clusterHosts], 6379); + const params = new URLSearchParams(); + if (hosts.length > 1 || values.redisTopology === "cluster") { + params.set("topology", "cluster"); + } + const redisUser = String(values.user || "").trim(); + const redisPassword = String(values.password || ""); + let redisAuth = ""; + if (redisUser || redisPassword) { + const encodedPassword = redisPassword + ? encodeURIComponent(redisPassword) + : ""; + redisAuth = redisUser + ? `${encodeURIComponent(redisUser)}${redisPassword ? `:${encodedPassword}` : ""}@` + : `:${encodedPassword}@`; + } + const redisDB = Number.isFinite(Number(values.redisDB)) + ? Math.max(0, Math.min(15, Math.trunc(Number(values.redisDB)))) + : 0; + const dbPath = `/${redisDB}`; + if (values.useSSL) { + const mode = String(values.sslMode || "preferred") + .trim() + .toLowerCase(); + if (mode === "skip-verify" || mode === "preferred") { + params.set("skip_verify", "true"); + } } const query = params.toString(); - return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${dbPath}${query ? `?${query}` : ''}`; + const scheme = values.useSSL ? "rediss" : "redis"; + return `${scheme}://${redisAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`; + } + + if (isFileDatabaseType(type)) { + const pathText = normalizeFileDbPath(String(values.host || "").trim()); + if (!pathText) { + return `${type}://`; + } + return `${type}://${encodeURI(pathText)}`; + } + + if (type === "mongodb") { + const useSrv = !!values.mongoSrv; + const primaryAddress = useSrv + ? parseHostPort(host, 27017)?.host || host || "localhost" + : toAddress(host, port, 27017); + const extraNodes = + values.mongoTopology === "replica" + ? useSrv + ? normalizeMongoSrvHostList(values.mongoHosts, 27017) + : normalizeAddressList(values.mongoHosts, 27017) + : []; + const hosts = useSrv + ? normalizeMongoSrvHostList([primaryAddress, ...extraNodes], 27017) + : normalizeAddressList([primaryAddress, ...extraNodes], 27017); + const scheme = useSrv ? "mongodb+srv" : "mongodb"; + const params = new URLSearchParams(); + const authSource = String( + values.mongoAuthSource || database || "admin", + ).trim(); + if (authSource) { + params.set("authSource", authSource); + } + const replicaSet = String(values.mongoReplicaSet || "").trim(); + if (replicaSet) { + params.set("replicaSet", replicaSet); + } + const readPreference = String(values.mongoReadPreference || "").trim(); + if (readPreference) { + params.set("readPreference", readPreference); + } + const authMechanism = String(values.mongoAuthMechanism || "").trim(); + if (authMechanism) { + params.set("authMechanism", authMechanism); + } + if (values.useSSL) { + const mode = String(values.sslMode || "preferred") + .trim() + .toLowerCase(); + params.set("tls", "true"); + if (mode === "skip-verify" || mode === "preferred") { + params.set("tlsInsecure", "true"); + } else { + params.delete("tlsInsecure"); + } + } + if (Number.isFinite(timeout) && timeout > 0) { + params.set("connectTimeoutMS", String(timeout * 1000)); + params.set("serverSelectionTimeoutMS", String(timeout * 1000)); + } + const dbPath = database ? `/${encodeURIComponent(database)}` : "/"; + const query = params.toString(); + return `${scheme}://${encodedAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`; + } + + const scheme = type === "postgres" ? "postgresql" : type; + const dbPath = database ? `/${encodeURIComponent(database)}` : ""; + const params = new URLSearchParams(); + if (supportsSSLForType(type) && values.useSSL) { + const mode = String(values.sslMode || "preferred") + .trim() + .toLowerCase(); + if ( + type === "postgres" || + type === "kingbase" || + type === "highgo" || + type === "vastbase" + ) { + params.set("sslmode", "require"); + } else if (type === "sqlserver") { + params.set("encrypt", "true"); + params.set( + "TrustServerCertificate", + mode === "skip-verify" || mode === "preferred" ? "true" : "false", + ); + } else if (type === "clickhouse") { + params.set("secure", "true"); + if (mode === "skip-verify" || mode === "preferred") { + params.set("skip_verify", "true"); + } + } else if (type === "dameng") { + const certPath = String(values.sslCertPath || "").trim(); + const keyPath = String(values.sslKeyPath || "").trim(); + if (certPath) params.set("SSL_CERT_PATH", certPath); + if (keyPath) params.set("SSL_KEY_PATH", keyPath); + } else if (type === "oracle") { + params.set("SSL", "TRUE"); + params.set("SSL VERIFY", mode === "required" ? "TRUE" : "FALSE"); + } else if (type === "tdengine") { + params.set("protocol", "wss"); + if (mode === "skip-verify" || mode === "preferred") { + params.set("skip_verify", "true"); + } + } + } else if (supportsSSLForType(type)) { + if ( + type === "postgres" || + type === "kingbase" || + type === "highgo" || + type === "vastbase" + ) { + params.set("sslmode", "disable"); + } else if (type === "sqlserver") { + params.set("encrypt", "disable"); + params.set("TrustServerCertificate", "true"); + } else if (type === "tdengine") { + params.set("protocol", "ws"); + } + } + const query = params.toString(); + return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${dbPath}${query ? `?${query}` : ""}`; }; const handleGenerateURI = () => { - try { - const values = form.getFieldsValue(true); - const uri = buildUriFromValues(values); - form.setFieldValue('uri', uri); - setUriFeedback({ type: 'success', message: 'URI 已生成' }); - } catch { - setUriFeedback({ type: 'error', message: '生成 URI 失败' }); - } + try { + const values = form.getFieldsValue(true); + const uri = buildUriFromValues(values); + form.setFieldValue("uri", uri); + setUriFeedback({ type: "success", message: "URI 已生成" }); + } catch { + setUriFeedback({ type: "error", message: "生成 URI 失败" }); + } }; const handleParseURI = () => { - try { - const uriText = String(form.getFieldValue('uri') || '').trim(); - const type = String(form.getFieldValue('type') || dbType).trim().toLowerCase(); - if (!uriText) { - setUriFeedback({ type: 'warning', message: '请先输入 URI' }); - return; - } - const parsedValues = parseUriToValues(uriText, type); - if (!parsedValues) { - setUriFeedback({ type: 'error', message: '当前 URI 与数据源类型不匹配,或 URI 格式不支持' }); - return; - } - form.setFieldsValue({ ...parsedValues, uri: uriText }); - if (testResult) { - setTestResult(null); - } - setUriFeedback({ type: 'success', message: '已根据 URI 回填连接参数' }); - } catch { - setUriFeedback({ type: 'error', message: 'URI 解析失败,请检查格式后重试' }); + try { + const uriText = String(form.getFieldValue("uri") || "").trim(); + const type = String(form.getFieldValue("type") || dbType) + .trim() + .toLowerCase(); + if (!uriText) { + setUriFeedback({ type: "warning", message: "请先输入 URI" }); + return; } + const parsedValues = parseUriToValues(uriText, type); + if (!parsedValues) { + setUriFeedback({ + type: "error", + message: "当前 URI 与数据源类型不匹配,或 URI 格式不支持", + }); + return; + } + form.setFieldsValue({ ...parsedValues, uri: uriText }); + if (testResult) { + setTestResult(null); + } + setUriFeedback({ type: "success", message: "已根据 URI 回填连接参数" }); + } catch { + setUriFeedback({ + type: "error", + message: "URI 解析失败,请检查格式后重试", + }); + } }; const handleCopyURI = async () => { - let uriText = String(form.getFieldValue('uri') || '').trim(); - if (!uriText) { - const values = form.getFieldsValue(true); - uriText = buildUriFromValues(values); - form.setFieldValue('uri', uriText); - } - if (!uriText) { - setUriFeedback({ type: 'warning', message: '没有可复制的 URI' }); - return; - } - try { - await navigator.clipboard.writeText(uriText); - setUriFeedback({ type: 'success', message: 'URI 已复制' }); - } catch { - setUriFeedback({ type: 'error', message: '复制失败' }); - } + let uriText = String(form.getFieldValue("uri") || "").trim(); + if (!uriText) { + const values = form.getFieldsValue(true); + uriText = buildUriFromValues(values); + form.setFieldValue("uri", uriText); + } + if (!uriText) { + setUriFeedback({ type: "warning", message: "没有可复制的 URI" }); + return; + } + try { + await navigator.clipboard.writeText(uriText); + setUriFeedback({ type: "success", message: "URI 已复制" }); + } catch { + setUriFeedback({ type: "error", message: "复制失败" }); + } }; const handleSelectSSHKeyFile = async () => { - if (selectingSSHKey) { - return; - } - try { - setSelectingSSHKey(true); - const currentPath = String(form.getFieldValue('sshKeyPath') || '').trim(); - const res = await SelectSSHKeyFile(currentPath); - if (res?.success) { - const data = res.data || {}; - const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim(); - if (selectedPath) { - form.setFieldValue('sshKeyPath', selectedPath); - } - } else if (res?.message !== '已取消') { - message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`); - } - } catch (e: any) { - message.error(`选择私钥文件失败: ${e?.message || String(e)}`); - } finally { - setSelectingSSHKey(false); + if (selectingSSHKey) { + return; + } + try { + setSelectingSSHKey(true); + const currentPath = String(form.getFieldValue("sshKeyPath") || "").trim(); + const res = await SelectSSHKeyFile(currentPath); + if (res?.success) { + const data = res.data || {}; + const selectedPath = + typeof data === "string" ? data : String(data.path || "").trim(); + if (selectedPath) { + form.setFieldValue("sshKeyPath", selectedPath); + } + } else if (res?.message !== "已取消") { + message.error(`选择私钥文件失败: ${res?.message || "未知错误"}`); } + } catch (e: any) { + message.error(`选择私钥文件失败: ${e?.message || String(e)}`); + } finally { + setSelectingSSHKey(false); + } }; const handleSelectDatabaseFile = async () => { - if (selectingDbFile) { - return; - } - try { - setSelectingDbFile(true); - const currentPath = String(form.getFieldValue('host') || '').trim(); - const res = await SelectDatabaseFile(currentPath, dbType); - if (res?.success) { - const data = res.data || {}; - const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim(); - if (selectedPath) { - form.setFieldValue('host', normalizeFileDbPath(selectedPath)); - } - } else if (res?.message !== '已取消') { - message.error(`选择数据库文件失败: ${res?.message || '未知错误'}`); - } - } catch (e: any) { - message.error(`选择数据库文件失败: ${e?.message || String(e)}`); - } finally { - setSelectingDbFile(false); + if (selectingDbFile) { + return; + } + try { + setSelectingDbFile(true); + const currentPath = String(form.getFieldValue("host") || "").trim(); + const res = await SelectDatabaseFile(currentPath, dbType); + if (res?.success) { + const data = res.data || {}; + const selectedPath = + typeof data === "string" ? data : String(data.path || "").trim(); + if (selectedPath) { + form.setFieldValue("host", normalizeFileDbPath(selectedPath)); + } + } else if (res?.message !== "已取消") { + message.error(`选择数据库文件失败: ${res?.message || "未知错误"}`); } + } catch (e: any) { + message.error(`选择数据库文件失败: ${e?.message || String(e)}`); + } finally { + setSelectingDbFile(false); + } }; useEffect(() => { - if (open) { - setLoading(false); - testInFlightRef.current = false; - if (testTimerRef.current !== null) { - window.clearTimeout(testTimerRef.current); - testTimerRef.current = null; - } - setTestResult(null); // Reset test result - setTestErrorLogOpen(false); - setDbList([]); - setRedisDbList([]); - setMongoMembers([]); - setUriFeedback(null); - setCustomIconType(undefined); - setCustomIconColor(undefined); - setClearSecrets(createEmptyConnectionSecretClearState()); - setTypeSelectWarning(null); - setDriverStatusLoaded(false); - void refreshDriverStatus(); - if (initialValues) { - // Edit mode: Go directly to step 2 - 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 - : parseHostPort( - normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort), - defaultPort - ); - const primaryHost = isFileDbConfigType - ? normalizeFileDbPath(String(config.host || '')) - : (primaryAddress?.host || String(config.host || 'localhost')); - const primaryPort = isFileDbConfigType - ? 0 - : (primaryAddress?.port || Number(config.port || defaultPort)); - const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : []; - const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : []; - const redisHosts = configType === 'redis' ? normalizedHosts.slice(1) : []; - 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 { allowedModes: resolvedJvmAllowedModes, preferredMode: resolvedJvmPreferredMode } = resolveEditableJVMModeSelection({ - allowedModes: config.jvm?.allowedModes, - preferredMode: config.jvm?.preferredMode, - }); - const resolvedJvmTimeout = isJvmConfigType - ? Number(config.jvm?.endpoint?.timeoutSeconds || config.timeout || 30) - : Number(config.timeout || 30); - const hasHttpTunnel = !!config.useHttpTunnel; - const hasProxy = !hasHttpTunnel && !!config.useProxy; - form.setFieldsValue({ - type: configType, - name: initialValues.name, - host: primaryHost, - port: primaryPort, - user: config.user, - password: config.password, - database: config.database, - uri: config.uri || '', - includeDatabases: initialValues.includeDatabases, - includeRedisDatabases: initialValues.includeRedisDatabases, - useSSL: !!config.useSSL, - sslMode: config.sslMode || 'preferred', - sslCertPath: config.sslCertPath || '', - sslKeyPath: config.sslKeyPath || '', - useSSH: config.useSSH, - sshHost: config.ssh?.host, - sshPort: config.ssh?.port, - sshUser: config.ssh?.user, - sshPassword: config.ssh?.password, - sshKeyPath: config.ssh?.keyPath, - useProxy: hasProxy, - proxyType: config.proxy?.type || 'socks5', - proxyHost: config.proxy?.host, - proxyPort: config.proxy?.port, - proxyUser: config.proxy?.user, - proxyPassword: config.proxy?.password, - useHttpTunnel: hasHttpTunnel, - httpTunnelHost: config.httpTunnel?.host, - httpTunnelPort: config.httpTunnel?.port || 8080, - httpTunnelUser: config.httpTunnel?.user, - httpTunnelPassword: config.httpTunnel?.password, - driver: config.driver, - dsn: config.dsn, - timeout: resolvedJvmTimeout, - mysqlTopology: mysqlIsReplica ? 'replica' : 'single', - mysqlReplicaHosts: mysqlReplicaHosts, - mysqlReplicaUser: config.mysqlReplicaUser || '', - mysqlReplicaPassword: config.mysqlReplicaPassword || '', - mongoTopology: mongoIsReplica ? 'replica' : 'single', - mongoHosts: mongoHosts, - redisTopology: redisIsCluster ? 'cluster' : 'single', - redisHosts: redisHosts, - mongoSrv: !!config.mongoSrv, - mongoReplicaSet: config.replicaSet || '', - mongoAuthSource: config.authSource || '', - mongoReadPreference: config.readPreference || 'primary', - mongoAuthMechanism: config.mongoAuthMechanism || '', - savePassword: config.savePassword !== false, - redisDB: Number.isFinite(Number(config.redisDB)) ? Number(config.redisDB) : 0, - mongoReplicaUser: config.mongoReplicaUser || '', - 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: resolvedJvmTimeout, - 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); - setCustomIconColor(initialValues.iconColor); - setUseSSH(config.useSSH || false); - setUseProxy(hasProxy); - setUseHttpTunnel(hasHttpTunnel); - setDbType(configType); - if (config.useSSL && supportsSSLForType(configType)) { - setActiveNetworkConfig('ssl'); - } else if (config.useSSH) { - setActiveNetworkConfig('ssh'); - } else if (hasProxy) { - setActiveNetworkConfig('proxy'); - } else if (hasHttpTunnel) { - setActiveNetworkConfig('httpTunnel'); - } else { - setActiveNetworkConfig('ssl'); - } - // 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表 - if (configType === 'redis') { - setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); - } - } else { - // Create mode: Start at step 1 - setActiveConfigSection('basic'); - setStep(1); - form.resetFields(); - setUseSSL(false); - setUseSSH(false); - setUseProxy(false); - setUseHttpTunnel(false); - setDbType('mysql'); - setActiveGroup(0); - setActiveConfigSection('basic'); - setActiveNetworkConfig('ssl'); - } + if (open) { + setLoading(false); + testInFlightRef.current = false; + if (testTimerRef.current !== null) { + window.clearTimeout(testTimerRef.current); + testTimerRef.current = null; } + setTestResult(null); // Reset test result + setTestErrorLogOpen(false); + setDbList([]); + setRedisDbList([]); + setMongoMembers([]); + setUriFeedback(null); + setCustomIconType(undefined); + setCustomIconColor(undefined); + setClearSecrets(createEmptyConnectionSecretClearState()); + setTypeSelectWarning(null); + setDriverStatusLoaded(false); + void refreshDriverStatus(); + if (initialValues) { + // Edit mode: Go directly to step 2 + 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 + : parseHostPort( + normalizedHosts[0] || + toAddress( + config.host || "localhost", + Number(config.port || defaultPort), + defaultPort, + ), + defaultPort, + ); + const primaryHost = isFileDbConfigType + ? normalizeFileDbPath(String(config.host || "")) + : primaryAddress?.host || String(config.host || "localhost"); + const primaryPort = isFileDbConfigType + ? 0 + : primaryAddress?.port || Number(config.port || defaultPort); + const mysqlReplicaHosts = + configType === "mysql" || + configType === "mariadb" || + configType === "diros" || + configType === "sphinx" + ? normalizedHosts.slice(1) + : []; + const mongoHosts = + configType === "mongodb" ? normalizedHosts.slice(1) : []; + const redisHosts = + configType === "redis" ? normalizedHosts.slice(1) : []; + 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 { + allowedModes: resolvedJvmAllowedModes, + preferredMode: resolvedJvmPreferredMode, + } = resolveEditableJVMModeSelection({ + allowedModes: config.jvm?.allowedModes, + preferredMode: config.jvm?.preferredMode, + }); + const resolvedJvmTimeout = isJvmConfigType + ? Number(config.jvm?.endpoint?.timeoutSeconds || config.timeout || 30) + : Number(config.timeout || 30); + const hasHttpTunnel = !!config.useHttpTunnel; + const hasProxy = !hasHttpTunnel && !!config.useProxy; + form.setFieldsValue({ + type: configType, + name: initialValues.name, + host: primaryHost, + port: primaryPort, + user: config.user, + password: config.password, + database: config.database, + uri: config.uri || "", + includeDatabases: initialValues.includeDatabases, + includeRedisDatabases: initialValues.includeRedisDatabases, + useSSL: !!config.useSSL, + sslMode: config.sslMode || "preferred", + sslCertPath: config.sslCertPath || "", + sslKeyPath: config.sslKeyPath || "", + useSSH: config.useSSH, + sshHost: config.ssh?.host, + sshPort: config.ssh?.port, + sshUser: config.ssh?.user, + sshPassword: config.ssh?.password, + sshKeyPath: config.ssh?.keyPath, + useProxy: hasProxy, + proxyType: config.proxy?.type || "socks5", + proxyHost: config.proxy?.host, + proxyPort: config.proxy?.port, + proxyUser: config.proxy?.user, + proxyPassword: config.proxy?.password, + useHttpTunnel: hasHttpTunnel, + httpTunnelHost: config.httpTunnel?.host, + httpTunnelPort: config.httpTunnel?.port || 8080, + httpTunnelUser: config.httpTunnel?.user, + httpTunnelPassword: config.httpTunnel?.password, + driver: config.driver, + dsn: config.dsn, + timeout: resolvedJvmTimeout, + mysqlTopology: mysqlIsReplica ? "replica" : "single", + mysqlReplicaHosts: mysqlReplicaHosts, + mysqlReplicaUser: config.mysqlReplicaUser || "", + mysqlReplicaPassword: config.mysqlReplicaPassword || "", + mongoTopology: mongoIsReplica ? "replica" : "single", + mongoHosts: mongoHosts, + redisTopology: redisIsCluster ? "cluster" : "single", + redisHosts: redisHosts, + mongoSrv: !!config.mongoSrv, + mongoReplicaSet: config.replicaSet || "", + mongoAuthSource: config.authSource || "", + mongoReadPreference: config.readPreference || "primary", + mongoAuthMechanism: config.mongoAuthMechanism || "", + savePassword: config.savePassword !== false, + redisDB: Number.isFinite(Number(config.redisDB)) + ? Number(config.redisDB) + : 0, + mongoReplicaUser: config.mongoReplicaUser || "", + 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, + jvmAgentEnabled: isJvmConfigType + ? (config.jvm?.agent?.enabled ?? + resolvedJvmAllowedModes.includes("agent")) + : jvmDefaultValues.jvmAgentEnabled, + jvmAgentBaseUrl: isJvmConfigType + ? config.jvm?.agent?.baseUrl || "" + : jvmDefaultValues.jvmAgentBaseUrl, + jvmAgentApiKey: isJvmConfigType + ? config.jvm?.agent?.apiKey || "" + : jvmDefaultValues.jvmAgentApiKey, + jvmDiagnosticEnabled: isJvmConfigType + ? (config.jvm?.diagnostic?.enabled ?? + jvmDefaultValues.jvmDiagnosticEnabled) + : jvmDefaultValues.jvmDiagnosticEnabled, + jvmDiagnosticTransport: isJvmConfigType + ? config.jvm?.diagnostic?.transport || + jvmDefaultValues.jvmDiagnosticTransport + : jvmDefaultValues.jvmDiagnosticTransport, + jvmDiagnosticBaseUrl: isJvmConfigType + ? config.jvm?.diagnostic?.baseUrl || "" + : jvmDefaultValues.jvmDiagnosticBaseUrl, + jvmDiagnosticTargetId: isJvmConfigType + ? config.jvm?.diagnostic?.targetId || "" + : jvmDefaultValues.jvmDiagnosticTargetId, + jvmDiagnosticApiKey: isJvmConfigType + ? config.jvm?.diagnostic?.apiKey || "" + : jvmDefaultValues.jvmDiagnosticApiKey, + jvmDiagnosticAllowObserveCommands: isJvmConfigType + ? (config.jvm?.diagnostic?.allowObserveCommands ?? + jvmDefaultValues.jvmDiagnosticAllowObserveCommands) + : jvmDefaultValues.jvmDiagnosticAllowObserveCommands, + jvmDiagnosticAllowTraceCommands: isJvmConfigType + ? (config.jvm?.diagnostic?.allowTraceCommands ?? + jvmDefaultValues.jvmDiagnosticAllowTraceCommands) + : jvmDefaultValues.jvmDiagnosticAllowTraceCommands, + jvmDiagnosticAllowMutatingCommands: isJvmConfigType + ? (config.jvm?.diagnostic?.allowMutatingCommands ?? + jvmDefaultValues.jvmDiagnosticAllowMutatingCommands) + : jvmDefaultValues.jvmDiagnosticAllowMutatingCommands, + jvmDiagnosticTimeoutSeconds: isJvmConfigType + ? Number( + config.jvm?.diagnostic?.timeoutSeconds || + jvmDefaultValues.jvmDiagnosticTimeoutSeconds, + ) + : jvmDefaultValues.jvmDiagnosticTimeoutSeconds, + jvmEndpointTimeoutSeconds: resolvedJvmTimeout, + 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); + setCustomIconColor(initialValues.iconColor); + setUseSSH(config.useSSH || false); + setUseProxy(hasProxy); + setUseHttpTunnel(hasHttpTunnel); + setDbType(configType); + if (config.useSSL && supportsSSLForType(configType)) { + setActiveNetworkConfig("ssl"); + } else if (config.useSSH) { + setActiveNetworkConfig("ssh"); + } else if (hasProxy) { + setActiveNetworkConfig("proxy"); + } else if (hasHttpTunnel) { + setActiveNetworkConfig("httpTunnel"); + } else { + setActiveNetworkConfig("ssl"); + } + // 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表 + if (configType === "redis") { + setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); + } + } else { + // Create mode: Start at step 1 + setActiveConfigSection("basic"); + setStep(1); + form.resetFields(); + setUseSSL(false); + setUseSSH(false); + setUseProxy(false); + setUseHttpTunnel(false); + setDbType("mysql"); + setActiveGroup(0); + setActiveConfigSection("basic"); + setActiveNetworkConfig("ssl"); + } + } }, [open, initialValues]); useEffect(() => { - return () => { - if (testTimerRef.current !== null) { - window.clearTimeout(testTimerRef.current); - testTimerRef.current = null; - } - }; + return () => { + if (testTimerRef.current !== null) { + window.clearTimeout(testTimerRef.current); + testTimerRef.current = null; + } + }; }, []); const buildSavedConnectionInput = (config: ConnectionConfig, values: any) => { - const connectionId = initialValues?.id || config.id || Date.now().toString(); - const primaryDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasPrimaryPassword, - valueInput: config.password, - clearSecret: clearSecrets.primaryPassword, - forceClear: values.type === 'mongodb' && values.savePassword === false, - }); - const sshDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasSSHPassword, - valueInput: config.ssh?.password, - clearSecret: clearSecrets.sshPassword, - forceClear: !config.useSSH, - }); - const proxyDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasProxyPassword, - valueInput: config.proxy?.password, - clearSecret: clearSecrets.proxyPassword, - forceClear: !config.useProxy, - }); - const httpTunnelDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasHttpTunnelPassword, - valueInput: config.httpTunnel?.password, - clearSecret: clearSecrets.httpTunnelPassword, - forceClear: !config.useHttpTunnel, - }); - const mysqlReplicaEnabled = (config.type === 'mysql' || config.type === 'mariadb' || config.type === 'diros' || config.type === 'sphinx') - && config.topology === 'replica'; - const mysqlReplicaDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasMySQLReplicaPassword, - valueInput: config.mysqlReplicaPassword, - clearSecret: clearSecrets.mysqlReplicaPassword, - forceClear: !mysqlReplicaEnabled, - }); - const mongoReplicaEnabled = config.type === 'mongodb' - && config.topology === 'replica' - && values.savePassword !== false; - const mongoReplicaDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasMongoReplicaPassword, - valueInput: config.mongoReplicaPassword, - clearSecret: clearSecrets.mongoReplicaPassword, - forceClear: !mongoReplicaEnabled, - }); - const opaqueUriDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasOpaqueURI, - valueInput: config.uri, - clearSecret: clearSecrets.opaqueURI, - forceClear: values.type === 'custom', - trimInput: true, - }); - const opaqueDsnDraft = resolveConnectionSecretDraft({ - hasSecret: initialValues?.hasOpaqueDSN, - valueInput: config.dsn, - clearSecret: clearSecrets.opaqueDSN, - forceClear: values.type !== 'custom', - trimInput: true, - }); - const isRedisType = values.type === 'redis'; - const displayHost = String((config as any).host || values.host || '').trim(); - const nextName = values.name || (isFileDatabaseType(values.type) - ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') - : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)); + const connectionId = + initialValues?.id || config.id || Date.now().toString(); + const primaryDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasPrimaryPassword, + valueInput: config.password, + clearSecret: clearSecrets.primaryPassword, + forceClear: values.type === "mongodb" && values.savePassword === false, + }); + const sshDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasSSHPassword, + valueInput: config.ssh?.password, + clearSecret: clearSecrets.sshPassword, + forceClear: !config.useSSH, + }); + const proxyDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasProxyPassword, + valueInput: config.proxy?.password, + clearSecret: clearSecrets.proxyPassword, + forceClear: !config.useProxy, + }); + const httpTunnelDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasHttpTunnelPassword, + valueInput: config.httpTunnel?.password, + clearSecret: clearSecrets.httpTunnelPassword, + forceClear: !config.useHttpTunnel, + }); + const mysqlReplicaEnabled = + (config.type === "mysql" || + config.type === "mariadb" || + config.type === "diros" || + config.type === "sphinx") && + config.topology === "replica"; + const mysqlReplicaDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasMySQLReplicaPassword, + valueInput: config.mysqlReplicaPassword, + clearSecret: clearSecrets.mysqlReplicaPassword, + forceClear: !mysqlReplicaEnabled, + }); + const mongoReplicaEnabled = + config.type === "mongodb" && + config.topology === "replica" && + values.savePassword !== false; + const mongoReplicaDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasMongoReplicaPassword, + valueInput: config.mongoReplicaPassword, + clearSecret: clearSecrets.mongoReplicaPassword, + forceClear: !mongoReplicaEnabled, + }); + const opaqueUriDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasOpaqueURI, + valueInput: config.uri, + clearSecret: clearSecrets.opaqueURI, + forceClear: values.type === "custom", + trimInput: true, + }); + const opaqueDsnDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasOpaqueDSN, + valueInput: config.dsn, + clearSecret: clearSecrets.opaqueDSN, + forceClear: values.type !== "custom", + trimInput: true, + }); + const isRedisType = values.type === "redis"; + const displayHost = String( + (config as any).host || values.host || "", + ).trim(); + const nextName = + values.name || + (isFileDatabaseType(values.type) + ? values.type === "duckdb" + ? "DuckDB DB" + : "SQLite DB" + : values.type === "redis" + ? `Redis ${displayHost}` + : displayHost); - return { - id: connectionId, - name: nextName, - config: { - ...config, - id: connectionId, - password: primaryDraft.value, - ssh: { - ...(config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }), - password: sshDraft.value, - }, - proxy: { - ...(config.proxy || { type: 'socks5', host: '', port: 1080, user: '', password: '' }), - password: proxyDraft.value, - }, - httpTunnel: { - ...(config.httpTunnel || { host: '', port: 8080, user: '', password: '' }), - password: httpTunnelDraft.value, - }, - uri: opaqueUriDraft.value, - dsn: opaqueDsnDraft.value, - mysqlReplicaPassword: mysqlReplicaDraft.value, - mongoReplicaPassword: mongoReplicaDraft.value, - }, - includeDatabases: values.includeDatabases, - includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined, - iconType: customIconType || '', - iconColor: customIconColor || '', - clearPrimaryPassword: primaryDraft.clearStoredSecret, - clearSSHPassword: sshDraft.clearStoredSecret, - clearProxyPassword: proxyDraft.clearStoredSecret, - clearHttpTunnelPassword: httpTunnelDraft.clearStoredSecret, - clearMySQLReplicaPassword: mysqlReplicaDraft.clearStoredSecret, - clearMongoReplicaPassword: mongoReplicaDraft.clearStoredSecret, - clearOpaqueURI: opaqueUriDraft.clearStoredSecret, - clearOpaqueDSN: opaqueDsnDraft.clearStoredSecret, - }; + return { + id: connectionId, + name: nextName, + config: { + ...config, + id: connectionId, + password: primaryDraft.value, + ssh: { + ...(config.ssh || { + host: "", + port: 22, + user: "", + password: "", + keyPath: "", + }), + password: sshDraft.value, + }, + proxy: { + ...(config.proxy || { + type: "socks5", + host: "", + port: 1080, + user: "", + password: "", + }), + password: proxyDraft.value, + }, + httpTunnel: { + ...(config.httpTunnel || { + host: "", + port: 8080, + user: "", + password: "", + }), + password: httpTunnelDraft.value, + }, + uri: opaqueUriDraft.value, + dsn: opaqueDsnDraft.value, + mysqlReplicaPassword: mysqlReplicaDraft.value, + mongoReplicaPassword: mongoReplicaDraft.value, + }, + includeDatabases: values.includeDatabases, + includeRedisDatabases: isRedisType + ? values.includeRedisDatabases + : undefined, + iconType: customIconType || "", + iconColor: customIconColor || "", + clearPrimaryPassword: primaryDraft.clearStoredSecret, + clearSSHPassword: sshDraft.clearStoredSecret, + clearProxyPassword: proxyDraft.clearStoredSecret, + clearHttpTunnelPassword: httpTunnelDraft.clearStoredSecret, + clearMySQLReplicaPassword: mysqlReplicaDraft.clearStoredSecret, + clearMongoReplicaPassword: mongoReplicaDraft.clearStoredSecret, + clearOpaqueURI: opaqueUriDraft.clearStoredSecret, + clearOpaqueDSN: opaqueDsnDraft.clearStoredSecret, + }; }; const handleOk = async () => { try { await form.validateFields(); const values = form.getFieldsValue(true); - const unavailableReason = await resolveDriverUnavailableReason(values.type); + const unavailableReason = await resolveDriverUnavailableReason( + values.type, + ); if (unavailableReason) { - message.warning(unavailableReason); - promptInstallDriver(values.type, unavailableReason); - return; + message.warning(unavailableReason); + promptInstallDriver(values.type, unavailableReason); + return; } setLoading(true); @@ -1472,22 +2013,26 @@ const ConnectionModal: React.FC<{ const backendApp = (window as any).go?.app?.App; const savedConnection = await backendApp?.SaveConnection?.(payload); if (!savedConnection) { - throw new Error('保存连接失败:后端接口不可用'); + throw new Error("保存连接失败:后端接口不可用"); } if (initialValues) { - updateConnection(savedConnection); - message.success('配置已更新(未连接)'); + updateConnection(savedConnection); + message.success("配置已更新(未连接)"); } else { - addConnection(savedConnection); - message.success('配置已保存(未连接)'); + addConnection(savedConnection); + message.success("配置已保存(未连接)"); } if (onSaved) { - void Promise.resolve(onSaved(savedConnection)).catch((error: unknown) => { - console.warn('Failed to refresh post-save state', error); - void message.warning('配置已保存,但安全更新状态暂未刷新,请稍后重新检查'); - }); + void Promise.resolve(onSaved(savedConnection)).catch( + (error: unknown) => { + console.warn("Failed to refresh post-save state", error); + void message.warning( + "配置已保存,但安全更新状态暂未刷新,请稍后重新检查", + ); + }, + ); } form.resetFields(); @@ -1495,1964 +2040,3623 @@ const ConnectionModal: React.FC<{ setUseSSH(false); setUseProxy(false); setUseHttpTunnel(false); - setDbType('mysql'); + setDbType("mysql"); setStep(1); setClearSecrets(createEmptyConnectionSecretClearState()); onClose(); } catch (e: any) { - message.error(normalizeConnectionSecretErrorMessage(e?.message || e, '保存失败')); + message.error( + normalizeConnectionSecretErrorMessage(e?.message || e, "保存失败"), + ); } finally { setLoading(false); } }; const requestTest = () => { - if (loading) return; - if (testTimerRef.current !== null) return; - testTimerRef.current = window.setTimeout(() => { - testTimerRef.current = null; - handleTest(); - }, 0); + if (loading) return; + if (testTimerRef.current !== null) return; + testTimerRef.current = window.setTimeout(() => { + testTimerRef.current = null; + handleTest(); + }, 0); }; - const withClientTimeout = async (promise: Promise, timeoutMs: number, timeoutMessage: string): Promise => { - let timer: number | null = null; - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timer = window.setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); - }), - ]); - } finally { - if (timer !== null) { - window.clearTimeout(timer); - } + const withClientTimeout = async ( + promise: Promise, + timeoutMs: number, + timeoutMessage: string, + ): Promise => { + let timer: number | null = null; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = window.setTimeout( + () => reject(new Error(timeoutMessage)), + timeoutMs, + ); + }), + ]); + } finally { + if (timer !== null) { + window.clearTimeout(timer); } + } }; const getBlockingSecretClearMessage = (values: any): string | null => { - if (clearSecrets.primaryPassword && values.type !== 'custom' && !isFileDatabaseType(values.type) && String(values.password ?? '') === '') { - return '测试连接前请填写新的密码,或取消清除已保存密码'; - } - if (clearSecrets.sshPassword && values.useSSH && String(values.sshPassword ?? '') === '') { - return '测试连接前请填写新的 SSH 密码,或取消清除已保存 SSH 密码'; - } - if (clearSecrets.proxyPassword && values.useProxy && !values.useHttpTunnel && String(values.proxyPassword ?? '') === '') { - return '测试连接前请填写新的代理密码,或取消清除已保存代理密码'; - } - if (clearSecrets.httpTunnelPassword && values.useHttpTunnel && String(values.httpTunnelPassword ?? '') === '') { - return '测试连接前请填写新的隧道密码,或取消清除已保存隧道密码'; - } - if (clearSecrets.mysqlReplicaPassword && (values.type === 'mysql' || values.type === 'mariadb' || values.type === 'diros' || values.type === 'sphinx') && values.mysqlTopology === 'replica' && String(values.mysqlReplicaPassword ?? '') === '') { - return '测试连接前请填写新的从库密码,或取消清除已保存从库密码'; - } - if (clearSecrets.mongoReplicaPassword && values.type === 'mongodb' && values.mongoTopology === 'replica' && String(values.mongoReplicaPassword ?? '') === '') { - return '测试连接前请填写新的副本集密码,或取消清除已保存副本集密码'; - } - if (values.type === 'mongodb' && values.savePassword === false && initialValues?.hasPrimaryPassword && String(values.password ?? '') === '') { - return '测试连接前请填写新的 MongoDB 密码,或重新勾选保存密码'; - } - return null; + if ( + clearSecrets.primaryPassword && + values.type !== "custom" && + !isFileDatabaseType(values.type) && + String(values.password ?? "") === "" + ) { + return "测试连接前请填写新的密码,或取消清除已保存密码"; + } + if ( + clearSecrets.sshPassword && + values.useSSH && + String(values.sshPassword ?? "") === "" + ) { + return "测试连接前请填写新的 SSH 密码,或取消清除已保存 SSH 密码"; + } + if ( + clearSecrets.proxyPassword && + values.useProxy && + !values.useHttpTunnel && + String(values.proxyPassword ?? "") === "" + ) { + return "测试连接前请填写新的代理密码,或取消清除已保存代理密码"; + } + if ( + clearSecrets.httpTunnelPassword && + values.useHttpTunnel && + String(values.httpTunnelPassword ?? "") === "" + ) { + return "测试连接前请填写新的隧道密码,或取消清除已保存隧道密码"; + } + if ( + clearSecrets.mysqlReplicaPassword && + (values.type === "mysql" || + values.type === "mariadb" || + values.type === "diros" || + values.type === "sphinx") && + values.mysqlTopology === "replica" && + String(values.mysqlReplicaPassword ?? "") === "" + ) { + return "测试连接前请填写新的从库密码,或取消清除已保存从库密码"; + } + if ( + clearSecrets.mongoReplicaPassword && + values.type === "mongodb" && + values.mongoTopology === "replica" && + String(values.mongoReplicaPassword ?? "") === "" + ) { + return "测试连接前请填写新的副本集密码,或取消清除已保存副本集密码"; + } + if ( + values.type === "mongodb" && + values.savePassword === false && + initialValues?.hasPrimaryPassword && + String(values.password ?? "") === "" + ) { + return "测试连接前请填写新的 MongoDB 密码,或重新勾选保存密码"; + } + return null; }; - const applyTestFailureFeedback = (feedback: { message: string; shouldToast: boolean }) => { - setTestResult({ type: 'error', message: feedback.message }); - if (feedback.shouldToast) { - void message.error({ - content: feedback.message, - key: 'connection-test-failure', - }); - } + const applyTestFailureFeedback = (feedback: { message: string }) => { + void message.destroy("connection-test-failure"); + setTestResult({ type: "error", message: feedback.message }); }; const handleTest = async () => { - if (testInFlightRef.current) return; - testInFlightRef.current = true; - try { - await form.validateFields(); - const values = form.getFieldsValue(true); - const unavailableReason = await resolveDriverUnavailableReason(values.type); - if (unavailableReason) { - applyTestFailureFeedback(resolveConnectionTestFailureFeedback({ - kind: 'driver_unavailable', - reason: unavailableReason, - fallback: '驱动未安装启用', - })); - promptInstallDriver(values.type, unavailableReason); - return; - } - const blockingSecretClearMessage = getBlockingSecretClearMessage(values); - if (blockingSecretClearMessage) { - applyTestFailureFeedback(resolveConnectionTestFailureFeedback({ - kind: 'secret_blocked', - reason: blockingSecretClearMessage, - fallback: '连接参数不完整', - })); - return; - } - setLoading(true); - setTestResult(null); - const config = await buildConfig(values, false); - if (initialValues?.id) { - config.id = initialValues.id; - } - const timeoutSecondsRaw = Number(values.timeout); - const timeoutSeconds = Number.isFinite(timeoutSecondsRaw) && timeoutSecondsRaw > 0 - ? Math.min(timeoutSecondsRaw, MAX_TIMEOUT_SECONDS) - : 30; - const rpcTimeoutMs = (timeoutSeconds + 5) * 1000; - - // Use different API for Redis / JVM - const isRedisType = values.type === 'redis'; - const isJVMType = values.type === 'jvm'; - const res = await withClientTimeout( - isJVMType - ? TestJVMConnection(config as any) - : isRedisType - ? RedisConnect(config as any) - : TestConnection(config as any), - rpcTimeoutMs, - `连接测试超时(>${timeoutSeconds} 秒),请检查网络/代理/SSH配置后重试` - ); - - if (res.success) { - void message.destroy('connection-test-failure'); - setTestResult({ type: 'success', message: res.message }); - if (isRedisType) { - setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); - } else if (!isJVMType) { - // Other databases: fetch database list - const dbRes = await withClientTimeout( - DBGetDatabases(config as any), - rpcTimeoutMs, - `连接成功但拉取数据库列表超时(>${timeoutSeconds} 秒)` - ); - if (dbRes.success) { - const dbRows = Array.isArray(dbRes.data) ? dbRes.data : []; - const dbs = dbRows - .map((row: any) => row?.Database || row?.database) - .filter((name: any) => typeof name === 'string' && name.trim() !== ''); - setDbList(dbs); - if (dbs.length === 0) { - message.warning(values.type === 'dameng' - ? '连接成功,但未获取到可见 schema;请检查当前账号权限或默认 schema 配置' - : '连接成功,但未获取到可见数据库列表'); - } - } else { - setDbList([]); - message.warning(`连接成功,但获取数据库列表失败:${normalizeConnectionSecretErrorMessage(dbRes.message, '未知错误')}`); - } - } - } else { - applyTestFailureFeedback(resolveConnectionTestFailureFeedback({ - kind: 'runtime', - reason: res?.message, - fallback: '连接被拒绝或参数无效,请检查后重试', - })); - } - } catch (e: unknown) { - if (e && typeof e === 'object' && 'errorFields' in e) { - applyTestFailureFeedback(resolveConnectionTestFailureFeedback({ - kind: 'validation', - reason: '', - fallback: '请先完善必填项后再测试连接', - })); - return; - } - const reason = e instanceof Error - ? e.message - : (typeof e === 'string' ? e : '未知异常'); - applyTestFailureFeedback(resolveConnectionTestFailureFeedback({ - kind: 'runtime', - reason, - fallback: '未知异常', - })); - } finally { - testInFlightRef.current = false; - setLoading(false); + if (testInFlightRef.current) return; + testInFlightRef.current = true; + try { + await form.validateFields(); + const values = form.getFieldsValue(true); + const unavailableReason = await resolveDriverUnavailableReason( + values.type, + ); + if (unavailableReason) { + applyTestFailureFeedback( + resolveConnectionTestFailureFeedback({ + kind: "driver_unavailable", + reason: unavailableReason, + fallback: "驱动未安装启用", + }), + ); + promptInstallDriver(values.type, unavailableReason); + return; } + const blockingSecretClearMessage = getBlockingSecretClearMessage(values); + if (blockingSecretClearMessage) { + applyTestFailureFeedback( + resolveConnectionTestFailureFeedback({ + kind: "secret_blocked", + reason: blockingSecretClearMessage, + fallback: "连接参数不完整", + }), + ); + return; + } + setLoading(true); + setTestResult(null); + const config = await buildConfig(values, false); + if (initialValues?.id) { + config.id = initialValues.id; + } + const timeoutSecondsRaw = Number(values.timeout); + const timeoutSeconds = + Number.isFinite(timeoutSecondsRaw) && timeoutSecondsRaw > 0 + ? Math.min(timeoutSecondsRaw, MAX_TIMEOUT_SECONDS) + : 30; + const rpcTimeoutMs = (timeoutSeconds + 5) * 1000; + + // Use different API for Redis / JVM + const isRedisType = values.type === "redis"; + const isJVMType = values.type === "jvm"; + const res = await withClientTimeout( + isJVMType + ? TestJVMConnection(config as any) + : isRedisType + ? RedisConnect(config as any) + : TestConnection(config as any), + rpcTimeoutMs, + `连接测试超时(>${timeoutSeconds} 秒),请检查网络/代理/SSH配置后重试`, + ); + + if (res.success) { + void message.destroy("connection-test-failure"); + setTestResult({ type: "success", message: res.message }); + if (isRedisType) { + setRedisDbList(Array.from({ length: 16 }, (_, i) => i)); + } else if (!isJVMType) { + // Other databases: fetch database list + const dbRes = await withClientTimeout( + DBGetDatabases(config as any), + rpcTimeoutMs, + `连接成功但拉取数据库列表超时(>${timeoutSeconds} 秒)`, + ); + if (dbRes.success) { + const dbRows = Array.isArray(dbRes.data) ? dbRes.data : []; + const dbs = dbRows + .map((row: any) => row?.Database || row?.database) + .filter( + (name: any) => typeof name === "string" && name.trim() !== "", + ); + setDbList(dbs); + if (dbs.length === 0) { + message.warning( + values.type === "dameng" + ? "连接成功,但未获取到可见 schema;请检查当前账号权限或默认 schema 配置" + : "连接成功,但未获取到可见数据库列表", + ); + } + } else { + setDbList([]); + message.warning( + `连接成功,但获取数据库列表失败:${normalizeConnectionSecretErrorMessage(dbRes.message, "未知错误")}`, + ); + } + } + } else { + applyTestFailureFeedback( + resolveConnectionTestFailureFeedback({ + kind: "runtime", + reason: res?.message, + fallback: "连接被拒绝或参数无效,请检查后重试", + }), + ); + } + } catch (e: unknown) { + if (e && typeof e === "object" && "errorFields" in e) { + applyTestFailureFeedback( + resolveConnectionTestFailureFeedback({ + kind: "validation", + reason: "", + fallback: "请先完善必填项后再测试连接", + }), + ); + return; + } + const reason = + e instanceof Error ? e.message : typeof e === "string" ? e : "未知异常"; + applyTestFailureFeedback( + resolveConnectionTestFailureFeedback({ + kind: "runtime", + reason, + fallback: "未知异常", + }), + ); + } finally { + testInFlightRef.current = false; + setLoading(false); + } }; const handleDiscoverMongoMembers = async () => { - if (discoveringMembers || dbType !== 'mongodb') { - return; + if (discoveringMembers || dbType !== "mongodb") { + return; + } + try { + await form.validateFields(); + const values = form.getFieldsValue(true); + setDiscoveringMembers(true); + const blockingSecretClearMessage = getBlockingSecretClearMessage(values); + if (blockingSecretClearMessage) { + message.error(blockingSecretClearMessage); + return; } - try { - await form.validateFields(); - const values = form.getFieldsValue(true); - setDiscoveringMembers(true); - const blockingSecretClearMessage = getBlockingSecretClearMessage(values); - if (blockingSecretClearMessage) { - message.error(blockingSecretClearMessage); - return; - } - const config = await buildConfig(values, false); - if (initialValues?.id) { - config.id = initialValues.id; - } - const result = await MongoDiscoverMembers(config as any); - if (!result.success) { - message.error(normalizeConnectionSecretErrorMessage(result.message, '成员发现失败')); - return; - } - const data = (result.data as Record) || {}; - const membersRaw = Array.isArray(data.members) ? data.members : []; - const members: MongoMemberInfo[] = membersRaw - .map((item: any) => ({ - host: String(item.host || '').trim(), - role: String(item.role || item.state || 'UNKNOWN').trim(), - state: String(item.state || item.role || 'UNKNOWN').trim(), - stateCode: Number(item.stateCode || 0), - healthy: !!item.healthy, - isSelf: !!item.isSelf, - })) - .filter((item: MongoMemberInfo) => !!item.host); - setMongoMembers(members); - if (!form.getFieldValue('mongoReplicaSet') && data.replicaSet) { - form.setFieldValue('mongoReplicaSet', String(data.replicaSet)); - } - message.success(result.message || `发现 ${members.length} 个成员`); - } catch (error: any) { - message.error(normalizeConnectionSecretErrorMessage(error?.message || error, '成员发现失败')); - } finally { - setDiscoveringMembers(false); + const config = await buildConfig(values, false); + if (initialValues?.id) { + config.id = initialValues.id; } + const result = await MongoDiscoverMembers(config as any); + if (!result.success) { + message.error( + normalizeConnectionSecretErrorMessage(result.message, "成员发现失败"), + ); + return; + } + const data = (result.data as Record) || {}; + const membersRaw = Array.isArray(data.members) ? data.members : []; + const members: MongoMemberInfo[] = membersRaw + .map((item: any) => ({ + host: String(item.host || "").trim(), + role: String(item.role || item.state || "UNKNOWN").trim(), + state: String(item.state || item.role || "UNKNOWN").trim(), + stateCode: Number(item.stateCode || 0), + healthy: !!item.healthy, + isSelf: !!item.isSelf, + })) + .filter((item: MongoMemberInfo) => !!item.host); + setMongoMembers(members); + if (!form.getFieldValue("mongoReplicaSet") && data.replicaSet) { + form.setFieldValue("mongoReplicaSet", String(data.replicaSet)); + } + message.success(result.message || `发现 ${members.length} 个成员`); + } catch (error: any) { + message.error( + normalizeConnectionSecretErrorMessage( + error?.message || error, + "成员发现失败", + ), + ); + } finally { + setDiscoveringMembers(false); + } }; - const buildConfig = async (values: any, forPersist: boolean): Promise => { - const mergedValues = { ...values }; - if (String(mergedValues.type || '').trim().toLowerCase() === 'jvm') { - if (hasUnsupportedJVMEditableModes({ - allowedModes: mergedValues.jvmAllowedModes, - preferredMode: mergedValues.jvmPreferredMode, - })) { - throw new Error('当前连接包含未支持的 JVM 模式;请先调整为 JMX 或 Endpoint 后再测试或保存'); - } - const resolvedJvmAllowedModes = normalizeEditableJVMModes(mergedValues.jvmAllowedModes); - const resolvedJvmTimeout = Number(mergedValues.timeout || 30); - const preferredJvmMode = String(mergedValues.jvmPreferredMode || '').trim().toLowerCase(); - const resolvedJvmPreferredMode = resolvedJvmAllowedModes.find((mode) => mode === preferredJvmMode) || resolvedJvmAllowedModes[0]; - return buildJVMConnectionConfig({ - ...buildDefaultJVMConnectionValues(), - ...mergedValues, - jvmAllowedModes: resolvedJvmAllowedModes, - jvmPreferredMode: resolvedJvmPreferredMode, - jvmEndpointEnabled: resolvedJvmAllowedModes.includes('endpoint'), - timeout: resolvedJvmTimeout, - jvmEndpointTimeoutSeconds: resolvedJvmTimeout, - }); + const buildConfig = async ( + values: any, + forPersist: boolean, + ): Promise => { + const mergedValues = { ...values }; + if ( + String(mergedValues.type || "") + .trim() + .toLowerCase() === "jvm" + ) { + if ( + hasUnsupportedJVMEditableModes({ + allowedModes: mergedValues.jvmAllowedModes, + preferredMode: mergedValues.jvmPreferredMode, + }) + ) { + throw new Error( + "当前连接包含未支持的 JVM 模式;请先调整为 JMX、Endpoint 或 Agent 后再测试或保存", + ); } - const parsedUriValues = parseUriToValues(mergedValues.uri, mergedValues.type); - const isEmptyField = (value: unknown) => ( - value === undefined - || value === null - || value === '' - || value === 0 - || (Array.isArray(value) && value.length === 0) + if ( + hasUnsupportedJVMDiagnosticTransport( + mergedValues.jvmDiagnosticTransport, + ) + ) { + throw new Error( + "当前连接包含未支持的 JVM 诊断 transport;请先调整为 agent-bridge 或 arthas-tunnel 后再测试或保存", + ); + } + const existingDiagnostic = initialValues?.config?.jvm?.diagnostic; + if ( + mergedValues.jvmDiagnosticEnabled === undefined && + existingDiagnostic?.enabled !== undefined + ) { + mergedValues.jvmDiagnosticEnabled = existingDiagnostic.enabled; + } + if ( + String(mergedValues.jvmDiagnosticTransport || "").trim() === "" && + existingDiagnostic?.transport + ) { + mergedValues.jvmDiagnosticTransport = existingDiagnostic.transport; + } + if ( + String(mergedValues.jvmDiagnosticBaseUrl || "").trim() === "" && + existingDiagnostic?.baseUrl + ) { + mergedValues.jvmDiagnosticBaseUrl = existingDiagnostic.baseUrl; + } + if ( + String(mergedValues.jvmDiagnosticTargetId || "").trim() === "" && + existingDiagnostic?.targetId + ) { + mergedValues.jvmDiagnosticTargetId = existingDiagnostic.targetId; + } + if ( + String(mergedValues.jvmDiagnosticApiKey || "").trim() === "" && + existingDiagnostic?.apiKey + ) { + mergedValues.jvmDiagnosticApiKey = existingDiagnostic.apiKey; + } + if ( + mergedValues.jvmDiagnosticAllowObserveCommands === undefined && + existingDiagnostic?.allowObserveCommands !== undefined + ) { + mergedValues.jvmDiagnosticAllowObserveCommands = + existingDiagnostic.allowObserveCommands; + } + if ( + mergedValues.jvmDiagnosticAllowTraceCommands === undefined && + existingDiagnostic?.allowTraceCommands !== undefined + ) { + mergedValues.jvmDiagnosticAllowTraceCommands = + existingDiagnostic.allowTraceCommands; + } + if ( + mergedValues.jvmDiagnosticAllowMutatingCommands === undefined && + existingDiagnostic?.allowMutatingCommands !== undefined + ) { + mergedValues.jvmDiagnosticAllowMutatingCommands = + existingDiagnostic.allowMutatingCommands; + } + if ( + (mergedValues.jvmDiagnosticTimeoutSeconds === undefined || + mergedValues.jvmDiagnosticTimeoutSeconds === null || + mergedValues.jvmDiagnosticTimeoutSeconds === "") && + Number(existingDiagnostic?.timeoutSeconds) > 0 + ) { + mergedValues.jvmDiagnosticTimeoutSeconds = Number( + existingDiagnostic?.timeoutSeconds, + ); + } + const resolvedJvmAllowedModes = normalizeEditableJVMModes( + mergedValues.jvmAllowedModes, ); - if (parsedUriValues) { - Object.entries(parsedUriValues).forEach(([key, value]) => { - if (isEmptyField((mergedValues as any)[key])) { - (mergedValues as any)[key] = value; - } - }); - } + const resolvedJvmTimeout = Number(mergedValues.timeout || 30); + const preferredJvmMode = String(mergedValues.jvmPreferredMode || "") + .trim() + .toLowerCase(); + const resolvedJvmPreferredMode = + resolvedJvmAllowedModes.find((mode) => mode === preferredJvmMode) || + resolvedJvmAllowedModes[0]; + return buildJVMConnectionConfig({ + ...buildDefaultJVMConnectionValues(), + ...mergedValues, + jvmAllowedModes: resolvedJvmAllowedModes, + jvmPreferredMode: resolvedJvmPreferredMode, + jvmEndpointEnabled: resolvedJvmAllowedModes.includes("endpoint"), + jvmAgentEnabled: resolvedJvmAllowedModes.includes("agent"), + timeout: resolvedJvmTimeout, + jvmEndpointTimeoutSeconds: resolvedJvmTimeout, + }); + } + const parsedUriValues = parseUriToValues( + mergedValues.uri, + mergedValues.type, + ); + const isEmptyField = (value: unknown) => + value === undefined || + value === null || + value === "" || + value === 0 || + (Array.isArray(value) && value.length === 0); + if (parsedUriValues) { + Object.entries(parsedUriValues).forEach(([key, value]) => { + if (isEmptyField((mergedValues as any)[key])) { + (mergedValues as any)[key] = value; + } + }); + } - const type = String(mergedValues.type || '').toLowerCase(); - const defaultPort = getDefaultPortByType(type); - const isFileDbType = isFileDatabaseType(type); - const sslCapableType = supportsSSLForType(type); + const type = String(mergedValues.type || "").toLowerCase(); + const defaultPort = getDefaultPortByType(type); + const isFileDbType = isFileDatabaseType(type); + const sslCapableType = supportsSSLForType(type); - // Redis 默认不展示用户名字段;若 URI 可解析则以 URI 为准覆盖 user, - // 同时清理历史默认值 root,避免 go-redis 发送 ACL AUTH(user, pass) 导致 WRONGPASS。 - if (type === 'redis') { - if (parsedUriValues && Object.prototype.hasOwnProperty.call(parsedUriValues, 'user')) { - mergedValues.user = String((parsedUriValues as any).user || ''); - } else if (String(mergedValues.user || '').trim() === 'root') { - mergedValues.user = ''; - } - } - const sslModeRaw = String(mergedValues.sslMode || 'preferred').trim().toLowerCase(); - const sslMode: 'preferred' | 'required' | 'skip-verify' | 'disable' = sslModeRaw === 'required' - ? 'required' - : sslModeRaw === 'skip-verify' - ? 'skip-verify' - : sslModeRaw === 'disable' - ? 'disable' - : 'preferred'; - const effectiveUseSSL = sslCapableType && !!mergedValues.useSSL; - const sslCertPath = sslCapableType ? String(mergedValues.sslCertPath || '').trim() : ''; - const sslKeyPath = sslCapableType ? String(mergedValues.sslKeyPath || '').trim() : ''; - if (type === 'dameng' && effectiveUseSSL && (!sslCertPath || !sslKeyPath)) { - throw new Error('达梦启用 SSL 时必须填写证书路径与私钥路径'); + // Redis 默认不展示用户名字段;若 URI 可解析则以 URI 为准覆盖 user, + // 同时清理历史默认值 root,避免 go-redis 发送 ACL AUTH(user, pass) 导致 WRONGPASS。 + if (type === "redis") { + if ( + parsedUriValues && + Object.prototype.hasOwnProperty.call(parsedUriValues, "user") + ) { + mergedValues.user = String((parsedUriValues as any).user || ""); + } else if (String(mergedValues.user || "").trim() === "root") { + mergedValues.user = ""; } + } + const sslModeRaw = String(mergedValues.sslMode || "preferred") + .trim() + .toLowerCase(); + const sslMode: "preferred" | "required" | "skip-verify" | "disable" = + sslModeRaw === "required" + ? "required" + : sslModeRaw === "skip-verify" + ? "skip-verify" + : sslModeRaw === "disable" + ? "disable" + : "preferred"; + const effectiveUseSSL = sslCapableType && !!mergedValues.useSSL; + const sslCertPath = sslCapableType + ? String(mergedValues.sslCertPath || "").trim() + : ""; + const sslKeyPath = sslCapableType + ? String(mergedValues.sslKeyPath || "").trim() + : ""; + if (type === "dameng" && effectiveUseSSL && (!sslCertPath || !sslKeyPath)) { + throw new Error("达梦启用 SSL 时必须填写证书路径与私钥路径"); + } - let primaryHost = 'localhost'; - let primaryPort = defaultPort; - if (isFileDbType) { - // 文件型数据库(sqlite/duckdb)这里的 host 即数据库文件路径,不应参与 host:port 拼接与解析。 - primaryHost = normalizeFileDbPath(String(mergedValues.host || '').trim()); - primaryPort = 0; + let primaryHost = "localhost"; + let primaryPort = defaultPort; + if (isFileDbType) { + // 文件型数据库(sqlite/duckdb)这里的 host 即数据库文件路径,不应参与 host:port 拼接与解析。 + primaryHost = normalizeFileDbPath(String(mergedValues.host || "").trim()); + primaryPort = 0; + } else { + const parsedPrimary = parseHostPort( + toAddress( + mergedValues.host || "localhost", + Number(mergedValues.port || defaultPort), + defaultPort, + ), + defaultPort, + ); + primaryHost = parsedPrimary?.host || "localhost"; + primaryPort = parsedPrimary?.port || defaultPort; + } + + let hosts: string[] = []; + let topology: "single" | "replica" | "cluster" | undefined; + let replicaSet = ""; + let authSource = ""; + let readPreference = ""; + let mysqlReplicaUser = ""; + let mysqlReplicaPassword = ""; + let mongoSrvEnabled = false; + let mongoAuthMechanism = ""; + let mongoReplicaUser = ""; + let mongoReplicaPassword = ""; + const savePassword = + type === "mongodb" ? mergedValues.savePassword !== false : true; + + if ( + type === "mysql" || + type === "mariadb" || + type === "diros" || + type === "sphinx" + ) { + const replicas = + mergedValues.mysqlTopology === "replica" + ? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort) + : []; + const allHosts = normalizeAddressList( + [`${primaryHost}:${primaryPort}`, ...replicas], + defaultPort, + ); + if (mergedValues.mysqlTopology === "replica" || allHosts.length > 1) { + hosts = allHosts; + topology = "replica"; + mysqlReplicaUser = String(mergedValues.mysqlReplicaUser || "").trim(); + mysqlReplicaPassword = String(mergedValues.mysqlReplicaPassword || ""); } else { - const parsedPrimary = parseHostPort( - toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort), - defaultPort - ); - primaryHost = parsedPrimary?.host || 'localhost'; - primaryPort = parsedPrimary?.port || defaultPort; + topology = "single"; } + } - let hosts: string[] = []; - let topology: 'single' | 'replica' | 'cluster' | undefined; - let replicaSet = ''; - let authSource = ''; - let readPreference = ''; - let mysqlReplicaUser = ''; - let mysqlReplicaPassword = ''; - let mongoSrvEnabled = false; - let mongoAuthMechanism = ''; - let mongoReplicaUser = ''; - let mongoReplicaPassword = ''; - const savePassword = type === 'mongodb' - ? mergedValues.savePassword !== false - : true; - - if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') { - const replicas = mergedValues.mysqlTopology === 'replica' - ? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort) - : []; - const allHosts = normalizeAddressList([`${primaryHost}:${primaryPort}`, ...replicas], defaultPort); - if (mergedValues.mysqlTopology === 'replica' || allHosts.length > 1) { - hosts = allHosts; - topology = 'replica'; - mysqlReplicaUser = String(mergedValues.mysqlReplicaUser || '').trim(); - mysqlReplicaPassword = String(mergedValues.mysqlReplicaPassword || ''); - } else { - topology = 'single'; - } + if (type === "mongodb") { + mongoSrvEnabled = !!mergedValues.mongoSrv; + const extraHosts = + mergedValues.mongoTopology === "replica" + ? mongoSrvEnabled + ? normalizeMongoSrvHostList(mergedValues.mongoHosts, defaultPort) + : normalizeAddressList(mergedValues.mongoHosts, defaultPort) + : []; + const primarySeed = mongoSrvEnabled + ? primaryHost + : `${primaryHost}:${primaryPort}`; + const allHosts = mongoSrvEnabled + ? normalizeMongoSrvHostList([primarySeed, ...extraHosts], defaultPort) + : normalizeAddressList([primarySeed, ...extraHosts], defaultPort); + if ( + mergedValues.mongoTopology === "replica" || + allHosts.length > 1 || + mergedValues.mongoReplicaSet + ) { + hosts = allHosts; + topology = "replica"; + mongoReplicaUser = String(mergedValues.mongoReplicaUser || "").trim(); + mongoReplicaPassword = String(mergedValues.mongoReplicaPassword || ""); + } else { + topology = "single"; } + replicaSet = String(mergedValues.mongoReplicaSet || "").trim(); + authSource = String( + mergedValues.mongoAuthSource || mergedValues.database || "admin", + ).trim(); + readPreference = String( + mergedValues.mongoReadPreference || "primary", + ).trim(); + mongoAuthMechanism = String(mergedValues.mongoAuthMechanism || "") + .trim() + .toUpperCase(); + } - if (type === 'mongodb') { - mongoSrvEnabled = !!mergedValues.mongoSrv; - const extraHosts = mergedValues.mongoTopology === 'replica' - ? (mongoSrvEnabled - ? normalizeMongoSrvHostList(mergedValues.mongoHosts, defaultPort) - : normalizeAddressList(mergedValues.mongoHosts, defaultPort)) - : []; - const primarySeed = mongoSrvEnabled ? primaryHost : `${primaryHost}:${primaryPort}`; - const allHosts = mongoSrvEnabled - ? normalizeMongoSrvHostList([primarySeed, ...extraHosts], defaultPort) - : normalizeAddressList([primarySeed, ...extraHosts], defaultPort); - if (mergedValues.mongoTopology === 'replica' || allHosts.length > 1 || mergedValues.mongoReplicaSet) { - hosts = allHosts; - topology = 'replica'; - mongoReplicaUser = String(mergedValues.mongoReplicaUser || '').trim(); - mongoReplicaPassword = String(mergedValues.mongoReplicaPassword || ''); - } else { - topology = 'single'; - } - replicaSet = String(mergedValues.mongoReplicaSet || '').trim(); - authSource = String(mergedValues.mongoAuthSource || mergedValues.database || 'admin').trim(); - readPreference = String(mergedValues.mongoReadPreference || 'primary').trim(); - mongoAuthMechanism = String(mergedValues.mongoAuthMechanism || '').trim().toUpperCase(); + if (type === "redis") { + const clusterNodes = + mergedValues.redisTopology === "cluster" + ? normalizeAddressList(mergedValues.redisHosts, defaultPort) + : []; + const allHosts = normalizeAddressList( + [`${primaryHost}:${primaryPort}`, ...clusterNodes], + defaultPort, + ); + if (mergedValues.redisTopology === "cluster" || allHosts.length > 1) { + hosts = allHosts; + topology = "cluster"; + } else { + topology = "single"; } + mergedValues.redisDB = Number.isFinite(Number(mergedValues.redisDB)) + ? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB)))) + : 0; + } - if (type === 'redis') { - const clusterNodes = mergedValues.redisTopology === 'cluster' - ? normalizeAddressList(mergedValues.redisHosts, defaultPort) - : []; - const allHosts = normalizeAddressList([`${primaryHost}:${primaryPort}`, ...clusterNodes], defaultPort); - if (mergedValues.redisTopology === 'cluster' || allHosts.length > 1) { - hosts = allHosts; - topology = 'cluster'; - } else { - topology = 'single'; - } - mergedValues.redisDB = Number.isFinite(Number(mergedValues.redisDB)) - ? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB)))) - : 0; - } - - const sshConfig = mergedValues.useSSH ? { + const sshConfig = mergedValues.useSSH + ? { host: mergedValues.sshHost, port: Number(mergedValues.sshPort), user: mergedValues.sshUser, password: mergedValues.sshPassword || "", - keyPath: mergedValues.sshKeyPath || "" - } : { host: "", port: 22, user: "", password: "", keyPath: "" }; - const effectiveUseHttpTunnel = !isFileDbType && !!mergedValues.useHttpTunnel; - const effectiveUseProxy = !isFileDbType && !!mergedValues.useProxy && !effectiveUseHttpTunnel; - const proxyTypeRaw = String(mergedValues.proxyType || 'socks5').toLowerCase(); - const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5'; - const proxyConfig: NonNullable = effectiveUseProxy ? { - type: proxyType, - host: String(mergedValues.proxyHost || '').trim(), - port: Number(mergedValues.proxyPort || (proxyTypeRaw === 'http' ? 8080 : 1080)), - user: String(mergedValues.proxyUser || '').trim(), - password: mergedValues.proxyPassword || "", - } : { - type: 'socks5', - host: '', - port: 1080, - user: '', - password: '', - }; - const httpTunnelConfig: NonNullable = effectiveUseHttpTunnel ? { - host: String(mergedValues.httpTunnelHost || '').trim(), - port: Number(mergedValues.httpTunnelPort || 8080), - user: String(mergedValues.httpTunnelUser || '').trim(), - password: mergedValues.httpTunnelPassword || "", - } : { - host: '', - port: 8080, - user: '', - password: '', - }; - if (effectiveUseHttpTunnel) { - if (!httpTunnelConfig.host) { - throw new Error('HTTP 隧道主机不能为空'); + keyPath: mergedValues.sshKeyPath || "", + } + : { host: "", port: 22, user: "", password: "", keyPath: "" }; + const effectiveUseHttpTunnel = + !isFileDbType && !!mergedValues.useHttpTunnel; + const effectiveUseProxy = + !isFileDbType && !!mergedValues.useProxy && !effectiveUseHttpTunnel; + const proxyTypeRaw = String( + mergedValues.proxyType || "socks5", + ).toLowerCase(); + const proxyType: "socks5" | "http" = + proxyTypeRaw === "http" ? "http" : "socks5"; + const proxyConfig: NonNullable = + effectiveUseProxy + ? { + type: proxyType, + host: String(mergedValues.proxyHost || "").trim(), + port: Number( + mergedValues.proxyPort || (proxyTypeRaw === "http" ? 8080 : 1080), + ), + user: String(mergedValues.proxyUser || "").trim(), + password: mergedValues.proxyPassword || "", } - if (!Number.isFinite(httpTunnelConfig.port) || httpTunnelConfig.port <= 0 || httpTunnelConfig.port > 65535) { - throw new Error('HTTP 隧道端口必须在 1-65535 之间'); + : { + type: "socks5", + host: "", + port: 1080, + user: "", + password: "", + }; + const httpTunnelConfig: NonNullable = + effectiveUseHttpTunnel + ? { + host: String(mergedValues.httpTunnelHost || "").trim(), + port: Number(mergedValues.httpTunnelPort || 8080), + user: String(mergedValues.httpTunnelUser || "").trim(), + password: mergedValues.httpTunnelPassword || "", } + : { + host: "", + port: 8080, + user: "", + password: "", + }; + if (effectiveUseHttpTunnel) { + if (!httpTunnelConfig.host) { + throw new Error("HTTP 隧道主机不能为空"); } + if ( + !Number.isFinite(httpTunnelConfig.port) || + httpTunnelConfig.port <= 0 || + httpTunnelConfig.port > 65535 + ) { + throw new Error("HTTP 隧道端口必须在 1-65535 之间"); + } + } - const keepPassword = !forPersist || savePassword; + const keepPassword = !forPersist || savePassword; - return { - type: mergedValues.type, - host: primaryHost, - port: Number(primaryPort || 0), - user: mergedValues.user || "", - password: keepPassword ? (mergedValues.password || "") : "", - savePassword: savePassword, - database: mergedValues.database || "", - useSSL: effectiveUseSSL, - sslMode: effectiveUseSSL ? sslMode : 'disable', - sslCertPath: sslCertPath, - sslKeyPath: sslKeyPath, - useSSH: !!mergedValues.useSSH, - ssh: sshConfig, - useProxy: effectiveUseProxy, - proxy: proxyConfig, - useHttpTunnel: effectiveUseHttpTunnel, - httpTunnel: httpTunnelConfig, - driver: mergedValues.driver, - dsn: mergedValues.dsn, - timeout: Number(mergedValues.timeout || 30), - redisDB: Number.isFinite(Number(mergedValues.redisDB)) - ? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB)))) - : 0, - uri: String(mergedValues.uri || '').trim(), - hosts: hosts, - topology: topology, - mysqlReplicaUser: mysqlReplicaUser, - mysqlReplicaPassword: keepPassword ? mysqlReplicaPassword : "", - replicaSet: replicaSet, - authSource: authSource, - readPreference: readPreference, - mongoSrv: mongoSrvEnabled, - mongoAuthMechanism: mongoAuthMechanism, - mongoReplicaUser: mongoReplicaUser, - mongoReplicaPassword: keepPassword ? mongoReplicaPassword : "", - }; + return { + type: mergedValues.type, + host: primaryHost, + port: Number(primaryPort || 0), + user: mergedValues.user || "", + password: keepPassword ? mergedValues.password || "" : "", + savePassword: savePassword, + database: mergedValues.database || "", + useSSL: effectiveUseSSL, + sslMode: effectiveUseSSL ? sslMode : "disable", + sslCertPath: sslCertPath, + sslKeyPath: sslKeyPath, + useSSH: !!mergedValues.useSSH, + ssh: sshConfig, + useProxy: effectiveUseProxy, + proxy: proxyConfig, + useHttpTunnel: effectiveUseHttpTunnel, + httpTunnel: httpTunnelConfig, + driver: mergedValues.driver, + dsn: mergedValues.dsn, + timeout: Number(mergedValues.timeout || 30), + redisDB: Number.isFinite(Number(mergedValues.redisDB)) + ? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB)))) + : 0, + uri: String(mergedValues.uri || "").trim(), + hosts: hosts, + topology: topology, + mysqlReplicaUser: mysqlReplicaUser, + mysqlReplicaPassword: keepPassword ? mysqlReplicaPassword : "", + replicaSet: replicaSet, + authSource: authSource, + readPreference: readPreference, + mongoSrv: mongoSrvEnabled, + mongoAuthMechanism: mongoAuthMechanism, + mongoReplicaUser: mongoReplicaUser, + mongoReplicaPassword: keepPassword ? mongoReplicaPassword : "", + }; }; const handleTypeSelect = (type: string) => { - const normalized = normalizeDriverType(type); - const snapshot = driverStatusMap[normalized]; - if (snapshot && !snapshot.connectable) { - const driverName = snapshot.name || type; - const reason = snapshot.message || `${driverName} 驱动未安装启用,请先在驱动管理中安装`; - setTypeSelectWarning({ driverName, reason }); - return; - } - setTypeSelectWarning(null); - setDbType(type); - form.setFieldsValue({ type: type }); + const normalized = normalizeDriverType(type); + const snapshot = driverStatusMap[normalized]; + if (snapshot && !snapshot.connectable) { + const driverName = snapshot.name || type; + const reason = + snapshot.message || + `${driverName} 驱动未安装启用,请先在驱动管理中安装`; + setTypeSelectWarning({ driverName, reason }); + return; + } + setTypeSelectWarning(null); + setDbType(type); + form.setFieldsValue({ type: type }); - const defaultPort = getDefaultPortByType(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); - setUseHttpTunnel(false); - form.setFieldsValue({ - host: '', - port: 0, - user: '', - password: '', - database: '', - useSSL: false, - sslMode: 'preferred', - sslCertPath: '', - sslKeyPath: '', - useSSH: false, - sshHost: '', - sshPort: 22, - sshUser: '', - sshPassword: '', - sshKeyPath: '', - useProxy: false, - proxyType: 'socks5', - proxyHost: '', - proxyPort: 1080, - proxyUser: '', - proxyPassword: '', - useHttpTunnel: false, - httpTunnelHost: '', - httpTunnelPort: 8080, - httpTunnelUser: '', - httpTunnelPassword: '', - mysqlTopology: 'single', - redisTopology: 'single', - mongoTopology: 'single', - mongoSrv: false, - mongoReadPreference: 'primary', - mongoReplicaSet: '', - mongoAuthSource: '', - mongoAuthMechanism: '', - savePassword: true, - mysqlReplicaHosts: [], - redisHosts: [], - mongoHosts: [], - mysqlReplicaUser: '', - mysqlReplicaPassword: '', - mongoReplicaUser: '', - mongoReplicaPassword: '', - redisDB: 0, - }); - } else if (type !== 'custom') { - const defaultUser = type === 'clickhouse' - ? 'default' - : type === 'redis' - ? '' - : 'root'; - const sslCapableType = supportsSSLForType(type); - setUseSSL(false); - setUseHttpTunnel(false); - form.setFieldsValue({ - user: defaultUser, - database: '', - port: defaultPort, - useSSL: sslCapableType ? false : undefined, - sslMode: sslCapableType ? 'preferred' : undefined, - sslCertPath: sslCapableType ? '' : undefined, - sslKeyPath: sslCapableType ? '' : undefined, - useHttpTunnel: false, - httpTunnelHost: '', - httpTunnelPort: 8080, - httpTunnelUser: '', - httpTunnelPassword: '', - mysqlTopology: 'single', - redisTopology: 'single', - mongoTopology: 'single', - mongoSrv: false, - mongoReadPreference: 'primary', - mongoReplicaSet: '', - mongoAuthSource: '', - mongoAuthMechanism: '', - savePassword: true, - mysqlReplicaHosts: [], - redisHosts: [], - mongoHosts: [], - mysqlReplicaUser: '', - mysqlReplicaPassword: '', - mongoReplicaUser: '', - mongoReplicaPassword: '', - redisDB: 0, - }); - } + const defaultPort = getDefaultPortByType(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: "", + jvmAgentEnabled: false, + jvmAgentBaseUrl: "", + jvmAgentApiKey: "", + }); + } else if (isFileDatabaseType(type)) { + setUseSSL(false); + setUseSSH(false); + setUseProxy(false); + setUseHttpTunnel(false); + form.setFieldsValue({ + host: "", + port: 0, + user: "", + password: "", + database: "", + useSSL: false, + sslMode: "preferred", + sslCertPath: "", + sslKeyPath: "", + useSSH: false, + sshHost: "", + sshPort: 22, + sshUser: "", + sshPassword: "", + sshKeyPath: "", + useProxy: false, + proxyType: "socks5", + proxyHost: "", + proxyPort: 1080, + proxyUser: "", + proxyPassword: "", + useHttpTunnel: false, + httpTunnelHost: "", + httpTunnelPort: 8080, + httpTunnelUser: "", + httpTunnelPassword: "", + mysqlTopology: "single", + redisTopology: "single", + mongoTopology: "single", + mongoSrv: false, + mongoReadPreference: "primary", + mongoReplicaSet: "", + mongoAuthSource: "", + mongoAuthMechanism: "", + savePassword: true, + mysqlReplicaHosts: [], + redisHosts: [], + mongoHosts: [], + mysqlReplicaUser: "", + mysqlReplicaPassword: "", + mongoReplicaUser: "", + mongoReplicaPassword: "", + redisDB: 0, + }); + } else if (type !== "custom") { + const defaultUser = + type === "clickhouse" ? "default" : type === "redis" ? "" : "root"; + const sslCapableType = supportsSSLForType(type); + setUseSSL(false); + setUseHttpTunnel(false); + form.setFieldsValue({ + user: defaultUser, + database: "", + port: defaultPort, + useSSL: sslCapableType ? false : undefined, + sslMode: sslCapableType ? "preferred" : undefined, + sslCertPath: sslCapableType ? "" : undefined, + sslKeyPath: sslCapableType ? "" : undefined, + useHttpTunnel: false, + httpTunnelHost: "", + httpTunnelPort: 8080, + httpTunnelUser: "", + httpTunnelPassword: "", + mysqlTopology: "single", + redisTopology: "single", + mongoTopology: "single", + mongoSrv: false, + mongoReadPreference: "primary", + mongoReplicaSet: "", + mongoAuthSource: "", + mongoAuthMechanism: "", + savePassword: true, + mysqlReplicaHosts: [], + redisHosts: [], + mongoHosts: [], + mysqlReplicaUser: "", + mysqlReplicaPassword: "", + mongoReplicaUser: "", + mongoReplicaPassword: "", + redisDB: 0, + }); + } - setMongoMembers([]); - setStep(2); + setMongoMembers([]); + setStep(2); - if (!driverStatusLoaded || !snapshot) { - void refreshDriverStatus(); - } + if (!driverStatusLoaded || !snapshot) { + void refreshDriverStatus(); + } }; const isFileDb = isFileDatabaseType(dbType); - const isCustom = dbType === 'custom'; - const isRedis = dbType === 'redis'; - const isJVM = dbType === 'jvm'; - const unsupportedJvmModeMessage = isJVM && hasUnsupportedJvmModeSelection - ? '当前连接包含未支持的 JVM 模式。此版本只支持 JMX / Endpoint,请先调整允许模式和首选模式后再继续。' - : ''; + const isCustom = dbType === "custom"; + const isRedis = dbType === "redis"; + const isJVM = dbType === "jvm"; + const unsupportedJvmModeMessage = + isJVM && hasUnsupportedJvmModeSelection + ? "当前连接包含未支持的 JVM 模式。此版本只支持 JMX / Endpoint / Agent,请先调整允许模式和首选模式后再继续。" + : ""; const currentDriverType = normalizeDriverType(dbType); const currentDriverSnapshot = driverStatusMap[currentDriverType]; - const currentDriverUnavailableReason = currentDriverType !== 'custom' - && currentDriverSnapshot - && !currentDriverSnapshot.connectable - ? (currentDriverSnapshot.message || `${currentDriverSnapshot.name || dbType} 驱动未安装启用`) - : ''; - const driverStatusChecking = currentDriverType !== 'custom' && !driverStatusLoaded && step === 2; + const currentDriverUnavailableReason = + currentDriverType !== "custom" && + currentDriverSnapshot && + !currentDriverSnapshot.connectable + ? currentDriverSnapshot.message || + `${currentDriverSnapshot.name || dbType} 驱动未安装启用` + : ""; + const driverStatusChecking = + currentDriverType !== "custom" && !driverStatusLoaded && step === 2; const dbTypeGroups = [ - { label: '关系型数据库', items: [ - { key: 'mysql', name: 'MySQL', icon: getDbIcon('mysql', undefined, 36) }, - { key: 'mariadb', name: 'MariaDB', icon: getDbIcon('mariadb', undefined, 36) }, - { key: 'diros', name: 'Doris', icon: getDbIcon('diros', undefined, 36) }, - { key: 'sphinx', name: 'Sphinx', icon: getDbIcon('sphinx', undefined, 36) }, - { key: 'clickhouse', name: 'ClickHouse', icon: getDbIcon('clickhouse', undefined, 36) }, - { key: 'postgres', name: 'PostgreSQL', icon: getDbIcon('postgres', undefined, 36) }, - { key: 'sqlserver', name: 'SQL Server', icon: getDbIcon('sqlserver', undefined, 36) }, - { key: 'sqlite', name: 'SQLite', icon: getDbIcon('sqlite', undefined, 36) }, - { key: 'duckdb', name: 'DuckDB', icon: getDbIcon('duckdb', undefined, 36) }, - { key: 'oracle', name: 'Oracle', icon: getDbIcon('oracle', undefined, 36) }, - ]}, - { label: '国产数据库', items: [ - { key: 'dameng', name: 'Dameng (达梦)', icon: getDbIcon('dameng', undefined, 36) }, - { key: 'kingbase', name: 'Kingbase (人大金仓)', icon: getDbIcon('kingbase', undefined, 36) }, - { key: 'highgo', name: 'HighGo (瀚高)', icon: getDbIcon('highgo', undefined, 36) }, - { key: 'vastbase', name: 'Vastbase (海量)', icon: getDbIcon('vastbase', undefined, 36) }, - ]}, - { label: 'NoSQL', items: [ - { key: 'mongodb', name: 'MongoDB', icon: getDbIcon('mongodb', undefined, 36) }, - { key: 'redis', name: 'Redis', icon: getDbIcon('redis', undefined, 36) }, - ]}, - { label: '时序数据库', items: [ - { 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) }, - ]}, + { + label: "关系型数据库", + items: [ + { + key: "mysql", + name: "MySQL", + icon: getDbIcon("mysql", undefined, 36), + }, + { + key: "mariadb", + name: "MariaDB", + icon: getDbIcon("mariadb", undefined, 36), + }, + { + key: "diros", + name: "Doris", + icon: getDbIcon("diros", undefined, 36), + }, + { + key: "sphinx", + name: "Sphinx", + icon: getDbIcon("sphinx", undefined, 36), + }, + { + key: "clickhouse", + name: "ClickHouse", + icon: getDbIcon("clickhouse", undefined, 36), + }, + { + key: "postgres", + name: "PostgreSQL", + icon: getDbIcon("postgres", undefined, 36), + }, + { + key: "sqlserver", + name: "SQL Server", + icon: getDbIcon("sqlserver", undefined, 36), + }, + { + key: "sqlite", + name: "SQLite", + icon: getDbIcon("sqlite", undefined, 36), + }, + { + key: "duckdb", + name: "DuckDB", + icon: getDbIcon("duckdb", undefined, 36), + }, + { + key: "oracle", + name: "Oracle", + icon: getDbIcon("oracle", undefined, 36), + }, + ], + }, + { + label: "国产数据库", + items: [ + { + key: "dameng", + name: "Dameng (达梦)", + icon: getDbIcon("dameng", undefined, 36), + }, + { + key: "kingbase", + name: "Kingbase (人大金仓)", + icon: getDbIcon("kingbase", undefined, 36), + }, + { + key: "highgo", + name: "HighGo (瀚高)", + icon: getDbIcon("highgo", undefined, 36), + }, + { + key: "vastbase", + name: "Vastbase (海量)", + icon: getDbIcon("vastbase", undefined, 36), + }, + ], + }, + { + label: "NoSQL", + items: [ + { + key: "mongodb", + name: "MongoDB", + icon: getDbIcon("mongodb", undefined, 36), + }, + { + key: "redis", + name: "Redis", + icon: getDbIcon("redis", undefined, 36), + }, + ], + }, + { + label: "时序数据库", + items: [ + { + 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), + }, + ], + }, ]; - const dbTypes = dbTypeGroups.flatMap(g => g.items); + const dbTypes = dbTypeGroups.flatMap((g) => g.items); const renderStep1 = () => ( -
-
-
选择数据源
-
先选择目标数据库或中间件类型,再进入详细连接参数配置。
-
- {typeSelectWarning && ( - - {typeSelectWarning.reason} - - - )} - onClose={() => setTypeSelectWarning(null)} - /> - )} -
- {/* 左侧分类导航 */} -
- {dbTypeGroups.map((group, idx) => ( -
setActiveGroup(idx)} - style={{ - padding: '10px 12px', - cursor: 'pointer', - borderRadius: 6, - marginBottom: 4, - background: activeGroup === idx ? step1SidebarActiveBg : 'transparent', - color: activeGroup === idx ? step1SidebarActiveColor : undefined, - fontWeight: activeGroup === idx ? 500 : 400, - transition: 'all 0.2s', - fontSize: 13, - }} +
+
+
+ 选择数据源 +
+
+ 先选择目标数据库或中间件类型,再进入详细连接参数配置。 +
+
+ {typeSelectWarning && ( + + {typeSelectWarning.reason} + + + } + onClose={() => setTypeSelectWarning(null)} + /> + )} +
+ {/* 左侧分类导航 */} +
+ {dbTypeGroups.map((group, idx) => ( +
setActiveGroup(idx)} + style={{ + padding: "10px 12px", + cursor: "pointer", + borderRadius: 6, + marginBottom: 4, + background: + activeGroup === idx ? step1SidebarActiveBg : "transparent", + color: + activeGroup === idx ? step1SidebarActiveColor : undefined, + fontWeight: activeGroup === idx ? 500 : 400, + transition: "all 0.2s", + fontSize: 13, + }} + > + {group.label} +
+ ))} +
+ {/* 右侧数据源卡片 */} +
+ + {dbTypeGroups[activeGroup]?.items.map((item) => ( + + { + void handleTypeSelect(item.key); + }} + style={{ + textAlign: "center", + cursor: "pointer", + height: 100, + }} + styles={{ + body: { + padding: "16px 8px", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100%", + }, + }} + > +
{item.icon}
+ - {group.label} -
- ))} -
- {/* 右侧数据源卡片 */} -
- - {dbTypeGroups[activeGroup]?.items.map(item => ( - - { void handleTypeSelect(item.key); }} - style={{ textAlign: 'center', cursor: 'pointer', height: 100 }} - styles={{ body: { padding: '16px 8px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' } }} - > -
{item.icon}
- {item.name} -
- - ))} -
-
-
+ {item.name} + + + + ))} + +
+
); const renderStep2 = () => { - const baseInfoSection = ( -
-
基础信息
-
常用参数集中在左侧,优先完成连接建立所需的最小输入。
+ const baseInfoSection = ( +
+
+ 基础信息 +
+
+ 常用参数集中在左侧,优先完成连接建立所需的最小输入。 +
- - + + + + + {!isCustom && !isJVM && ( + <> + + + + + + + + + {uriFeedback && ( + setUriFeedback(null)} + style={{ marginBottom: 16 }} + /> + )} + {renderStoredSecretControls({ + fieldName: "uri", + clearKey: "opaqueURI", + hasStoredSecret: initialValues?.hasOpaqueURI, + clearLabel: "清除已保存 URI", + description: + "当前已保存连接 URI。留空表示继续沿用,输入新值表示替换。", + })} + + )} + + {isCustom ? ( + <> + + + + + + + {renderStoredSecretControls({ + fieldName: "dsn", + clearKey: "opaqueDSN", + hasStoredSecret: initialValues?.hasOpaqueDSN, + clearLabel: "清除已保存 DSN", + description: + "当前已保存连接字符串。留空表示继续沿用,输入新值表示替换。", + })} + + ) : isJVM ? ( + <> + {unsupportedJvmModeMessage && ( + + )} +
+ + + + + +
- {!isCustom && !isJVM && ( - <> - - - - - - - - - {uriFeedback && ( - setUriFeedback(null)} - style={{ marginBottom: 16 }} - /> - )} - {renderStoredSecretControls({ - fieldName: 'uri', - clearKey: 'opaqueURI', - hasStoredSecret: initialValues?.hasOpaqueURI, - clearLabel: '清除已保存 URI', - description: '当前已保存连接 URI。留空表示继续沿用,输入新值表示替换。', - })} - - )} + + - - - - - {renderStoredSecretControls({ - fieldName: 'dsn', - clearKey: 'opaqueDSN', - hasStoredSecret: initialValues?.hasOpaqueDSN, - clearLabel: '清除已保存 DSN', - description: '当前已保存连接字符串。留空表示继续沿用,输入新值表示替换。', - })} - - ) : isJVM ? ( - <> - {unsupportedJvmModeMessage && ( - - )} -
- - - - - - -
+ + ({ - value: mode, - label: resolveJVMModeMeta(mode).label, - }))} - /> - + + 只读模式 + - - + - - 只读模式 - + + + - - - + + + - - - - + + + + + 诊断增强 + + + + + + {jvmDiagnosticEnabled ? ( + <> + + + + + + + + + + + + + + + 观察类命令 + + + 跟踪类命令 + + + 高风险命令 + + + + + + + + ) : null} + + + + + + ) : ( + <> +
+ + + + {isFileDb ? ( + + + ) : ( - <> -
- - - - {isFileDb ? ( - - - - ) : ( - Number(value) > 0)]} - style={{ marginBottom: 0 }} - > - - - )} -
- - {(dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase') && ( - - - - )} - - {dbType === 'oracle' && ( - - - - )} - - {(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && ( - <> - - - -
- - - - - - -
- {renderStoredSecretControls({ - fieldName: 'mysqlReplicaPassword', - clearKey: 'mysqlReplicaPassword', - hasStoredSecret: initialValues?.hasMySQLReplicaPassword, - clearLabel: '清除已保存从库密码', - description: '当前已保存从库密码。留空表示继续沿用,输入新值表示替换。', - })} - - )} - - )} - - {dbType === 'mongodb' && ( - <> - - - -
- - - - - - -
- - - - {renderStoredSecretControls({ - fieldName: 'mongoReplicaPassword', - clearKey: 'mongoReplicaPassword', - hasStoredSecret: initialValues?.hasMongoReplicaPassword, - clearLabel: '清除已保存副本集密码', - description: '当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。', - })} - - - - {mongoMembers.length > 0 && ( - record.host} - pagination={false} - dataSource={mongoMembers} - style={{ marginBottom: 12 }} - columns={[ - { title: 'Host', dataIndex: 'host', width: '48%' }, - { - title: '角色', - dataIndex: 'role', - width: '32%', - render: (value: string, record: MongoMemberInfo) => ( - {value || 'UNKNOWN'} - ), - }, - { - title: '健康', - dataIndex: 'healthy', - width: '20%', - render: (value: boolean) => ( - {value ? '正常' : '异常'} - ), - }, - ]} - /> - )} - - )} -
- - - - - - - {redisTopology === 'cluster' && ( - - - {redisDbList.map(db => db{db})} - - - - )} - - {!isFileDb && !isRedis && !isJVM && ( - <> -
- - - - - - - {dbType === 'mongodb' && ( - - - {dbList.map(db => {db})} - - - )} - + Number(value) > 0, + ), + ]} + style={{ marginBottom: 0 }} + > + + )} -
- ); +
- const networkSecuritySection = !isFileDb && !isJVM ? (() => { - const networkItems: Array<{ - key: 'ssl' | 'ssh' | 'proxy' | 'httpTunnel'; + {(dbType === "postgres" || + dbType === "kingbase" || + dbType === "highgo" || + dbType === "vastbase") && ( + + + + )} + + {dbType === "oracle" && ( + + + + )} + + {(dbType === "mysql" || + dbType === "mariadb" || + dbType === "diros" || + dbType === "sphinx") && ( + <> + + + +
+ + + + + + +
+ {renderStoredSecretControls({ + fieldName: "mysqlReplicaPassword", + clearKey: "mysqlReplicaPassword", + hasStoredSecret: initialValues?.hasMySQLReplicaPassword, + clearLabel: "清除已保存从库密码", + description: + "当前已保存从库密码。留空表示继续沿用,输入新值表示替换。", + })} + + )} + + )} + + {dbType === "mongodb" && ( + <> + + + +
+ + + + + + +
+ + + + {renderStoredSecretControls({ + fieldName: "mongoReplicaPassword", + clearKey: "mongoReplicaPassword", + hasStoredSecret: initialValues?.hasMongoReplicaPassword, + clearLabel: "清除已保存副本集密码", + description: + "当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。", + })} + + + + {mongoMembers.length > 0 && ( +
record.host} + pagination={false} + dataSource={mongoMembers} + style={{ marginBottom: 12 }} + columns={[ + { title: "Host", dataIndex: "host", width: "48%" }, + { + title: "角色", + dataIndex: "role", + width: "32%", + render: ( + value: string, + record: MongoMemberInfo, + ) => ( + + {value || "UNKNOWN"} + + ), + }, + { + title: "健康", + dataIndex: "healthy", + width: "20%", + render: (value: boolean) => ( + + {value ? "正常" : "异常"} + + ), + }, + ]} + /> + )} + + )} +
+ + + + + + + {redisTopology === "cluster" && ( + + + {redisDbList.map((db) => ( + + db{db} + + ))} + + + + )} + + {!isFileDb && !isRedis && !isJVM && ( + <> +
+ + + + + + + {dbType === "mongodb" && ( + + + {dbList.map((db) => ( + + {db} + + ))} + + + )} + + )} +
+ ); + + const networkSecuritySection = + !isFileDb && !isJVM + ? (() => { + const networkItems: Array<{ + key: "ssl" | "ssh" | "proxy" | "httpTunnel"; title: string; description: string; enabled: boolean; - }> = [ - ...(isSSLType ? [{ key: 'ssl' as const, title: 'SSL/TLS', description: '加密与证书校验', enabled: useSSL }] : []), - { key: 'ssh', title: 'SSH 隧道', description: '跳板机 / 堡垒机转发', enabled: useSSH }, - { key: 'proxy', title: '代理', description: 'SOCKS5 / HTTP CONNECT', enabled: useProxy }, - { key: 'httpTunnel', title: 'HTTP 隧道', description: '独立 HTTP CONNECT 路由', enabled: useHttpTunnel }, - ]; - const resolvedNetworkConfig = networkItems.some((item) => item.key === activeNetworkConfig) + }> = [ + ...(isSSLType + ? [ + { + key: "ssl" as const, + title: "SSL/TLS", + description: "加密与证书校验", + enabled: useSSL, + }, + ] + : []), + { + key: "ssh", + title: "SSH 隧道", + description: "跳板机 / 堡垒机转发", + enabled: useSSH, + }, + { + key: "proxy", + title: "代理", + description: "SOCKS5 / HTTP CONNECT", + enabled: useProxy, + }, + { + key: "httpTunnel", + title: "HTTP 隧道", + description: "独立 HTTP CONNECT 路由", + enabled: useHttpTunnel, + }, + ]; + const resolvedNetworkConfig = networkItems.some( + (item) => item.key === activeNetworkConfig, + ) ? activeNetworkConfig - : networkItems[0]?.key || 'ssh'; - const renderNetworkPanel = () => { - if (resolvedNetworkConfig === 'ssl') { - return ( -
-
SSL/TLS
-
为连接链路增加加密与证书校验控制,适合生产或跨网络访问场景。
- {!useSSL ? ( -
- 左侧勾选“SSL/TLS”后,可在这里配置模式、证书与校验策略。 -
- ) : ( -
- - - - - - - - )} - {sslHintText} -
- )} + : networkItems[0]?.key || "ssh"; + const renderNetworkPanel = () => { + if (resolvedNetworkConfig === "ssl") { + return ( +
+
+ SSL/TLS +
+
+ 为连接链路增加加密与证书校验控制,适合生产或跨网络访问场景。 +
+ {!useSSL ? ( +
+ 左侧勾选“SSL/TLS”后,可在这里配置模式、证书与校验策略。
- ); + ) : ( +
+ + + + + + + + )} + + {sslHintText} + +
+ )} +
+ ); } - if (resolvedNetworkConfig === 'ssh') { - return ( -
-
SSH 隧道
-
通过跳板机或堡垒机转发数据库连接,适合内网或受限网络环境。
- {!useSSH ? ( -
- 左侧勾选“SSH 隧道”后,可在这里填写主机、端口、用户名、密码和私钥路径。 -
- ) : ( -
-
- - - - - - -
-
- - - - - - -
- - - - - - - - - {renderStoredSecretControls({ - fieldName: 'sshPassword', - clearKey: 'sshPassword', - hasStoredSecret: initialValues?.hasSSHPassword, - clearLabel: '清除已保存 SSH 密码', - description: '当前已保存 SSH 密码。留空表示继续沿用,输入新值表示替换。', - })} -
- )} + if (resolvedNetworkConfig === "ssh") { + return ( +
+
+ SSH 隧道 +
+
+ 通过跳板机或堡垒机转发数据库连接,适合内网或受限网络环境。 +
+ {!useSSH ? ( +
+ 左侧勾选“SSH + 隧道”后,可在这里填写主机、端口、用户名、密码和私钥路径。
- ); + ) : ( +
+
+ + + + + + +
+
+ + + + + + +
+ + + + + + + + + {renderStoredSecretControls({ + fieldName: "sshPassword", + clearKey: "sshPassword", + hasStoredSecret: initialValues?.hasSSHPassword, + clearLabel: "清除已保存 SSH 密码", + description: + "当前已保存 SSH 密码。留空表示继续沿用,输入新值表示替换。", + })} +
+ )} +
+ ); } - if (resolvedNetworkConfig === 'proxy') { - return ( -
-
代理
-
适合借助本地代理软件或中间网关转发数据库流量。
- {!useProxy ? ( -
- 左侧勾选“代理”后,可在这里选择代理类型并填写主机、端口与认证信息。 -
- ) : ( -
- - - -
- - - - - - -
- {renderStoredSecretControls({ - fieldName: 'proxyPassword', - clearKey: 'proxyPassword', - hasStoredSecret: initialValues?.hasProxyPassword, - clearLabel: '清除已保存代理密码', - description: '当前已保存代理密码。留空表示继续沿用,输入新值表示替换。', - })} -
- )} + if (resolvedNetworkConfig === "proxy") { + return ( +
+
+ 代理 +
+
+ 适合借助本地代理软件或中间网关转发数据库流量。 +
+ {!useProxy ? ( +
+ 左侧勾选“代理”后,可在这里选择代理类型并填写主机、端口与认证信息。
- ); + ) : ( +
+ + + +
+ + + + + + +
+ {renderStoredSecretControls({ + fieldName: "proxyPassword", + clearKey: "proxyPassword", + hasStoredSecret: initialValues?.hasProxyPassword, + clearLabel: "清除已保存代理密码", + description: + "当前已保存代理密码。留空表示继续沿用,输入新值表示替换。", + })} +
+ )} +
+ ); } return ( -
-
HTTP 隧道
-
与代理模式互斥,适合单独指定一条 HTTP CONNECT 隧道路由。
- {!useHttpTunnel ? ( -
- 左侧勾选“HTTP 隧道”后,可在这里填写隧道目标与认证信息。 -
- ) : ( -
-
- - - - - - -
-
- - - - - - -
- {renderStoredSecretControls({ - fieldName: 'httpTunnelPassword', - clearKey: 'httpTunnelPassword', - hasStoredSecret: initialValues?.hasHttpTunnelPassword, - clearLabel: '清除已保存隧道密码', - description: '当前已保存隧道密码。留空表示继续沿用,输入新值表示替换。', - })} - 与“使用代理”互斥,启用后将通过 HTTP CONNECT 建立独立隧道。 -
- )} +
+
+ HTTP 隧道
+
+ 与代理模式互斥,适合单独指定一条 HTTP CONNECT 隧道路由。 +
+ {!useHttpTunnel ? ( +
+ 左侧勾选“HTTP 隧道”后,可在这里填写隧道目标与认证信息。 +
+ ) : ( +
+
+ + + + + + +
+
+ + + + + + +
+ {renderStoredSecretControls({ + fieldName: "httpTunnelPassword", + clearKey: "httpTunnelPassword", + hasStoredSecret: initialValues?.hasHttpTunnelPassword, + clearLabel: "清除已保存隧道密码", + description: + "当前已保存隧道密码。留空表示继续沿用,输入新值表示替换。", + })} + + 与“使用代理”互斥,启用后将通过 HTTP CONNECT + 建立独立隧道。 + +
+ )} +
); - }; + }; + + return ( +
+
+ 网络与安全 +
+
+ 上方稳定列出所有连接方式,下方固定展示当前方式的配置详情,避免启用后页面重新排布,同时给详情区留出足够宽度。 +
+
+ {networkItems.map((item) => { + const active = item.key === resolvedNetworkConfig; + const activeColor = darkMode ? "#ffd666" : "#1677ff"; + return ( +
setActiveNetworkConfig(item.key)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setActiveNetworkConfig(item.key); + } + }} + style={{ + ...getConnectionOptionCardStyle(item.enabled), + borderColor: active + ? darkMode + ? "rgba(255,214,102,0.46)" + : "rgba(24,144,255,0.36)" + : "transparent", + background: active + ? darkMode + ? "linear-gradient(180deg, rgba(255,214,102,0.14) 0%, rgba(255,214,102,0.08) 100%)" + : "linear-gradient(180deg, rgba(24,144,255,0.12) 0%, rgba(24,144,255,0.06) 100%)" + : getConnectionOptionCardStyle(item.enabled) + .background, + boxShadow: active + ? darkMode + ? "0 0 0 1px rgba(255,214,102,0.18) inset, 0 12px 26px rgba(0,0,0,0.16)" + : "0 0 0 1px rgba(24,144,255,0.14) inset, 0 12px 22px rgba(24,144,255,0.10)" + : "none", + cursor: "pointer", + outline: "none", + }} + > +
+
+
+ + + +
+
+ + {item.title} + +
+ {active && ( + + 当前编辑 + + )} + + {item.enabled ? "已启用" : "未启用"} + +
+
+
+ {item.description} +
+
+
+
+
+ ); + })} +
+
{renderNetworkPanel()}
+
+
+ 高级连接 +
+ + + +
+
+ ); + })() + : null; + + return ( +
{ + if (testResult) { + setTestResult(null); + setTestErrorLogOpen(false); + } + if (changed.uri !== undefined || changed.type !== undefined) { + setUriFeedback(null); + } + if (changed.useSSL !== undefined) { + setUseSSL(changed.useSSL); + if (changed.useSSL) setActiveNetworkConfig("ssl"); + } + if (changed.useSSH !== undefined) { + setUseSSH(changed.useSSH); + if (changed.useSSH) setActiveNetworkConfig("ssh"); + } + if (changed.useProxy !== undefined) { + const enabledProxy = !!changed.useProxy; + setUseProxy(enabledProxy); + if (enabledProxy) setActiveNetworkConfig("proxy"); + if (enabledProxy && form.getFieldValue("useHttpTunnel")) { + form.setFieldValue("useHttpTunnel", false); + setUseHttpTunnel(false); + } + } + if (changed.proxyType !== undefined) { + const nextType = String( + changed.proxyType || "socks5", + ).toLowerCase(); + if (nextType === "http") { + const currentPort = Number(form.getFieldValue("proxyPort") || 0); + if (!currentPort || currentPort === 1080) { + form.setFieldValue("proxyPort", 8080); + } + } else { + const currentPort = Number(form.getFieldValue("proxyPort") || 0); + if (!currentPort || currentPort === 8080) { + form.setFieldValue("proxyPort", 1080); + } + } + } + if (changed.useHttpTunnel !== undefined) { + const enabledHttpTunnel = !!changed.useHttpTunnel; + setUseHttpTunnel(enabledHttpTunnel); + if (enabledHttpTunnel) setActiveNetworkConfig("httpTunnel"); + if (enabledHttpTunnel && form.getFieldValue("useProxy")) { + form.setFieldValue("useProxy", false); + setUseProxy(false); + } + if (enabledHttpTunnel) { + const currentPort = Number( + form.getFieldValue("httpTunnelPort") || 0, + ); + if (!currentPort || currentPort <= 0) { + form.setFieldValue("httpTunnelPort", 8080); + } + } + } + if (changed.type !== undefined) setDbType(changed.type); + if (changed.jvmAllowedModes !== undefined) { + const resolvedModes = normalizeEditableJVMModes( + changed.jvmAllowedModes, + ); + const currentPreferredMode = String( + form.getFieldValue("jvmPreferredMode") || "", + ) + .trim() + .toLowerCase(); + const resolvedPreferredMode = + resolvedModes.find((mode) => mode === currentPreferredMode) || + resolvedModes[0]; + form.setFieldValue("jvmAllowedModes", resolvedModes); + form.setFieldValue("jvmPreferredMode", resolvedPreferredMode); + form.setFieldValue( + "jvmEndpointEnabled", + resolvedModes.includes("endpoint"), + ); + form.setFieldValue( + "jvmAgentEnabled", + resolvedModes.includes("agent"), + ); + } + if (changed.redisTopology !== undefined) { + const supportedDbs = Array.from({ length: 16 }, (_, i) => i); + setRedisDbList(supportedDbs); + const selectedDbsRaw = form.getFieldValue("includeRedisDatabases"); + const selectedDbs = Array.isArray(selectedDbsRaw) + ? selectedDbsRaw.map((entry: any) => Number(entry)) + : []; + const validDbs = selectedDbs + .filter((entry: number) => Number.isFinite(entry)) + .map((entry: number) => Math.trunc(entry)) + .filter((entry: number) => supportedDbs.includes(entry)); + form.setFieldValue( + "includeRedisDatabases", + validDbs.length > 0 ? validDbs : undefined, + ); + } + if ( + changed.type !== undefined || + changed.host !== undefined || + changed.port !== undefined || + changed.mongoHosts !== undefined || + changed.mongoTopology !== undefined || + changed.mongoSrv !== undefined + ) { + setMongoMembers([]); + } + }} + > + + {currentDriverUnavailableReason && ( + + {currentDriverUnavailableReason} + + + } + /> + )} + {(() => { + const sectionItems: Array<{ + key: "basic" | "network" | "appearance"; + title: string; + description: string; + icon: React.ReactNode; + }> = [ + { + key: "basic", + title: "基础信息", + description: "名称、地址、认证、URI 与数据库范围", + icon: , + }, + ...(!isCustom && !isFileDb && !isJVM + ? [ + { + key: "network" as const, + title: "网络与安全", + description: "SSL、SSH、代理与高级连接", + icon: , + }, + ] + : []), + { + key: "appearance", + title: "外观", + description: "自定义图标与颜色", + icon: , + }, + ]; + const resolvedSection = sectionItems.some( + (item) => item.key === activeConfigSection, + ) + ? activeConfigSection + : sectionItems[0]?.key || "basic"; + + const effectiveIconType = customIconType || dbType; + const effectiveIconColor = + customIconColor || getDbDefaultColor(effectiveIconType); + + const appearanceSection = ( +
+
+
+ 图标 +
+
+ {DB_ICON_TYPES.map((iconKey) => { + const isActive = effectiveIconType === iconKey; + return ( + + ); + })} +
+
+ 当前:{getDbIconLabel(effectiveIconType)} +
+
+
+
+ 颜色 +
+
+ {PRESET_ICON_COLORS.map((presetColor) => { + const isActive = effectiveIconColor === presetColor; + return ( +
+
+
+
+ 预览 +
+
+ {getDbIcon(effectiveIconType, effectiveIconColor, 24)} + + {form.getFieldValue("name") || "连接名称"} + +
+ {(customIconType || customIconColor) && ( + + )} +
+
+ ); + + const currentSectionContent = + resolvedSection === "basic" + ? baseInfoSection + : resolvedSection === "appearance" + ? appearanceSection + : networkSecuritySection; + + if (sectionItems.length <= 1) { + return currentSectionContent; + } return ( -
-
网络与安全
-
上方稳定列出所有连接方式,下方固定展示当前方式的配置详情,避免启用后页面重新排布,同时给详情区留出足够宽度。
-
- {networkItems.map((item) => { - const active = item.key === resolvedNetworkConfig; - const activeColor = darkMode ? '#ffd666' : '#1677ff'; - return ( -
setActiveNetworkConfig(item.key)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - setActiveNetworkConfig(item.key); - } - }} - style={{ - ...getConnectionOptionCardStyle(item.enabled), - borderColor: active - ? (darkMode ? 'rgba(255,214,102,0.46)' : 'rgba(24,144,255,0.36)') - : 'transparent', - background: active - ? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.14) 0%, rgba(255,214,102,0.08) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.12) 0%, rgba(24,144,255,0.06) 100%)') - : getConnectionOptionCardStyle(item.enabled).background, - boxShadow: active - ? (darkMode ? '0 0 0 1px rgba(255,214,102,0.18) inset, 0 12px 26px rgba(0,0,0,0.16)' : '0 0 0 1px rgba(24,144,255,0.14) inset, 0 12px 22px rgba(24,144,255,0.10)') - : 'none', - cursor: 'pointer', - outline: 'none', - }} - > -
-
-
- - - -
-
- {item.title} -
- {active && ( - - 当前编辑 - - )} - - {item.enabled ? '已启用' : '未启用'} - -
-
-
- {item.description} -
-
-
-
-
- ); - })} -
-
- {renderNetworkPanel()} -
-
-
高级连接
- - - -
+
+
+
+ 配置分区 +
+
+ {sectionItems.map((item) => { + const active = item.key === resolvedSection; + return ( + + ); + })} +
+
{currentSectionContent}
+
); - })() : null; - - return ( - { - if (testResult) { - setTestResult(null); - setTestErrorLogOpen(false); - } - if (changed.uri !== undefined || changed.type !== undefined) { - setUriFeedback(null); - } - if (changed.useSSL !== undefined) { - setUseSSL(changed.useSSL); - if (changed.useSSL) setActiveNetworkConfig('ssl'); - } - if (changed.useSSH !== undefined) { - setUseSSH(changed.useSSH); - if (changed.useSSH) setActiveNetworkConfig('ssh'); - } - if (changed.useProxy !== undefined) { - const enabledProxy = !!changed.useProxy; - setUseProxy(enabledProxy); - if (enabledProxy) setActiveNetworkConfig('proxy'); - if (enabledProxy && form.getFieldValue('useHttpTunnel')) { - form.setFieldValue('useHttpTunnel', false); - setUseHttpTunnel(false); - } - } - if (changed.proxyType !== undefined) { - const nextType = String(changed.proxyType || 'socks5').toLowerCase(); - if (nextType === 'http') { - const currentPort = Number(form.getFieldValue('proxyPort') || 0); - if (!currentPort || currentPort === 1080) { - form.setFieldValue('proxyPort', 8080); - } - } else { - const currentPort = Number(form.getFieldValue('proxyPort') || 0); - if (!currentPort || currentPort === 8080) { - form.setFieldValue('proxyPort', 1080); - } - } - } - if (changed.useHttpTunnel !== undefined) { - const enabledHttpTunnel = !!changed.useHttpTunnel; - setUseHttpTunnel(enabledHttpTunnel); - if (enabledHttpTunnel) setActiveNetworkConfig('httpTunnel'); - if (enabledHttpTunnel && form.getFieldValue('useProxy')) { - form.setFieldValue('useProxy', false); - setUseProxy(false); - } - if (enabledHttpTunnel) { - const currentPort = Number(form.getFieldValue('httpTunnelPort') || 0); - if (!currentPort || currentPort <= 0) { - form.setFieldValue('httpTunnelPort', 8080); - } - } - } - if (changed.type !== undefined) setDbType(changed.type); - if (changed.jvmAllowedModes !== undefined) { - const resolvedModes = normalizeEditableJVMModes(changed.jvmAllowedModes); - const currentPreferredMode = String(form.getFieldValue('jvmPreferredMode') || '').trim().toLowerCase(); - const resolvedPreferredMode = resolvedModes.find((mode) => mode === currentPreferredMode) || resolvedModes[0]; - form.setFieldValue('jvmAllowedModes', resolvedModes); - form.setFieldValue('jvmPreferredMode', resolvedPreferredMode); - form.setFieldValue('jvmEndpointEnabled', resolvedModes.includes('endpoint')); - } - if (changed.redisTopology !== undefined) { - const supportedDbs = Array.from({ length: 16 }, (_, i) => i); - setRedisDbList(supportedDbs); - const selectedDbsRaw = form.getFieldValue('includeRedisDatabases'); - const selectedDbs = Array.isArray(selectedDbsRaw) ? selectedDbsRaw.map((entry: any) => Number(entry)) : []; - const validDbs = selectedDbs - .filter((entry: number) => Number.isFinite(entry)) - .map((entry: number) => Math.trunc(entry)) - .filter((entry: number) => supportedDbs.includes(entry)); - form.setFieldValue('includeRedisDatabases', validDbs.length > 0 ? validDbs : undefined); - } - if ( - changed.type !== undefined - || changed.host !== undefined - || changed.port !== undefined - || changed.mongoHosts !== undefined - || changed.mongoTopology !== undefined - || changed.mongoSrv !== undefined - ) { - setMongoMembers([]); - } - }} - > - - {currentDriverUnavailableReason && ( - - {currentDriverUnavailableReason} - - - )} - /> - )} - {(() => { - const sectionItems: Array<{ key: 'basic' | 'network' | 'appearance'; title: string; description: string; icon: React.ReactNode }> = [ - { key: 'basic', title: '基础信息', description: '名称、地址、认证、URI 与数据库范围', icon: }, - ...(!isCustom && !isFileDb && !isJVM ? [{ key: 'network' as const, title: '网络与安全', description: 'SSL、SSH、代理与高级连接', icon: }] : []), - { key: 'appearance', title: '外观', description: '自定义图标与颜色', icon: }, - ]; - const resolvedSection = sectionItems.some((item) => item.key === activeConfigSection) - ? activeConfigSection - : sectionItems[0]?.key || 'basic'; - - const effectiveIconType = customIconType || dbType; - const effectiveIconColor = customIconColor || getDbDefaultColor(effectiveIconType); - - const appearanceSection = ( -
-
-
图标
-
- {DB_ICON_TYPES.map((iconKey) => { - const isActive = effectiveIconType === iconKey; - return ( - - ); - })} -
-
- 当前:{getDbIconLabel(effectiveIconType)} -
-
-
-
颜色
-
- {PRESET_ICON_COLORS.map((presetColor) => { - const isActive = effectiveIconColor === presetColor; - return ( -
-
-
-
预览
-
- {getDbIcon(effectiveIconType, effectiveIconColor, 24)} - {form.getFieldValue('name') || '连接名称'} -
- {(customIconType || customIconColor) && ( - - )} -
-
- ); - - const currentSectionContent = resolvedSection === 'basic' - ? baseInfoSection - : resolvedSection === 'appearance' - ? appearanceSection - : networkSecuritySection; - - if (sectionItems.length <= 1) { - return currentSectionContent; - } - - return ( -
-
-
配置分区
-
- {sectionItems.map((item) => { - const active = item.key === resolvedSection; - return ( - - ); - })} -
-
-
- {currentSectionContent} -
-
- ); - })()} - - ); + })()} + + ); }; const getFooter = () => { - if (step === 1) { - return [ - - ]; - } - const isTestSuccess = testResult?.type === 'success'; - const hasTestError = !!testResult && !isTestSuccess; - const operationBlocked = !!currentDriverUnavailableReason || driverStatusChecking || !!unsupportedJvmModeMessage; - return ( -
-
- {!initialValues && } - {testResult ? ( - - {isTestSuccess ? : } - {isTestSuccess ? '连接成功' : '连接失败'} - - ) : null} - {hasTestError && ( - - )} -
- - - - - -
- ); + if (step === 1) { + return [ + , + ]; + } + const isTestSuccess = testResult?.type === "success"; + const hasTestError = !!testResult && !isTestSuccess; + const testFailureSummary = hasTestError + ? summarizeConnectionTestFailureMessage(testResult?.message, "连接失败") + : ""; + const operationBlocked = + !!currentDriverUnavailableReason || + driverStatusChecking || + !!unsupportedJvmModeMessage; + return ( +
+
+ {!initialValues && ( + + )} + {testResult ? ( + + {isTestSuccess ? : } + {isTestSuccess ? "连接成功" : "连接失败"} + + ) : null} + {hasTestError && ( + + {testFailureSummary} + + )} + {hasTestError && ( + + )} +
+ + + + + +
+ ); }; const getTitle = () => { - if (step === 1) { - return renderConnectionModalTitle(, '选择数据源类型', '按数据库、中间件或文件类型快速进入对应的连接配置流程。'); - } - const typeName = dbTypes.find(t => t.key === dbType)?.name || dbType; - return initialValues - ? renderConnectionModalTitle(, '编辑连接', `调整 ${typeName} 连接的参数、认证方式与网络选项。`) - : renderConnectionModalTitle(, `新建 ${typeName} 连接`, '填写连接参数、测试连通性,并保存到连接树中。'); + if (step === 1) { + return renderConnectionModalTitle( + , + "选择数据源类型", + "按数据库、中间件或文件类型快速进入对应的连接配置流程。", + ); + } + const typeName = dbTypes.find((t) => t.key === dbType)?.name || dbType; + return initialValues + ? renderConnectionModalTitle( + , + "编辑连接", + `调整 ${typeName} 连接的参数、认证方式与网络选项。`, + ) + : renderConnectionModalTitle( + , + `新建 ${typeName} 连接`, + "填写连接参数、测试连通性,并保存到连接树中。", + ); }; const modalBodyStyle = { - padding: '12px 24px 18px', - height: CONNECTION_MODAL_BODY_HEIGHT, - overflowY: 'auto' as const, - overflowX: 'hidden' as const, + padding: "12px 24px 18px", + height: CONNECTION_MODAL_BODY_HEIGHT, + overflowY: "auto" as const, + overflowX: "hidden" as const, }; return ( <> {step === 1 ? renderStep1() : renderStep2()} , '测试连接失败原因', '查看本次测试连接的完整错误上下文,便于快速定位配置问题。')} - open={testErrorLogOpen} - onCancel={() => setTestErrorLogOpen(false)} - centered - width={760} - zIndex={10002} - destroyOnHidden - styles={{ - content: modalShellStyle, - header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, - body: { paddingTop: 8 }, - footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } - }} - footer={[ - , - ]} + title={renderConnectionModalTitle( + , + "测试连接失败原因", + "查看本次测试连接的完整错误上下文,便于快速定位配置问题。", + )} + open={testErrorLogOpen} + onCancel={() => setTestErrorLogOpen(false)} + centered + width={760} + zIndex={10002} + destroyOnHidden + styles={{ + content: modalShellStyle, + header: { + background: "transparent", + borderBottom: "none", + paddingBottom: 8, + }, + body: { paddingTop: 8 }, + footer: { + background: "transparent", + borderTop: "none", + paddingTop: 10, + }, + }} + footer={[ + , + ]} > -
-              {String(testResult?.message || '暂无失败日志')}
-          
+
+          {String(testResult?.message || "暂无失败日志")}
+        
); diff --git a/frontend/src/components/JVMAuditViewer.tsx b/frontend/src/components/JVMAuditViewer.tsx index 776f759..521c53b 100644 --- a/frontend/src/components/JVMAuditViewer.tsx +++ b/frontend/src/components/JVMAuditViewer.tsx @@ -1,11 +1,26 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Alert, Button, Card, Empty, Select, Space, Table, Tag, Typography } from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import { ReloadOutlined } from '@ant-design/icons'; +import React, { useEffect, useMemo, useState } from "react"; +import { + Alert, + Button, + Card, + Empty, + Select, + Space, + Table, + Tag, + Typography, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { ReloadOutlined } from "@ant-design/icons"; -import { useStore } from '../store'; -import type { JVMAuditRecord, TabData } from '../types'; -import JVMModeBadge from './jvm/JVMModeBadge'; +import { useStore } from "../store"; +import type { JVMAuditRecord, TabData } from "../types"; +import { + formatJVMAuditResultLabel, + formatJVMActionDisplayText, + resolveJVMAuditResultColor, +} from "../utils/jvmResourcePresentation"; +import JVMModeBadge from "./jvm/JVMModeBadge"; const { Text } = Typography; @@ -25,90 +40,109 @@ const normalizeAuditRecords = (value: any): JVMAuditRecord[] => { return []; }; -const filterAuditRecordsByMode = (records: JVMAuditRecord[], providerMode?: string): JVMAuditRecord[] => { - const normalizedMode = String(providerMode || '').trim().toLowerCase(); +const filterAuditRecordsByMode = ( + records: JVMAuditRecord[], + providerMode?: string, +): JVMAuditRecord[] => { + const normalizedMode = String(providerMode || "") + .trim() + .toLowerCase(); if (!normalizedMode) { return records; } - return records.filter((record) => String(record.providerMode || '').trim().toLowerCase() === normalizedMode); + return records.filter( + (record) => + String(record.providerMode || "") + .trim() + .toLowerCase() === normalizedMode, + ); }; const formatTimestamp = (timestamp: number): string => { if (!timestamp) { - return '-'; + return "-"; } const normalized = timestamp > 1e12 ? timestamp : timestamp * 1000; const date = new Date(normalized); if (Number.isNaN(date.getTime())) { return String(timestamp); } - return date.toLocaleString('zh-CN', { hour12: false }); -}; - -const resultColor = (result: string): string => { - const normalized = String(result || '').trim().toLowerCase(); - if (normalized.includes('success') || normalized.includes('ok') || normalized.includes('done')) { - return 'green'; - } - if (normalized.includes('warn')) { - return 'gold'; - } - if (normalized.includes('fail') || normalized.includes('error')) { - return 'red'; - } - return 'default'; + return date.toLocaleString("zh-CN", { hour12: false }); }; const JVMAuditViewer: React.FC = ({ tab }) => { - const connection = useStore((state) => state.connections.find((item) => item.id === tab.connectionId)); + const connection = useStore((state) => + state.connections.find((item) => item.id === tab.connectionId), + ); const [limit, setLimit] = useState(50); const [loading, setLoading] = useState(true); const [records, setRecords] = useState([]); - const [error, setError] = useState(''); + const [error, setError] = useState(""); const columns = useMemo>( () => [ { - title: '时间', - dataIndex: 'timestamp', - key: 'timestamp', + title: "时间", + dataIndex: "timestamp", + key: "timestamp", width: 180, render: (value: number) => formatTimestamp(value), }, { - title: '模式', - dataIndex: 'providerMode', - key: 'providerMode', + title: "模式", + dataIndex: "providerMode", + key: "providerMode", width: 120, - render: (value: string) => , + render: (value: string) => ( + + ), }, { - title: '动作', - dataIndex: 'action', - key: 'action', + title: "动作", + dataIndex: "action", + key: "action", + width: 160, + render: (value: string) => formatJVMActionDisplayText(value) || "-", + }, + { + title: "资源", + dataIndex: "resourceId", + key: "resourceId", + ellipsis: true, + render: (value: string) => value || "-", + }, + { + title: "原因", + dataIndex: "reason", + key: "reason", + ellipsis: true, + render: (value: string) => value || "-", + }, + { + title: "来源", + dataIndex: "source", + key: "source", width: 120, - render: (value: string) => value || '-', + render: (value?: string) => { + const normalized = String(value || "") + .trim() + .toLowerCase(); + if (normalized === "ai-plan") { + return AI 辅助; + } + return 手工; + }, }, { - title: '资源', - dataIndex: 'resourceId', - key: 'resourceId', - ellipsis: true, - render: (value: string) => value || '-', - }, - { - title: '原因', - dataIndex: 'reason', - key: 'reason', - ellipsis: true, - render: (value: string) => value || '-', - }, - { - title: '结果', - dataIndex: 'result', - key: 'result', + title: "结果", + dataIndex: "result", + key: "result", width: 140, - render: (value: string) => {value || 'unknown'}, + render: (value: string) => ( + + {formatJVMAuditResultLabel(value)} + + ), }, ], [tab.providerMode], @@ -118,31 +152,36 @@ const JVMAuditViewer: React.FC = ({ tab }) => { if (!connection) { setLoading(false); setRecords([]); - setError('连接不存在或已被删除'); + setError("连接不存在或已被删除"); return; } const backendApp = (window as any).go?.app?.App; - if (typeof backendApp?.JVMListAuditRecords !== 'function') { + if (typeof backendApp?.JVMListAuditRecords !== "function") { setLoading(false); setRecords([]); - setError('JVMListAuditRecords 后端方法不可用'); + setError("JVMListAuditRecords 后端方法不可用"); return; } setLoading(true); - setError(''); + setError(""); try { const result = await backendApp.JVMListAuditRecords(connection.id, limit); if (result?.success === false) { setRecords([]); - setError(String(result?.message || '读取 JVM 审计记录失败')); + setError(String(result?.message || "读取 JVM 审计记录失败")); return; } - setRecords(filterAuditRecordsByMode(normalizeAuditRecords(result), tab.providerMode)); + setRecords( + filterAuditRecordsByMode( + normalizeAuditRecords(result), + tab.providerMode, + ), + ); } catch (err: any) { setRecords([]); - setError(err?.message || '读取 JVM 审计记录失败'); + setError(err?.message || "读取 JVM 审计记录失败"); } finally { setLoading(false); } @@ -153,23 +192,38 @@ const JVMAuditViewer: React.FC = ({ tab }) => { }, [connection, limit, tab.connectionId]); if (!connection) { - return ; + return ( + + ); } return ( -
+
- + - - setDraft(tab.id, { reason: event.target.value })} + /> +
+ + +
+ + + + + + +
+
+ ); +}; + +export default JVMDiagnosticConsole; diff --git a/frontend/src/components/JVMOverview.tsx b/frontend/src/components/JVMOverview.tsx index 99dddbe..0f0990b 100644 --- a/frontend/src/components/JVMOverview.tsx +++ b/frontend/src/components/JVMOverview.tsx @@ -1,65 +1,223 @@ -import React, { useMemo } from 'react'; -import { Card, Descriptions, Empty, Space, Tag, Typography } from 'antd'; +import React, { useEffect, useMemo, useState } from "react"; +import { + Alert, + Card, + Descriptions, + Empty, + Skeleton, + Space, + Tag, + Typography, +} from "antd"; -import { useStore } from '../store'; -import type { SavedConnection, TabData } from '../types'; -import JVMModeBadge from './jvm/JVMModeBadge'; +import { useStore } from "../store"; +import { JVMProbeCapabilities } from "../../wailsjs/go/app/App"; +import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig"; +import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation"; +import type { JVMCapability, TabData } from "../types"; +import JVMModeBadge from "./jvm/JVMModeBadge"; const { Paragraph, Text } = Typography; +const DESCRIPTION_STYLES = { label: { width: 120 } } as const; type JVMOverviewProps = { tab: TabData; }; const JVMOverview: 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 connection = useStore((state) => + state.connections.find((item) => item.id === tab.connectionId), + ); + const providerMode = + tab.providerMode || connection?.config.jvm?.preferredMode || "jmx"; const readOnly = connection?.config.jvm?.readOnly !== false; const allowedModes = connection?.config.jvm?.allowedModes || []; + const [capabilities, setCapabilities] = useState([]); + const [capabilityLoading, setCapabilityLoading] = useState(true); + const [capabilityError, setCapabilityError] = useState(""); const endpointSummary = useMemo(() => { if (!connection?.config.jvm?.endpoint) { - return ''; + return ""; } const endpoint = connection.config.jvm.endpoint; if (!endpoint.enabled && !endpoint.baseUrl) { - return ''; + return ""; } - return endpoint.baseUrl || '已启用'; + return endpoint.baseUrl || "已启用"; + }, [connection]); + + const agentSummary = useMemo(() => { + if (!connection?.config.jvm?.agent) { + return ""; + } + const agent = connection.config.jvm.agent; + if (!agent.enabled && !agent.baseUrl) { + return ""; + } + return agent.baseUrl || "已启用"; + }, [connection]); + + const allowedModeSummary = useMemo(() => { + const items = allowedModes.length > 0 ? allowedModes : ["jmx"]; + return items.map((item) => resolveJVMModeMeta(item).label).join("、"); + }, [allowedModes]); + + useEffect(() => { + if (!connection) { + setCapabilities([]); + setCapabilityError("连接不存在或已被删除"); + setCapabilityLoading(false); + return; + } + + let cancelled = false; + const loadCapabilities = async () => { + setCapabilityLoading(true); + setCapabilityError(""); + try { + const result = await JVMProbeCapabilities( + buildRpcConnectionConfig(connection.config, { database: "" }) as any, + ); + if (cancelled) { + return; + } + if (result?.success === false) { + setCapabilities([]); + setCapabilityError( + String(result?.message || "读取 JVM 模式能力失败"), + ); + return; + } + setCapabilities( + Array.isArray(result?.data) ? (result.data as JVMCapability[]) : [], + ); + } catch (error: any) { + if (!cancelled) { + setCapabilities([]); + setCapabilityError(error?.message || "读取 JVM 模式能力失败"); + } + } finally { + if (!cancelled) { + setCapabilityLoading(false); + } + } + }; + + void loadCapabilities(); + return () => { + cancelled = true; + }; }, [connection]); if (!connection) { - return ; + return ( + + ); } const jmxHost = connection.config.jvm?.jmx?.host || connection.config.host; const jmxPort = connection.config.jvm?.jmx?.port || connection.config.port; return ( -
+
- + - {readOnly ? '只读连接' : '可写连接'} - {connection.config.jvm?.environment || 'dev'} + + {readOnly ? "只读连接" : "可写连接"} + + {connection.config.jvm?.environment || "dev"} {connection.name} - · {connection.config.host}:{connection.config.port} + + {" "} + · {connection.config.host}:{connection.config.port} + - - {providerMode} - {allowedModes.length > 0 ? allowedModes.join(', ') : 'jmx'} + + + {resolveJVMModeMeta(providerMode).label} + + + {allowedModeSummary} + {`${jmxHost}:${jmxPort}`} - {endpointSummary || '未配置'} - {'通过侧边栏展开模式节点后懒加载'} + + {endpointSummary || "未配置"} + + + {agentSummary || "未配置"} + + + {"通过侧边栏展开模式节点后懒加载"} + + + + {capabilityLoading ? ( + + ) : capabilityError ? ( + + {capabilityError} + + } + /> + ) : capabilities.length === 0 ? ( + + ) : ( + + {capabilities.map((capability) => ( +
+ + + + {capability.canBrowse ? "可浏览" : "不可浏览"} + + + {capability.canWrite ? "可写" : "只读"} + + + {capability.canPreview ? "支持预览" : "不支持预览"} + + + {capability.reason ? ( + + {capability.reason} + + ) : null} +
+ ))} +
+ )} +
); }; diff --git a/frontend/src/components/JVMResourceBrowser.layout.test.tsx b/frontend/src/components/JVMResourceBrowser.layout.test.tsx new file mode 100644 index 0000000..5805079 --- /dev/null +++ b/frontend/src/components/JVMResourceBrowser.layout.test.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import JVMResourceBrowser from './JVMResourceBrowser'; + +vi.mock('@monaco-editor/react', () => ({ + default: ({ language, value }: { language?: string; value?: string }) => ( +
+ {value} +
+ ), +})); + +vi.mock('../store', () => ({ + useStore: (selector: (state: any) => any) => selector({ + connections: [ + { + id: 'conn-jvm-1', + name: 'localhost', + config: { + host: 'localhost', + jvm: { + preferredMode: 'jmx', + readOnly: true, + }, + }, + }, + { + id: 'conn-jvm-2', + name: 'writable-jvm', + config: { + host: 'localhost', + jvm: { + preferredMode: 'jmx', + readOnly: false, + }, + }, + }, + ], + addTab: vi.fn(), + aiPanelVisible: false, + setAIPanelVisible: vi.fn(), + }), +})); + +vi.mock('./jvm/JVMModeBadge', () => ({ + default: ({ mode }: { mode: string }) => {mode}, +})); + +vi.mock('./jvm/JVMChangePreviewModal', () => ({ + default: () => null, +})); + +describe('JVMResourceBrowser layout', () => { + it('renders a dedicated vertical scroll shell for tall snapshot content', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-jvm-resource-browser-scroll-shell="true"'); + expect(markup).toContain('height:100%'); + expect(markup).toContain('overflow-y:auto'); + }); + + it('shows the draft action field with a Chinese label', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('动作'); + expect(markup).not.toContain('>Action<'); + }); + + it('hides the change draft form entirely for read-only JVM connections', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain('变更草稿'); + expect(markup).not.toContain('预览变更'); + expect(markup).not.toContain('Payload(JSON)'); + }); +}); diff --git a/frontend/src/components/JVMResourceBrowser.tsx b/frontend/src/components/JVMResourceBrowser.tsx index cde1b02..c673169 100644 --- a/frontend/src/components/JVMResourceBrowser.tsx +++ b/frontend/src/components/JVMResourceBrowser.tsx @@ -1,9 +1,26 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Alert, Button, Card, Descriptions, Empty, Input, Skeleton, Space, Tag, Typography } from 'antd'; -import { FileSearchOutlined, ReloadOutlined, RobotOutlined } from '@ant-design/icons'; +import React, { useEffect, useMemo, useState } from "react"; +import Editor from "@monaco-editor/react"; +import { + Alert, + Button, + Card, + Descriptions, + Empty, + Input, + Skeleton, + Space, + Tag, + Typography, +} from "antd"; +import { + FileSearchOutlined, + ReloadOutlined, + RobotOutlined, +} from "@ant-design/icons"; -import { useStore } from '../store'; +import { useStore } from "../store"; import type { + JVMActionDefinition, JVMApplyResult, JVMChangePreview, JVMChangeRequest, @@ -11,28 +28,39 @@ import type { JVMValueSnapshot, SavedConnection, TabData, -} from '../types'; -import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +} from "../types"; +import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig"; import { buildJVMChangeDraftFromAIPlan, buildJVMAIPlanPrompt, matchesJVMAIPlanTargetTab, type JVMAIChangeDraft, type JVMAIChangePlan, -} from '../utils/jvmAiPlan'; -import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation'; -import JVMModeBadge from './jvm/JVMModeBadge'; -import JVMChangePreviewModal from './jvm/JVMChangePreviewModal'; +} from "../utils/jvmAiPlan"; +import { + estimateJVMResourceEditorHeight, + formatJVMActionDisplayText, + formatJVMActionSummary, + resolveJVMActionDisplay, + resolveJVMValueEditorLanguage, +} from "../utils/jvmResourcePresentation"; +import { buildJVMTabTitle } from "../utils/jvmRuntimePresentation"; +import JVMModeBadge from "./jvm/JVMModeBadge"; +import JVMChangePreviewModal from "./jvm/JVMChangePreviewModal"; const { Paragraph, Text } = Typography; +const DESCRIPTION_STYLES = { label: { width: 120 } } as const; const { TextArea } = Input; -const DEFAULT_PAYLOAD_TEXT = '{\n \n}'; +const DEFAULT_PAYLOAD_TEXT = "{\n \n}"; type JVMResourceBrowserProps = { tab: TabData; }; -const buildJVMRuntimeConfig = (connection: SavedConnection, providerMode: string) => { +const buildJVMRuntimeConfig = ( + connection: SavedConnection, + providerMode: string, +) => { const sourceJVM = connection.config.jvm || {}; return buildRpcConnectionConfig(connection.config, { jvm: { @@ -43,8 +71,15 @@ const buildJVMRuntimeConfig = (connection: SavedConnection, providerMode: string }); }; +const snapshotBlockStyle = (background: string): React.CSSProperties => ({ + margin: 0, + borderRadius: 8, + background, + overflow: "auto", +}); + const formatValue = (value: unknown): string => { - if (typeof value === 'string') { + if (typeof value === "string") { return value; } try { @@ -58,77 +93,150 @@ const formatDraftPayload = (draft: JVMAIChangeDraft): string => { try { return JSON.stringify(draft.payload ?? {}, null, 2); } catch { - return '{}'; + return "{}"; } }; +const buildActionPayloadTemplate = ( + definition?: JVMActionDefinition | null, +): string => { + if (definition?.payloadExample) { + try { + return JSON.stringify(definition.payloadExample, null, 2); + } catch { + return DEFAULT_PAYLOAD_TEXT; + } + } + return DEFAULT_PAYLOAD_TEXT; +}; + +const resolveDefaultAction = ( + actions: JVMActionDefinition[] | undefined, + providerMode: "jmx" | "endpoint" | "agent", +): string => { + if (actions && actions.length > 0) { + return String(actions[0].action || "").trim() || "put"; + } + if (providerMode === "jmx") { + return "set"; + } + return "put"; +}; + const normalizePreviewResult = (value: any): JVMChangePreview | null => { - if (value && typeof value === 'object' && typeof value.allowed === 'boolean') { + if ( + value && + typeof value === "object" && + typeof value.allowed === "boolean" + ) { return value as JVMChangePreview; } - if (value?.data && typeof value.data.allowed === 'boolean') { + 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') { + if (value && typeof value === "object" && typeof value.status === "string") { return value as JVMApplyResult; } - if (value?.data && typeof value.data.status === 'string') { + 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 connection = useStore((state) => + state.connections.find((item) => item.id === tab.connectionId), + ); 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 theme = useStore((state) => state.theme); + const darkMode = theme === "dark"; + 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('put'); - const [reason, setReason] = useState(''); + const [error, setError] = useState(""); + const [action, setAction] = useState(""); + const [reason, setReason] = useState(""); const [payloadText, setPayloadText] = useState(DEFAULT_PAYLOAD_TEXT); - const [draftResourceId, setDraftResourceId] = useState(''); - const [draftError, setDraftError] = useState(''); - const [applyMessage, setApplyMessage] = useState(''); + const [draftSource, setDraftSource] = useState<"manual" | "ai-plan">( + "manual", + ); + const [draftResourceId, setDraftResourceId] = useState(""); + const [draftError, setDraftError] = useState(""); + const [applyMessage, setApplyMessage] = useState(""); const [previewLoading, setPreviewLoading] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); - const [previewResult, setPreviewResult] = useState(null); + const [previewResult, setPreviewResult] = useState( + null, + ); const [applyLoading, setApplyLoading] = useState(false); const displayValue = useMemo(() => formatValue(snapshot?.value), [snapshot]); + const displayLanguage = useMemo( + () => + resolveJVMValueEditorLanguage(snapshot?.format || "", snapshot?.value), + [snapshot?.format, snapshot?.value], + ); + const metadataText = useMemo( + () => + snapshot?.metadata && Object.keys(snapshot.metadata).length > 0 + ? JSON.stringify(snapshot.metadata, null, 2) + : "", + [snapshot?.metadata], + ); + const metadataLanguage = useMemo( + () => resolveJVMValueEditorLanguage("json", snapshot?.metadata), + [snapshot?.metadata], + ); + const supportedActions = useMemo(() => { + if (!Array.isArray(snapshot?.supportedActions)) { + return [] as JVMActionDefinition[]; + } + return snapshot.supportedActions.filter( + (item) => !!String(item?.action || "").trim(), + ); + }, [snapshot]); + const selectedActionDefinition = useMemo( + () => supportedActions.find((item) => item.action === action) || null, + [action, supportedActions], + ); + const selectedActionDisplay = useMemo( + () => resolveJVMActionDisplay(selectedActionDefinition || action), + [action, selectedActionDefinition], + ); const loadSnapshot = async () => { if (!connection) { setLoading(false); setSnapshot(null); - setError('连接不存在或已被删除'); + setError("连接不存在或已被删除"); return; } if (!resourcePath) { setLoading(false); setSnapshot(null); - setError('资源路径为空'); + setError("资源路径为空"); return; } const backendApp = (window as any).go?.app?.App; - if (typeof backendApp?.JVMGetValue !== 'function') { + if (typeof backendApp?.JVMGetValue !== "function") { setLoading(false); setSnapshot(null); - setError('JVMGetValue 后端方法不可用'); + setError("JVMGetValue 后端方法不可用"); return; } setLoading(true); - setError(''); + setError(""); try { const result = await backendApp.JVMGetValue( buildJVMRuntimeConfig(connection, providerMode), @@ -136,13 +244,13 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { ); if (!result?.success) { setSnapshot(null); - setError(String(result?.message || '读取 JVM 资源失败')); + setError(String(result?.message || "读取 JVM 资源失败")); return; } setSnapshot((result.data || null) as JVMValueSnapshot | null); } catch (err: any) { setSnapshot(null); - setError(err?.message || '读取 JVM 资源失败'); + setError(err?.message || "读取 JVM 资源失败"); } finally { setLoading(false); } @@ -153,16 +261,34 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { }, [connection, providerMode, resourcePath, tab.connectionId]); useEffect(() => { - setAction('put'); - setReason(''); + setAction(""); + setReason(""); setPayloadText(DEFAULT_PAYLOAD_TEXT); - setDraftResourceId(''); - setDraftError(''); - setApplyMessage(''); + setDraftSource("manual"); + setDraftResourceId(""); + setDraftError(""); + setApplyMessage(""); setPreviewOpen(false); setPreviewResult(null); }, [providerMode, resourcePath, tab.connectionId]); + useEffect(() => { + if (action.trim()) { + return; + } + const nextAction = resolveDefaultAction(supportedActions, providerMode); + setAction(nextAction); + const nextDefinition = supportedActions.find( + (item) => item.action === nextAction, + ); + if ( + String(payloadText || "").trim() === "" || + payloadText === DEFAULT_PAYLOAD_TEXT + ) { + setPayloadText(buildActionPayloadTemplate(nextDefinition)); + } + }, [action, payloadText, providerMode, supportedActions]); + useEffect(() => { const handler = (event: Event) => { const detail = (event as CustomEvent).detail as @@ -170,7 +296,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { plan?: JVMAIChangePlan; targetTabId?: string; connectionId?: string; - providerMode?: JVMAIPlanContext['providerMode']; + providerMode?: JVMAIPlanContext["providerMode"]; resourcePath?: string; } | undefined; @@ -180,7 +306,10 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { } const planContext = - detail?.targetTabId && detail?.connectionId && detail?.providerMode && detail?.resourcePath + detail?.targetTabId && + detail?.connectionId && + detail?.providerMode && + detail?.resourcePath ? { tabId: detail.targetTabId, connectionId: detail.connectionId, @@ -190,16 +319,20 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { : undefined; if (!planContext) { - setDraftError('AI 计划缺少来源上下文,请在目标 JVM 资源页重新生成后再应用。'); - setApplyMessage(''); + setDraftError( + "AI 计划缺少来源上下文,请在目标 JVM 资源页重新生成后再应用。", + ); + setApplyMessage(""); setPreviewOpen(false); setPreviewResult(null); return; } if (!matchesJVMAIPlanTargetTab(tab, planContext)) { - setDraftError('当前 JVM 页签与 AI 计划的来源上下文不一致,已拒绝自动应用。'); - setApplyMessage(''); + setDraftError( + "当前 JVM 页签与 AI 计划的来源上下文不一致,已拒绝自动应用。", + ); + setApplyMessage(""); setPreviewOpen(false); setPreviewResult(null); return; @@ -209,8 +342,8 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { try { draftFromPlan = buildJVMChangeDraftFromAIPlan(plan); } catch (err: any) { - setDraftError(err?.message || 'AI 计划暂时无法转换为 JVM 预览草稿'); - setApplyMessage(''); + setDraftError(err?.message || "AI 计划暂时无法转换为 JVM 预览草稿"); + setApplyMessage(""); setPreviewOpen(false); setPreviewResult(null); return; @@ -220,36 +353,67 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { setAction(draftFromPlan.action); setReason(draftFromPlan.reason); setPayloadText(formatDraftPayload(draftFromPlan)); - setDraftError(''); - setApplyMessage(`已从 AI 计划填充草稿,目标资源为 ${draftFromPlan.resourceId},请先执行“预览变更”再确认写入。`); + setDraftSource(draftFromPlan.source || "ai-plan"); + setDraftError(""); + setApplyMessage( + `已从 AI 计划填充草稿,目标资源为 ${draftFromPlan.resourceId},请先执行“预览变更”再确认写入。`, + ); setPreviewOpen(false); setPreviewResult(null); }; - window.addEventListener('gonavi:jvm-apply-ai-plan', handler as EventListener); - return () => window.removeEventListener('gonavi:jvm-apply-ai-plan', handler as EventListener); + window.addEventListener( + "gonavi:jvm-apply-ai-plan", + handler as EventListener, + ); + return () => + window.removeEventListener( + "gonavi:jvm-apply-ai-plan", + handler as EventListener, + ); }, [resourcePath, tab.id]); + const handleSelectAction = ( + nextAction: string, + definition?: JVMActionDefinition | null, + ) => { + const normalized = String(nextAction || "").trim(); + setAction(normalized); + if (!normalized) { + return; + } + const currentPayload = String(payloadText || "").trim(); + if ( + !currentPayload || + currentPayload === "{}" || + payloadText === DEFAULT_PAYLOAD_TEXT + ) { + setPayloadText(buildActionPayloadTemplate(definition)); + } + }; + const buildDraftPlan = (): JVMChangeRequest => { - const trimmedAction = String(action || '').trim() || 'put'; - const trimmedReason = String(reason || '').trim(); + const trimmedAction = String(action || "").trim() || "put"; + const trimmedReason = String(reason || "").trim(); if (!trimmedReason) { - throw new Error('请填写变更原因'); + throw new Error("请填写变更原因"); } - const rawPayload = String(payloadText || '').trim(); + 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 对象'); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Payload 必须是 JSON 对象"); } payload = parsed as Record; } - const resourceId = String(draftResourceId || snapshot?.resourceId || resourcePath).trim(); + const resourceId = String( + draftResourceId || snapshot?.resourceId || resourcePath, + ).trim(); if (!resourceId) { - throw new Error('资源 ID 为空,无法生成变更草稿'); + throw new Error("资源 ID 为空,无法生成变更草稿"); } return { @@ -257,6 +421,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { resourceId, action: trimmedAction, reason: trimmedReason, + source: draftSource, expectedVersion: snapshot?.version || undefined, payload, }; @@ -269,8 +434,8 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { addTab({ id: `jvm-audit-${connection.id}-${providerMode}`, - title: buildJVMTabTitle(connection.name, 'audit', providerMode), - type: 'jvm-audit', + title: buildJVMTabTitle(connection.name, "audit", providerMode), + type: "jvm-audit", connectionId: connection.id, providerMode, }); @@ -278,7 +443,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { const handleAskAIForPlan = () => { if (!connection) { - setDraftError('连接不存在或已被删除'); + setDraftError("连接不存在或已被删除"); return; } @@ -297,20 +462,25 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { if (wasClosed) { store.setAIPanelVisible(true); } - setTimeout(() => { - window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } })); - }, wasClosed ? 350 : 0); + setTimeout( + () => { + window.dispatchEvent( + new CustomEvent("gonavi:ai:inject-prompt", { detail: { prompt } }), + ); + }, + wasClosed ? 350 : 0, + ); }; const handlePreview = async () => { if (!connection) { - setDraftError('连接不存在或已被删除'); + setDraftError("连接不存在或已被删除"); return; } const backendApp = (window as any).go?.app?.App; - if (typeof backendApp?.JVMPreviewChange !== 'function') { - setDraftError('JVMPreviewChange 后端方法不可用'); + if (typeof backendApp?.JVMPreviewChange !== "function") { + setDraftError("JVMPreviewChange 后端方法不可用"); return; } @@ -318,13 +488,13 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { try { draftPlan = buildDraftPlan(); } catch (err: any) { - setDraftError(err?.message || '变更草稿不合法'); + setDraftError(err?.message || "变更草稿不合法"); return; } setPreviewLoading(true); - setDraftError(''); - setApplyMessage(''); + setDraftError(""); + setApplyMessage(""); try { const result = await backendApp.JVMPreviewChange( buildJVMRuntimeConfig(connection, providerMode), @@ -333,7 +503,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { if (result?.success === false) { setPreviewResult(null); setPreviewOpen(false); - setDraftError(String(result?.message || '预览 JVM 变更失败')); + setDraftError(String(result?.message || "预览 JVM 变更失败")); return; } @@ -341,7 +511,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { if (!preview) { setPreviewResult(null); setPreviewOpen(false); - setDraftError('预览结果格式不正确'); + setDraftError("预览结果格式不正确"); return; } @@ -350,7 +520,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { } catch (err: any) { setPreviewResult(null); setPreviewOpen(false); - setDraftError(err?.message || '预览 JVM 变更失败'); + setDraftError(err?.message || "预览 JVM 变更失败"); } finally { setPreviewLoading(false); } @@ -358,13 +528,13 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { const handleApply = async () => { if (!connection) { - setDraftError('连接不存在或已被删除'); + setDraftError("连接不存在或已被删除"); return; } const backendApp = (window as any).go?.app?.App; - if (typeof backendApp?.JVMApplyChange !== 'function') { - setDraftError('JVMApplyChange 后端方法不可用'); + if (typeof backendApp?.JVMApplyChange !== "function") { + setDraftError("JVMApplyChange 后端方法不可用"); return; } @@ -372,20 +542,20 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { try { draftPlan = buildDraftPlan(); } catch (err: any) { - setDraftError(err?.message || '变更草稿不合法'); + setDraftError(err?.message || "变更草稿不合法"); return; } setApplyLoading(true); - setDraftError(''); - setApplyMessage(''); + setDraftError(""); + setApplyMessage(""); try { const result = await backendApp.JVMApplyChange( buildJVMRuntimeConfig(connection, providerMode), draftPlan, ); if (result?.success === false) { - setDraftError(String(result?.message || '执行 JVM 变更失败')); + setDraftError(String(result?.message || "执行 JVM 变更失败")); return; } @@ -396,41 +566,91 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { setPreviewOpen(false); setPreviewResult(null); - setApplyMessage(applyResult?.message || result?.message || 'JVM 变更已执行'); + setApplyMessage( + applyResult?.message || result?.message || "JVM 变更已执行", + ); await loadSnapshot(); } catch (err: any) { - setDraftError(err?.message || '执行 JVM 变更失败'); + setDraftError(err?.message || "执行 JVM 变更失败"); } finally { setApplyLoading(false); } }; if (!connection) { - return ; + return ( + + ); } return ( <> -
+ +
- + - {readOnly ? '只读连接' : '可写连接'} - - - {connection.name} - {resourcePath || '-'} + {resourcePath || "-"} @@ -438,104 +658,229 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { {loading ? ( ) : ( - + {error ? : null} {snapshot ? ( <> - - {snapshot.resourceId || '-'} - {snapshot.kind || tab.resourceKind || '-'} - {snapshot.format || '-'} - {snapshot.version || '-'} - -
-                    {displayValue}
-                  
- {snapshot.metadata && Object.keys(snapshot.metadata).length > 0 ? ( -
+                      {snapshot.resourceId || "-"}
+                    
+                    
+                      {snapshot.kind || tab.resourceKind || "-"}
+                    
+                    
+                      {snapshot.format || "-"}
+                    
+                    
+                      {snapshot.version || "-"}
+                    
+                    
+                      {formatJVMActionSummary(supportedActions)}
+                    
+                  
+                  {snapshot.description ? (
+                    {snapshot.description}
+                  ) : null}
+                  
+ + 资源值 + +
- {JSON.stringify(snapshot.metadata, null, 2)} -
+ +
+
+ {metadataText ? ( +
+ + 元数据 + +
+ +
+
) : null} - ) : error ? null : } + ) : error ? null : ( + + )} )} - - - {readOnly ? ( - - ) : null} - {draftError ? : null} - {applyMessage ? : null} - - {resourcePath || '-'} - {draftResourceId || resourcePath || '-'} - {snapshot?.version || '-'} - - - Action - setAction(event.target.value)} - placeholder="例如 put" - maxLength={64} - /> + {!readOnly ? ( + + + {draftError ? ( + + ) : null} + {applyMessage ? ( + + ) : null} + + + {resourcePath || "-"} + + + {draftResourceId || resourcePath || "-"} + + + {snapshot?.version || "-"} + + + {draftSource === "ai-plan" ? "AI 辅助草稿" : "手工编辑"} + + + {supportedActions.length > 0 ? ( + + 资源支持动作 + + {supportedActions.map((item) => ( + + ))} + + {selectedActionDisplay.description ? ( + + {selectedActionDisplay.description} + + ) : null} + {selectedActionDefinition?.payloadFields?.length ? ( + + Payload 字段: + {selectedActionDefinition.payloadFields + .map( + (field) => + `${field.name}${field.required ? "(必填)" : ""}`, + ) + .join("、")} + + ) : null} + + ) : null} + + 动作 + + handleSelectAction( + event.target.value, + selectedActionDefinition, + ) + } + placeholder={ + providerMode === "jmx" + ? "例如 set 或 invoke" + : "例如 put / clear / evict" + } + maxLength={64} + /> + {action ? ( + + 当前动作: + {formatJVMActionDisplayText(selectedActionDisplay)} + + ) : null} + + + 变更原因 + setReason(event.target.value)} + placeholder="填写本次 JVM 资源变更原因" + maxLength={200} + /> + + + Payload(JSON) + + 需要输入 JSON 对象,预览和执行都会直接使用这份 payload。 + {selectedActionDefinition?.payloadExample + ? " 已按当前动作填充推荐模板。" + : ""} + +