diff --git a/.gitignore b/.gitignore index 8aca869..af09dda 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ GoNavi-Wails.exe .superpowers/ .claude/ .gemini/ +.playwright-mcp/ **/tmpclaude-* docs/superpowers/ docs/需求追踪/ @@ -28,4 +29,4 @@ CLAUDE.md **/CLAUDE.md .worktrees docs -.tmp_superpowers_edit \ No newline at end of file +.tmp_superpowers_edit diff --git a/build-release.sh b/build-release.sh index d2bc228..edb9fe3 100755 --- a/build-release.sh +++ b/build-release.sh @@ -1,16 +1,42 @@ #!/bin/bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + # 配置 APP_NAME="GoNavi" DIST_DIR="dist" BUILD_BIN_DIR="build/bin" DEFAULT_BINARY_NAME="GoNavi" # 对应 wails.json 中的 outputfilename +DEV_VERSION_FILE="version/dev-version.txt" +DEFAULT_DEV_VERSION="0.0.1-test" -# 提取版本号 -VERSION=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]') -if [ -z "$VERSION" ]; then - VERSION="0.0.0" -fi +resolve_build_version() { + if [ -n "${GONAVI_VERSION:-}" ]; then + printf '%s\n' "${GONAVI_VERSION}" + return + fi + + if [ -f "$DEV_VERSION_FILE" ]; then + local dev_version + dev_version=$(head -n 1 "$DEV_VERSION_FILE" | tr -d '\r' | tr -d '[:space:]') + if [ -n "$dev_version" ]; then + printf '%s\n' "$dev_version" + return + fi + fi + + local package_version + package_version=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[:space:]') + if [ -n "$package_version" ]; then + printf '%s\n' "$package_version" + return + fi + + printf '%s\n' "$DEFAULT_DEV_VERSION" +} + +VERSION="$(resolve_build_version)" echo "ℹ️ 检测到版本号: $VERSION" LDFLAGS="-s -w -X GoNavi-Wails/internal/app.AppVersion=$VERSION" @@ -94,280 +120,79 @@ clear_macos_bundle_xattrs() { fi } -verify_macos_dmg_bundle_signature() { - local dmg_path="$1" - local mount_dir="" - local app_path="" +package_macos_bundle_zip() { + local app_path="$1" + local archive_path="$2" + local archive_abs - if [ -z "$dmg_path" ] || [ ! -f "$dmg_path" ]; then - echo -e "${RED} ❌ DMG 文件不存在,无法校验签名:$dmg_path${NC}" - return 1 - fi - if ! command -v hdiutil >/dev/null 2>&1 || ! command -v codesign >/dev/null 2>&1; then - echo -e "${YELLOW} ⚠️ 当前环境缺少 hdiutil 或 codesign,跳过 DMG 内应用签名校验。${NC}" - return 0 + if [ ! -d "$app_path" ]; then + echo -e "${RED} ❌ 未找到 macOS 应用包:$app_path${NC}" + exit 1 fi - mount_dir=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-dmg-verify.XXXXXX") - if [ -z "$mount_dir" ] || [ ! -d "$mount_dir" ]; then - echo -e "${RED} ❌ 创建 DMG 校验挂载目录失败。${NC}" - return 1 + archive_abs="$(cd "$(dirname "$archive_path")" && pwd)/$(basename "$archive_path")" + rm -f "$archive_path" + if command -v ditto >/dev/null 2>&1; then + ditto -c -k --sequesterRsrc --keepParent "$app_path" "$archive_abs" + elif command -v zip >/dev/null 2>&1; then + ( + cd "$(dirname "$app_path")" && \ + zip -qry "$archive_abs" "$(basename "$app_path")" + ) + else + echo -e "${RED} ❌ 未找到 ditto/zip,无法打包 macOS 应用。${NC}" + exit 1 fi - if ! hdiutil attach -nobrowse -readonly -mountpoint "$mount_dir" "$dmg_path" >/dev/null 2>&1; then - rmdir "$mount_dir" >/dev/null 2>&1 || true - echo -e "${RED} ❌ 挂载 DMG 失败,无法校验签名。${NC}" - return 1 + if [ ! -f "$archive_abs" ]; then + echo -e "${RED} ❌ macOS 应用归档失败:$archive_abs${NC}" + exit 1 fi - - app_path=$(find "$mount_dir" -maxdepth 1 -name "*.app" -print -quit) - if [ -z "$app_path" ] || [ ! -d "$app_path" ]; then - hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true - rmdir "$mount_dir" >/dev/null 2>&1 || true - echo -e "${RED} ❌ DMG 内未找到 .app 应用包。${NC}" - return 1 - fi - - if ! codesign --verify --deep --strict --verbose=4 "$app_path" >/dev/null 2>&1; then - echo -e "${RED} ❌ DMG 内 .app 签名校验失败:$(basename "$app_path")${NC}" - codesign --verify --deep --strict --verbose=4 "$app_path" 2>&1 | sed 's/^/ /' - hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true - rmdir "$mount_dir" >/dev/null 2>&1 || true - return 1 - fi - - hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true - rmdir "$mount_dir" >/dev/null 2>&1 || true - return 0 } -MAC_VOLICON_PATH="build/darwin/icon.icns" -if [ ! -f "$MAC_VOLICON_PATH" ]; then - MAC_VOLICON_PATH="" -fi +package_macos_release() { + local platform="$1" + local archive_suffix="$2" + + echo -e "${GREEN}🍎 正在构建 macOS (${platform})...${NC}" + wails build -platform "darwin/${platform}" -clean -ldflags "$LDFLAGS" + if [ $? -ne 0 ]; then + echo -e "${RED} ❌ macOS ${platform} 构建失败。${NC}" + return + fi + + local app_src="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app" + local app_dest_name="${APP_NAME}-${VERSION}-${archive_suffix}.app" + local zip_name="${APP_NAME}-${VERSION}-${archive_suffix}.zip" + + mv "$app_src" "$DIST_DIR/$app_dest_name" + + local app_bin_path + app_bin_path=$(find "$DIST_DIR/$app_dest_name/Contents/MacOS" -maxdepth 1 -type f -print -quit) + if [ -z "$app_bin_path" ] || [ ! -f "$app_bin_path" ]; then + echo -e "${RED} ❌ 未找到 macOS ${platform} 主程序文件。${NC}" + exit 1 + fi + + echo -e "${YELLOW} ⚠️ macOS ${platform} 改为无交互 ZIP 打包,不再生成 DMG。${NC}" + echo " 🔏 正在对 .app 进行 ad-hoc 签名 (${platform})..." + clear_macos_bundle_xattrs "$DIST_DIR/$app_dest_name" + codesign --force --deep --sign - "$DIST_DIR/$app_dest_name" + + echo " 📦 正在打包 macOS 应用归档 (${platform})..." + package_macos_bundle_zip "$DIST_DIR/$app_dest_name" "$DIST_DIR/$zip_name" + rm -rf "$DIST_DIR/$app_dest_name" + echo " ✅ 已生成 $zip_name" +} echo -e "${GREEN}🚀 开始构建 $APP_NAME $VERSION...${NC}" # 清理并创建输出目录 -rm -rf $DIST_DIR -mkdir -p $DIST_DIR +rm -rf "$DIST_DIR" +mkdir -p "$DIST_DIR" -# --- macOS ARM64 构建 --- -echo -e "${GREEN}🍎 正在构建 macOS (arm64)...${NC}" -wails build -platform darwin/arm64 -clean -ldflags "$LDFLAGS" -if [ $? -eq 0 ]; then - APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app" - APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-arm64.app" - DMG_NAME="${APP_NAME}-${VERSION}-mac-arm64.dmg" - - # 移动 .app 到 dist - mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME" - - APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit) - if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then - echo -e "${YELLOW} ⚠️ macOS arm64 不再执行 UPX 压缩,保留原始主程序。${NC}" - else - echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件。${NC}" - exit 1 - fi - - # Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏) - echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..." - clear_macos_bundle_xattrs "$DIST_DIR/$APP_DEST_NAME" - codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME" - - # 创建 DMG - if command -v create-dmg &> /dev/null; then - echo " 📦 正在打包 DMG (arm64)..." - # 移除已存在的 DMG (以防万一) - rm -f "$DIST_DIR/$DMG_NAME" - # create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。 - STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-arm64.XXXXXX") - if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then - echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}" - else - if command -v ditto &> /dev/null; then - ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME" - else - cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME" - fi - - # 注意:本地验证表明 `--sandbox-safe` 与“目录作为 source”组合会污染 DMG 内 .app 的扩展属性, - # 导致签名校验失败,因此这里显式禁用该参数,优先保证产物可打开。 - CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO) - if [ -n "$MAC_VOLICON_PATH" ]; then - CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH") - else - echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon。${NC}" - fi - - create-dmg "${CREATE_DMG_ARGS[@]}" \ - --window-pos 200 120 \ - --window-size 800 400 \ - --icon-size 100 \ - --icon "$APP_DEST_NAME" 200 190 \ - --hide-extension "$APP_DEST_NAME" \ - --app-drop-link 600 185 \ - "$DIST_DIR/$DMG_NAME" \ - "$STAGE_DIR" - - CREATE_DMG_EXIT_CODE=$? - rm -rf "$STAGE_DIR" - - if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then - echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}" - else - # create-dmg 可能会在失败时遗留 rw.*.dmg 中间产物;不要直接当作最终产物使用 - if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then - RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit) - if [ -n "$RW_FILE" ]; then - echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}" - hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1 - rm -f "$RW_FILE" - fi - fi - - # 防御性:即使生成了目标文件,也要确保不是 UDRW(UDRW 在 Finder 下可能表现为“已损坏/无法打开”) - if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then - DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}') - if [ "$DMG_FORMAT" = "UDRW" ]; then - echo -e "${YELLOW} ⚠️ 检测到 UDRW(可写原始映像),正在转换为 UDZO...${NC}" - TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME" - rm -f "$TMP_UDZO" - hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME" - fi - fi - - if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then - hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1 - if [ $? -ne 0 ]; then - echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}" - elif ! verify_macos_dmg_bundle_signature "$DIST_DIR/$DMG_NAME"; then - echo -e "${RED} ❌ DMG 内应用签名校验失败,保留 .app 与 .dmg 以便排查。${NC}" - else - # 删除中间的 .app 文件,保持目录整洁 - rm -rf "$DIST_DIR/$APP_DEST_NAME" - echo " ✅ 已生成 $DMG_NAME" - fi - fi - fi - - if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then - echo -e "${RED} ❌ DMG 生成失败,请检查 create-dmg 输出。${NC}" - fi - fi - else - echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具,跳过 DMG 打包,仅保留 .app。${NC}" - echo " 安装命令: brew install create-dmg" - fi - else - echo -e "${RED} ❌ macOS arm64 构建失败。${NC}" -fi - -# --- macOS AMD64 构建 --- -echo -e "${GREEN}🍎 正在构建 macOS (amd64)...${NC}" -wails build -platform darwin/amd64 -clean -ldflags "$LDFLAGS" -if [ $? -eq 0 ]; then - APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app" - APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-amd64.app" - DMG_NAME="${APP_NAME}-${VERSION}-mac-amd64.dmg" - - mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME" - - APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit) - if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then - echo -e "${YELLOW} ⚠️ macOS amd64 不再执行 UPX 压缩,保留原始主程序。${NC}" - else - echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件。${NC}" - exit 1 - fi - - # Ad-hoc 代码签名 - echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..." - clear_macos_bundle_xattrs "$DIST_DIR/$APP_DEST_NAME" - codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME" - - if command -v create-dmg &> /dev/null; then - echo " 📦 正在打包 DMG (amd64)..." - rm -f "$DIST_DIR/$DMG_NAME" - # create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。 - STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-amd64.XXXXXX") - if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then - echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}" - else - if command -v ditto &> /dev/null; then - ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME" - else - cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME" - fi - - # 注意:本地验证表明 `--sandbox-safe` 与“目录作为 source”组合会污染 DMG 内 .app 的扩展属性, - # 导致签名校验失败,因此这里显式禁用该参数,优先保证产物可打开。 - CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO) - if [ -n "$MAC_VOLICON_PATH" ]; then - CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH") - else - echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon。${NC}" - fi - - create-dmg "${CREATE_DMG_ARGS[@]}" \ - --window-pos 200 120 \ - --window-size 800 400 \ - --icon-size 100 \ - --icon "$APP_DEST_NAME" 200 190 \ - --hide-extension "$APP_DEST_NAME" \ - --app-drop-link 600 185 \ - "$DIST_DIR/$DMG_NAME" \ - "$STAGE_DIR" - - CREATE_DMG_EXIT_CODE=$? - rm -rf "$STAGE_DIR" - - if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then - echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}" - else - if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then - RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit) - if [ -n "$RW_FILE" ]; then - echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}" - hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1 - rm -f "$RW_FILE" - fi - fi - - if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then - DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}') - if [ "$DMG_FORMAT" = "UDRW" ]; then - echo -e "${YELLOW} ⚠️ 检测到 UDRW(可写原始映像),正在转换为 UDZO...${NC}" - TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME" - rm -f "$TMP_UDZO" - hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME" - fi - fi - - if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then - hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1 - if [ $? -ne 0 ]; then - echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}" - elif ! verify_macos_dmg_bundle_signature "$DIST_DIR/$DMG_NAME"; then - echo -e "${RED} ❌ DMG 内应用签名校验失败,保留 .app 与 .dmg 以便排查。${NC}" - else - rm -rf "$DIST_DIR/$APP_DEST_NAME" - echo " ✅ 已生成 $DMG_NAME" - fi - fi - fi - - if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then - echo -e "${RED} ❌ DMG 生成失败。${NC}" - fi - fi - else - echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具。${NC}" - fi - else - echo -e "${RED} ❌ macOS amd64 构建失败。${NC}" -fi +package_macos_release "arm64" "mac-arm64" +package_macos_release "amd64" "mac-amd64" # --- Windows AMD64 构建 --- echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}" diff --git a/docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md b/docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md new file mode 100644 index 0000000..ce3665d --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md @@ -0,0 +1,1432 @@ +# JVM Connector MVP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 GoNavi 中落地 JVM Connector MVP,首期支持 JMX + Management Endpoint 两种接入模式,覆盖连接测试、能力探测、资源浏览、受控预览/写入、审计记录和 AI 变更计划生成。 + +**Architecture:** 复用 GoNavi 现有的“Redis 式独立能力线”,新增 `internal/jvm` 后端包和一组 JVM 专用前端组件,而不是复用 SQL `Database` 接口。所有写操作统一通过 Guard + Preview + Audit 链路,AI 只生成结构化变更计划,不直接执行。 + +**Tech Stack:** Go 1.24, Wails v2, React 18, TypeScript, Zustand, Ant Design 5, Vitest + +--- + +## File Map + +- Modify: `internal/connection/types.go` + - 为 `ConnectionConfig` 增加 `JVMConfig`、JMX/Endpoint 可选配置,保持现有连接持久化链路可复用。 +- Create: `internal/jvm/types.go` + - JVM 能力、资源、值快照、变更预览、审计记录等 DTO。 +- Create: `internal/jvm/config.go` + - 运行模式归一化、只读/生产保护、模式可用性判断。 +- Create: `internal/jvm/provider.go` + - Provider 接口、注册与按模式分发。 +- Create: `internal/jvm/jmx_provider.go` + - JMX Provider 实现。 +- Create: `internal/jvm/http_provider.go` + - Management Endpoint Provider 实现。 +- Create: `internal/jvm/guard.go` + - 写入前预览、权限保护和风险等级判断。 +- Create: `internal/jvm/audit_store.go` + - JSONL 审计落盘与查询。 +- Create: `internal/jvm/config_test.go` + - JVM 配置归一化和保护规则测试。 +- Create: `internal/app/methods_jvm.go` + - Wails 暴露的 JVM 读写方法。 +- Create: `internal/app/methods_jvm_test.go` + - App 层对 fake provider 的集成测试。 +- Modify: `frontend/src/types.ts` + - 新增 JVM 连接配置、资源模型、TabData 扩展。 +- Create: `frontend/src/utils/jvmConnectionConfig.ts` + - JVM 连接默认值、表单转配置、模式标签和默认端口。 +- Create: `frontend/src/utils/jvmConnectionConfig.test.ts` + - JVM 表单配置转换测试。 +- Create: `frontend/src/utils/jvmRuntimePresentation.ts` + - 模式徽标、审计风险文案、JVM tab 标题构造。 +- Create: `frontend/src/utils/jvmRuntimePresentation.test.ts` + - 展示层纯函数测试。 +- Modify: `frontend/src/components/DatabaseIcons.tsx` + - 增加 JVM 图标映射。 +- Modify: `frontend/src/components/ConnectionModal.tsx` + - 新增 JVM 连接类型与表单。 +- Modify: `frontend/src/components/Sidebar.tsx` + - 新增 JVM 节点、懒加载和资源打开动作。 +- Modify: `frontend/src/components/TabManager.tsx` + - 路由 JVM 新 Tab。 +- Create: `frontend/src/components/JVMOverview.tsx` + - 展示连接能力矩阵与风险提示。 +- Create: `frontend/src/components/JVMResourceBrowser.tsx` + - 资源树、值快照和写入入口。 +- Create: `frontend/src/components/JVMAuditViewer.tsx` + - JVM 审计记录查看器。 +- Create: `frontend/src/components/jvm/JVMModeBadge.tsx` + - 统一渲染 `JMX` / `Endpoint` / `只读` / `可写` 徽标。 +- Create: `frontend/src/components/jvm/JVMChangePreviewModal.tsx` + - 写入预览与确认对话框。 +- Create: `frontend/src/utils/jvmAiPlan.ts` + - 解析和校验 AI 结构化变更计划。 +- Create: `frontend/src/utils/jvmAiPlan.test.ts` + - AI 计划解析测试。 +- Modify: `frontend/src/components/AIChatPanel.tsx` + - 向 JVM tab 注入上下文与推荐 prompt。 +- Modify: `frontend/src/components/ai/AIMessageBubble.tsx` + - 检测 JVM 结构化计划,提供“应用到预览”按钮。 +- Regenerate: `frontend/wailsjs/go/app/App.d.ts`, `frontend/wailsjs/go/app/App.js`, `frontend/wailsjs/go/models.ts` + - 由 Wails 命令生成,不手工编辑。 +- Modify: `docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md` + - 记录计划文件、实施进度和验证证据。 + +## Task 1: 定义 JVM 共享契约与配置归一化 + +**Files:** +- Create: `internal/jvm/types.go` +- Create: `internal/jvm/config.go` +- Create: `internal/jvm/config_test.go` +- Modify: `internal/connection/types.go` +- Create: `frontend/src/utils/jvmConnectionConfig.ts` +- Create: `frontend/src/utils/jvmConnectionConfig.test.ts` +- Modify: `frontend/src/types.ts` + +- [ ] **Step 1: 写后端失败测试,锁定 JVM 模式归一化和默认保护规则** + +```go +package jvm + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestNormalizeConnectionConfigDefaultsToReadOnlyJMX(t *testing.T) { + raw := connection.ConnectionConfig{ + Type: "jvm", + Host: "orders-prod.internal", + Port: 9010, + } + + got, err := NormalizeConnectionConfig(raw) + if err != nil { + t.Fatalf("NormalizeConnectionConfig returned error: %v", err) + } + if !got.JVM.ReadOnly { + t.Fatalf("expected JVM connection to default to readOnly") + } + if got.JVM.PreferredMode != ModeJMX { + t.Fatalf("expected preferred mode %q, got %q", ModeJMX, got.JVM.PreferredMode) + } + if len(got.JVM.AllowedModes) != 1 || got.JVM.AllowedModes[0] != ModeJMX { + t.Fatalf("expected allowed modes [jmx], got %#v", got.JVM.AllowedModes) + } + if got.JVM.JMX.Port != 9010 { + t.Fatalf("expected JMX port to inherit root port 9010, got %d", got.JVM.JMX.Port) + } +} + +func TestNormalizeConnectionConfigFallsBackToFirstAllowedMode(t *testing.T) { + raw := connection.ConnectionConfig{ + Type: "jvm", + Host: "cache-svc.internal", + JVM: connection.JVMConfig{ + AllowedModes: []string{ModeEndpoint, ModeJMX}, + PreferredMode: ModeAgent, + Endpoint: connection.JVMEndpointConfig{ + Enabled: true, + BaseURL: "https://cache-svc.internal/manage/jvm", + }, + }, + } + + got, err := NormalizeConnectionConfig(raw) + if err != nil { + t.Fatalf("NormalizeConnectionConfig returned error: %v", err) + } + if got.JVM.PreferredMode != ModeEndpoint { + t.Fatalf("expected preferred mode %q, got %q", ModeEndpoint, got.JVM.PreferredMode) + } +} +``` + +- [ ] **Step 2: 运行测试,确认 `internal/jvm` 还不存在导致失败** + +Run: `go test ./internal/jvm -run TestNormalizeConnectionConfig -count=1` + +Expected: FAIL,提示 `GoNavi-Wails/internal/jvm` 尚不存在或 `NormalizeConnectionConfig` 未定义。 + +- [ ] **Step 3: 实现后端 JVM 类型与归一化规则** + +```go +package connection + +type JVMJMXConfig struct { + Enabled bool `json:"enabled,omitempty"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + DomainAllowlist []string `json:"domainAllowlist,omitempty"` +} + +type JVMEndpointConfig struct { + Enabled bool `json:"enabled,omitempty"` + BaseURL string `json:"baseUrl,omitempty"` + APIKey string `json:"apiKey,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` +} + +type JVMConfig struct { + Environment string `json:"environment,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + AllowedModes []string `json:"allowedModes,omitempty"` + PreferredMode string `json:"preferredMode,omitempty"` + JMX JVMJMXConfig `json:"jmx,omitempty"` + Endpoint JVMEndpointConfig `json:"endpoint,omitempty"` +} +``` + +```go +package jvm + +import ( + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +const ( + ModeJMX = "jmx" + ModeEndpoint = "endpoint" + ModeAgent = "agent" + EnvPROD = "prod" +) + +type Capability struct { + Mode string `json:"mode"` + CanBrowse bool `json:"canBrowse"` + CanWrite bool `json:"canWrite"` + CanPreview bool `json:"canPreview"` + Reason string `json:"reason,omitempty"` + DisplayLabel string `json:"displayLabel"` +} + +type ResourceSummary struct { + ID string `json:"id"` + ParentID string `json:"parentId,omitempty"` + Kind string `json:"kind"` + Name string `json:"name"` + Path string `json:"path"` + ProviderMode string `json:"providerMode"` + CanRead bool `json:"canRead"` + CanWrite bool `json:"canWrite"` + HasChildren bool `json:"hasChildren"` + Sensitive bool `json:"sensitive,omitempty"` +} + +type ValueSnapshot struct { + ResourceID string `json:"resourceId"` + Kind string `json:"kind"` + Format string `json:"format"` + Version string `json:"version,omitempty"` + Value interface{} `json:"value"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type ChangeRequest struct { + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + ExpectedVersion string `json:"expectedVersion,omitempty"` + Payload map[string]any `json:"payload,omitempty"` +} + +type ChangePreview struct { + Allowed bool `json:"allowed"` + RequiresConfirmation bool `json:"requiresConfirmation,omitempty"` + Summary string `json:"summary"` + RiskLevel string `json:"riskLevel"` + BlockingReason string `json:"blockingReason,omitempty"` + Before ValueSnapshot `json:"before"` + After ValueSnapshot `json:"after"` +} + +type ApplyResult struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + UpdatedValue ValueSnapshot `json:"updatedValue"` +} + +type AuditRecord struct { + Timestamp int64 `json:"timestamp"` + ConnectionID string `json:"connectionId"` + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + Result string `json:"result"` +} + +func NormalizeConnectionConfig(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + cfg := raw + if strings.TrimSpace(cfg.Type) != "jvm" { + return connection.ConnectionConfig{}, fmt.Errorf("unexpected connection type: %s", cfg.Type) + } + cfg.Type = "jvm" + cfg.JVM.Environment = strings.ToLower(strings.TrimSpace(cfg.JVM.Environment)) + if cfg.JVM.ReadOnly == false { + cfg.JVM.ReadOnly = true + } + if cfg.JVM.JMX.Port <= 0 { + cfg.JVM.JMX.Port = cfg.Port + } + if len(cfg.JVM.AllowedModes) == 0 { + cfg.JVM.AllowedModes = []string{ModeJMX} + } + cfg.JVM.AllowedModes = normalizeModes(cfg.JVM.AllowedModes) + if cfg.JVM.PreferredMode == "" || !containsMode(cfg.JVM.AllowedModes, cfg.JVM.PreferredMode) { + cfg.JVM.PreferredMode = cfg.JVM.AllowedModes[0] + } + return cfg, nil +} + +func normalizeModes(input []string) []string { + result := make([]string, 0, len(input)) + seen := map[string]struct{}{} + for _, item := range input { + mode := strings.ToLower(strings.TrimSpace(item)) + switch mode { + case ModeJMX, ModeEndpoint, ModeAgent: + default: + continue + } + if _, ok := seen[mode]; ok { + continue + } + seen[mode] = struct{}{} + result = append(result, mode) + } + if len(result) == 0 { + return []string{ModeJMX} + } + return result +} + +func containsMode(items []string, target string) bool { + target = strings.ToLower(strings.TrimSpace(target)) + for _, item := range items { + if strings.ToLower(strings.TrimSpace(item)) == target { + return true + } + } + return false +} +``` + +- [ ] **Step 4: 写前端 JVM 默认值与配置转换的失败测试** + +```ts +import { describe, expect, it } from 'vitest'; +import { buildDefaultJVMConnectionValues, buildJVMConnectionConfig } from './jvmConnectionConfig'; + +describe('jvmConnectionConfig', () => { + it('defaults to readonly jmx mode', () => { + const values = buildDefaultJVMConnectionValues(); + expect(values.type).toBe('jvm'); + expect(values.jvmReadOnly).toBe(true); + expect(values.jvmAllowedModes).toEqual(['jmx']); + expect(values.jvmPreferredMode).toBe('jmx'); + }); + + it('builds nested jvm config payload', () => { + const config = buildJVMConnectionConfig({ + name: 'Orders JVM', + type: 'jvm', + host: 'orders.internal', + port: 9010, + jvmReadOnly: true, + jvmAllowedModes: ['jmx', 'endpoint'], + jvmPreferredMode: 'endpoint', + jvmEnvironment: 'prod', + jvmEndpointEnabled: true, + jvmEndpointBaseUrl: 'https://orders.internal/manage/jvm', + jvmEndpointApiKey: 'token-1', + }); + expect(config.jvm?.preferredMode).toBe('endpoint'); + expect(config.jvm?.endpoint.baseUrl).toBe('https://orders.internal/manage/jvm'); + }); +}); +``` + +- [ ] **Step 5: 实现前端类型与连接工具** + +```ts +export interface JVMJMXConfig { + enabled?: boolean; + host?: string; + port?: number; + username?: string; + password?: string; + domainAllowlist?: string[]; +} + +export interface JVMEndpointConfig { + enabled?: boolean; + baseUrl?: string; + apiKey?: string; + timeoutSeconds?: number; +} + +export interface JVMConfig { + environment?: 'dev' | 'uat' | 'prod'; + readOnly?: boolean; + allowedModes?: Array<'jmx' | 'endpoint' | 'agent'>; + preferredMode?: 'jmx' | 'endpoint' | 'agent'; + jmx?: JVMJMXConfig; + endpoint?: JVMEndpointConfig; +} + +export interface JVMCapability { + mode: 'jmx' | 'endpoint' | 'agent'; + canBrowse: boolean; + canWrite: boolean; + canPreview: boolean; + reason?: string; + displayLabel: string; +} + +export interface JVMResourceSummary { + id: string; + parentId?: string; + kind: string; + name: string; + path: string; + providerMode: 'jmx' | 'endpoint' | 'agent'; + canRead: boolean; + canWrite: boolean; + hasChildren: boolean; + sensitive?: boolean; +} + +export interface JVMValueSnapshot { + resourceId: string; + kind: string; + format: string; + version?: string; + value: any; + metadata?: Record; +} + +export interface JVMChangePreview { + allowed: boolean; + requiresConfirmation?: boolean; + summary: string; + riskLevel: 'low' | 'medium' | 'high'; + blockingReason?: string; + before: JVMValueSnapshot; + after: JVMValueSnapshot; +} +``` + +```ts +import type { ConnectionConfig } from '../types'; + +export const buildDefaultJVMConnectionValues = () => ({ + type: 'jvm', + host: 'localhost', + port: 9010, + jvmReadOnly: true, + jvmAllowedModes: ['jmx'], + jvmPreferredMode: 'jmx', + jvmEnvironment: 'dev', + jvmEndpointEnabled: false, + jvmEndpointBaseUrl: '', + jvmEndpointApiKey: '', +}); + +export const buildJVMConnectionConfig = (values: Record): ConnectionConfig => ({ + type: 'jvm', + host: String(values.host || '').trim(), + port: Number(values.port || 0), + user: '', + password: '', + timeout: Number(values.timeout || 30), + jvm: { + environment: values.jvmEnvironment, + readOnly: Boolean(values.jvmReadOnly), + allowedModes: values.jvmAllowedModes, + preferredMode: values.jvmPreferredMode, + jmx: { + enabled: values.jvmAllowedModes?.includes('jmx'), + host: String(values.jvmJmxHost || values.host || '').trim(), + port: Number(values.jvmJmxPort || values.port || 0), + username: String(values.jvmJmxUsername || '').trim(), + password: String(values.jvmJmxPassword || ''), + }, + endpoint: { + enabled: Boolean(values.jvmEndpointEnabled), + baseUrl: String(values.jvmEndpointBaseUrl || '').trim(), + apiKey: String(values.jvmEndpointApiKey || ''), + timeoutSeconds: Number(values.jvmEndpointTimeoutSeconds || values.timeout || 30), + }, + }, +}); +``` + +- [ ] **Step 6: 运行单测,确认前后端配置契约稳定** + +Run: `go test ./internal/jvm -run TestNormalizeConnectionConfig -count=1` + +Expected: PASS,输出 `ok GoNavi-Wails/internal/jvm` + +Run: `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts` + +Expected: PASS,2 个测试通过。 + +- [ ] **Step 7: 提交配置契约** + +```bash +git add internal/connection/types.go internal/jvm/types.go internal/jvm/config.go internal/jvm/config_test.go frontend/src/types.ts frontend/src/utils/jvmConnectionConfig.ts frontend/src/utils/jvmConnectionConfig.test.ts +git commit -m "feat(jvm): 定义 JVM 连接契约与配置归一化" +``` + +## Task 2: 建立后端 Provider 注册与连接探测 API + +**Files:** +- Create: `internal/jvm/provider.go` +- Create: `internal/jvm/jmx_provider.go` +- Create: `internal/jvm/http_provider.go` +- Create: `internal/app/methods_jvm.go` +- Create: `internal/app/methods_jvm_test.go` +- Regenerate: `frontend/wailsjs/go/app/App.d.ts` +- Regenerate: `frontend/wailsjs/go/app/App.js` +- Regenerate: `frontend/wailsjs/go/models.ts` + +- [ ] **Step 1: 写 App 层失败测试,锁定连接测试与能力探测输出** + +```go +package app + +import ( + "context" + "testing" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/jvm" +) + +type fakeJVMProvider struct { + testErr error + probe []jvm.Capability + list []jvm.ResourceSummary + value jvm.ValueSnapshot + apply jvm.ApplyResult +} + +func (f fakeJVMProvider) Mode() string { return jvm.ModeJMX } +func (f fakeJVMProvider) TestConnection(context.Context, connection.ConnectionConfig) error { return f.testErr } +func (f fakeJVMProvider) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]jvm.Capability, error) { + return f.probe, nil +} +func (f fakeJVMProvider) ListResources(context.Context, connection.ConnectionConfig, string) ([]jvm.ResourceSummary, error) { + return f.list, nil +} +func (f fakeJVMProvider) GetValue(context.Context, connection.ConnectionConfig, string) (jvm.ValueSnapshot, error) { + return f.value, nil +} +func (f fakeJVMProvider) PreviewChange(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ChangePreview, error) { + return jvm.ChangePreview{Allowed: true, Summary: "preview"}, nil +} +func (f fakeJVMProvider) ApplyChange(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) { + return f.apply, nil +} + +func swapJVMProviderFactory(factory func(mode string) (jvm.Provider, error)) func() { + prev := newJVMProvider + newJVMProvider = factory + return func() { newJVMProvider = prev } +} + +func TestTestJVMConnectionUsesPreferredProvider(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{}, nil + }) + defer restore() + + res := app.TestJVMConnection(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }) + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } +} + +func TestJVMProbeCapabilitiesReturnsCapabilityArray(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + probe: []jvm.Capability{{Mode: jvm.ModeJMX, CanBrowse: true, CanWrite: false, CanPreview: false, DisplayLabel: "JMX"}}, + }, nil + }) + defer restore() + + res := app.JVMProbeCapabilities(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }) + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + items, ok := res.Data.([]jvm.Capability) + if !ok || len(items) != 1 { + t.Fatalf("expected one capability, got %#v", res.Data) + } +} +``` + +- [ ] **Step 2: 运行测试,确认 App 方法尚未定义** + +Run: `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` + +Expected: FAIL,提示 `TestJVMConnection` 或 `JVMProbeCapabilities` 未定义。 + +- [ ] **Step 3: 实现 Provider 接口、JMX/Endpoint 骨架和 App 方法** + +```go +package jvm + +import ( + "context" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +type Provider interface { + Mode() string + TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error + ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) + ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) + GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) + PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) + ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) +} + +var providerFactories = map[string]func() Provider{ + ModeJMX: func() Provider { return NewJMXProvider() }, + ModeEndpoint: func() Provider { return NewHTTPProvider() }, +} + +func NewProvider(mode string) (Provider, error) { + normalized := strings.ToLower(strings.TrimSpace(mode)) + factory, ok := providerFactories[normalized] + if !ok { + return nil, fmt.Errorf("unsupported jvm provider mode: %s", mode) + } + return factory(), nil +} + +type JMXProvider struct{} + +func NewJMXProvider() Provider { return &JMXProvider{} } +func (p *JMXProvider) Mode() string { return ModeJMX } +func (p *JMXProvider) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error { return nil } +func (p *JMXProvider) ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) { + return []Capability{{Mode: ModeJMX, CanBrowse: true, CanWrite: false, CanPreview: false, DisplayLabel: "JMX"}}, nil +} +func (p *JMXProvider) ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) { + return []ResourceSummary{}, nil +} +func (p *JMXProvider) GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) { + return ValueSnapshot{}, nil +} +func (p *JMXProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { + return ChangePreview{}, nil +} +func (p *JMXProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) { + return ApplyResult{}, nil +} + +type HTTPProvider struct{} + +func NewHTTPProvider() Provider { return &HTTPProvider{} } +func (p *HTTPProvider) Mode() string { return ModeEndpoint } +func (p *HTTPProvider) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error { return nil } +func (p *HTTPProvider) ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) { + return []Capability{{Mode: ModeEndpoint, CanBrowse: true, CanWrite: true, CanPreview: true, DisplayLabel: "Endpoint"}}, nil +} +func (p *HTTPProvider) ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) { + return []ResourceSummary{}, nil +} +func (p *HTTPProvider) GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) { + return ValueSnapshot{}, nil +} +func (p *HTTPProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { + return ChangePreview{}, nil +} +func (p *HTTPProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) { + return ApplyResult{}, nil +} +``` + +```go +package app + +import ( + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/jvm" + "path/filepath" + "strings" +) + +var newJVMProvider = jvm.NewProvider + +func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.QueryResult { + normalized, err := jvm.NormalizeConnectionConfig(cfg) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + provider, err := newJVMProvider(normalized.JVM.PreferredMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if err := provider.TestConnection(a.ctx, normalized); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Message: "JVM 连接成功"} +} + +func (a *App) JVMProbeCapabilities(cfg connection.ConnectionConfig) connection.QueryResult { + normalized, err := jvm.NormalizeConnectionConfig(cfg) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + items := make([]jvm.Capability, 0, len(normalized.JVM.AllowedModes)) + for _, mode := range normalized.JVM.AllowedModes { + provider, providerErr := newJVMProvider(mode) + if providerErr != nil { + items = append(items, jvm.Capability{Mode: mode, DisplayLabel: strings.ToUpper(mode), Reason: providerErr.Error()}) + continue + } + caps, probeErr := provider.ProbeCapabilities(a.ctx, normalized) + if probeErr != nil { + items = append(items, jvm.Capability{Mode: mode, DisplayLabel: strings.ToUpper(mode), Reason: probeErr.Error()}) + continue + } + items = append(items, caps...) + } + return connection.QueryResult{Success: true, Data: items} +} +``` + +- [ ] **Step 4: 刷新 Wails 绑定** + +Run: `wails build -clean` + +Expected: PASS,命令退出码为 0,同时刷新 `frontend/wailsjs/go/app/App.*` 与 `frontend/wailsjs/go/models.ts`。 + +- [ ] **Step 5: 运行后端测试,确认探测 API 可用** + +Run: `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` + +Expected: PASS,输出 `ok GoNavi-Wails/internal/app` + +- [ ] **Step 6: 提交 Provider 骨架** + +```bash +git add internal/jvm/provider.go internal/jvm/jmx_provider.go internal/jvm/http_provider.go internal/app/methods_jvm.go internal/app/methods_jvm_test.go frontend/wailsjs/go/app/App.d.ts frontend/wailsjs/go/app/App.js frontend/wailsjs/go/models.ts +git commit -m "feat(jvm): 增加连接测试与能力探测 API" +``` + +## Task 3: 接入 JVM 连接表单与图标 + +**Files:** +- Modify: `frontend/src/components/DatabaseIcons.tsx` +- Modify: `frontend/src/components/ConnectionModal.tsx` +- Create: `frontend/src/utils/jvmRuntimePresentation.ts` +- Create: `frontend/src/utils/jvmRuntimePresentation.test.ts` + +- [ ] **Step 1: 写展示层失败测试,锁定 JVM 模式标签和 tab 标题构造** + +```ts +import { describe, expect, it } from 'vitest'; +import { buildJVMTabTitle, resolveJVMModeMeta } from './jvmRuntimePresentation'; + +describe('jvmRuntimePresentation', () => { + it('renders readable mode meta', () => { + expect(resolveJVMModeMeta('jmx').label).toBe('JMX'); + expect(resolveJVMModeMeta('endpoint').label).toBe('Endpoint'); + }); + + it('builds overview title with provider suffix', () => { + expect(buildJVMTabTitle('Orders JVM', 'overview', 'jmx')).toBe('[Orders JVM] JVM 概览 · JMX'); + }); +}); +``` + +- [ ] **Step 2: 运行测试,确认展示帮助函数尚未实现** + +Run: `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts` + +Expected: FAIL,提示 `buildJVMTabTitle` / `resolveJVMModeMeta` 未定义。 + +- [ ] **Step 3: 实现 JVM 图标和展示帮助函数** + +```ts +export const resolveJVMModeMeta = (mode: string) => { + switch (mode) { + case 'endpoint': + return { label: 'Endpoint', color: 'blue' as const }; + case 'agent': + return { label: 'Agent', color: 'purple' as const }; + default: + return { label: 'JMX', color: 'gold' as const }; + } +}; + +export const buildJVMTabTitle = (connectionName: string, tabKind: 'overview' | 'resource' | 'audit', mode: string) => { + const modeLabel = resolveJVMModeMeta(mode).label; + if (tabKind === 'audit') return `[${connectionName}] JVM 审计 · ${modeLabel}`; + if (tabKind === 'resource') return `[${connectionName}] JVM 资源 · ${modeLabel}`; + return `[${connectionName}] JVM 概览 · ${modeLabel}`; +}; +``` + +```tsx +export const DB_ICON_TYPES = [ + 'mysql', + 'postgres', + 'oracle', + 'redis', + 'mongodb', + 'custom', + 'jvm', +] as const; +``` + +- [ ] **Step 4: 扩展 ConnectionModal,新增 JVM 连接类型与测试连接分发** + +```tsx +{ key: 'jvm', name: 'JVM', icon: } +``` + +```tsx +if (dbType === 'jvm') { + return ( + <> + + + + + + + + + + + 默认只读 + + + + + + ); +} +``` + +```tsx +const requestTest = async () => { + const values = form.getFieldsValue(true); + const config = values.type === 'jvm' + ? buildJVMConnectionConfig(values) + : await buildConfig(values, false); + const result = values.type === 'jvm' + ? await (window as any).go.app.App.TestJVMConnection(config as any) + : values.type === 'redis' + ? await RedisConnect(config as any) + : await TestConnection(config as any); + setTestResult(result.success ? { type: 'success', message: result.message || '连接成功' } : { type: 'error', message: result.message || '连接失败' }); +}; +``` + +- [ ] **Step 5: 运行前端纯函数测试与构建** + +Run: `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts` + +Expected: PASS + +Run: `cd frontend && npm run build` + +Expected: PASS,生成最新 `frontend/dist`。 + +- [ ] **Step 6: 提交连接体验改动** + +```bash +git add frontend/src/components/DatabaseIcons.tsx frontend/src/components/ConnectionModal.tsx frontend/src/utils/jvmRuntimePresentation.ts frontend/src/utils/jvmRuntimePresentation.test.ts +git commit -m "feat(jvm): 新增 JVM 连接表单与展示元数据" +``` + +## Task 4: 打通只读资源浏览与 JVM Tab + +**Files:** +- Modify: `frontend/src/types.ts` +- Modify: `frontend/src/components/Sidebar.tsx` +- Modify: `frontend/src/components/TabManager.tsx` +- Create: `frontend/src/components/JVMOverview.tsx` +- Create: `frontend/src/components/JVMResourceBrowser.tsx` +- Create: `frontend/src/components/jvm/JVMModeBadge.tsx` +- Modify: `internal/app/methods_jvm.go` +- Modify: `internal/app/methods_jvm_test.go` + +- [ ] **Step 1: 写后端失败测试,锁定资源列表和值读取接口** + +```go +func TestJVMListResourcesReturnsTreePayload(t *testing.T) { + app := NewAppWithSecretStore(nil) + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + list: []jvm.ResourceSummary{ + {ID: "cache:orders", Kind: "cacheNamespace", Name: "orders", Path: "cache/orders", ProviderMode: "jmx", HasChildren: true, CanRead: true}, + }, + }, nil + }) + defer restore() + + res := app.JVMListResources(connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, "") + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + items, ok := res.Data.([]jvm.ResourceSummary) + if !ok || len(items) != 1 { + t.Fatalf("expected one resource item, got %#v", res.Data) + } +} +``` + +- [ ] **Step 2: 运行测试,确认资源读取方法尚未实现** + +Run: `go test ./internal/app -run 'TestJVMListResources' -count=1` + +Expected: FAIL,提示 `JVMListResources` 未定义。 + +- [ ] **Step 3: 实现后端读接口并在 Sidebar 中新增 JVM 懒加载节点** + +```go +func (a *App) JVMListResources(cfg connection.ConnectionConfig, parentPath string) connection.QueryResult { + normalized, err := jvm.NormalizeConnectionConfig(cfg) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + provider, err := newJVMProvider(normalized.JVM.PreferredMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + items, err := provider.ListResources(a.ctx, normalized, parentPath) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: items} +} + +func (a *App) JVMGetValue(cfg connection.ConnectionConfig, resourcePath string) connection.QueryResult { + normalized, err := jvm.NormalizeConnectionConfig(cfg) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + provider, err := newJVMProvider(normalized.JVM.PreferredMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + value, err := provider.GetValue(a.ctx, normalized, resourcePath) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: value} +} +``` + +```tsx +type TreeNode = { + title: string; + key: string; + isLeaf?: boolean; + children?: TreeNode[]; + icon?: React.ReactNode; + dataRef?: any; + type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource'; +}; +``` + +```tsx +if (conn.config.type === 'jvm') { + const modeChildren = (caps as JVMCapability[]).map((cap) => ({ + title: ( + + {cap.displayLabel} + + + ), + key: `${conn.id}-jvm-mode-${cap.mode}`, + type: 'jvm-mode' as const, + dataRef: { ...conn, providerMode: cap.mode }, + isLeaf: false, + })); + setTreeData((origin) => updateTreeData(origin, conn.id, modeChildren)); + return; +} +``` + +- [ ] **Step 4: 新增 JVM 概览与资源浏览 Tab** + +```tsx +if (tab.type === 'jvm-overview') { + content = ; +} else if (tab.type === 'jvm-resource') { + content = ; +} else if (tab.type === 'jvm-audit') { + content = ; +} +``` + +```tsx +export interface TabData { + id: string; + title: string; + type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'redis-monitor' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview' | 'jvm-overview' | 'jvm-resource' | 'jvm-audit'; + connectionId: string; + dbName?: string; + tableName?: string; + providerMode?: 'jmx' | 'endpoint' | 'agent'; + resourcePath?: string; + resourceKind?: string; +} +``` + +- [ ] **Step 5: 运行后端与前端最小回归** + +Run: `go test ./internal/app -run 'TestJVMListResources' -count=1` + +Expected: PASS + +Run: `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts` + +Expected: PASS + +- [ ] **Step 6: 提交只读浏览链路** + +```bash +git add internal/app/methods_jvm.go internal/app/methods_jvm_test.go frontend/src/types.ts frontend/src/components/Sidebar.tsx frontend/src/components/TabManager.tsx frontend/src/components/JVMOverview.tsx frontend/src/components/JVMResourceBrowser.tsx frontend/src/components/jvm/JVMModeBadge.tsx +git commit -m "feat(jvm): 打通 JVM 只读资源浏览" +``` + +## Task 5: 加入写入预览、Guard 和审计记录 + +**Files:** +- Create: `internal/jvm/guard.go` +- Create: `internal/jvm/audit_store.go` +- Modify: `internal/jvm/types.go` +- Modify: `internal/app/methods_jvm.go` +- Modify: `internal/app/methods_jvm_test.go` +- Create: `frontend/src/components/jvm/JVMChangePreviewModal.tsx` +- Create: `frontend/src/components/JVMAuditViewer.tsx` +- Modify: `frontend/src/components/JVMResourceBrowser.tsx` + +- [ ] **Step 1: 写 Guard 失败测试,锁定只读/生产环境拦截** + +```go +func TestPreviewChangeBlocksReadOnlyConnection(t *testing.T) { + cfg := connection.ConnectionConfig{ + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: true, + Environment: "prod", + PreferredMode: "endpoint", + AllowedModes: []string{"endpoint"}, + }, + } + + preview, err := jvm.BuildChangePreview(cfg, jvm.ChangeRequest{ + ProviderMode: "endpoint", + ResourceID: "cache/orders/user:1", + Action: "updateValue", + Reason: "修复错误缓存态", + Payload: map[string]any{"status": "ACTIVE"}, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error: %v", err) + } + if preview.Allowed { + t.Fatalf("expected readonly connection to block write preview") + } + if preview.BlockingReason == "" { + t.Fatalf("expected blocking reason") + } +} +``` + +- [ ] **Step 2: 运行测试,确认 Guard 逻辑尚未存在** + +Run: `go test ./internal/jvm -run TestPreviewChangeBlocksReadOnlyConnection -count=1` + +Expected: FAIL,提示 `BuildChangePreview` 未定义。 + +- [ ] **Step 3: 实现 Guard、预览和审计落盘** + +```go +package jvm + +import ( + "encoding/json" + "os" + "fmt" + "time" + + "GoNavi-Wails/internal/connection" +) + +func BuildChangePreview(cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { + normalized, err := NormalizeConnectionConfig(cfg) + if err != nil { + return ChangePreview{}, err + } + preview := ChangePreview{ + Allowed: true, + RiskLevel: "medium", + Summary: fmt.Sprintf("%s -> %s", req.ResourceID, req.Action), + } + if normalized.JVM.ReadOnly { + preview.Allowed = false + preview.RiskLevel = "high" + preview.BlockingReason = "当前连接为只读,禁止写入" + } + if normalized.JVM.Environment == EnvPROD { + preview.RequiresConfirmation = true + } + return preview, nil +} + +type AuditStore struct { + path string +} + +func NewAuditStore(path string) *AuditStore { return &AuditStore{path: path} } + +func (s *AuditStore) Append(record AuditRecord) error { + record.Timestamp = time.Now().UnixMilli() + file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer file.Close() + return json.NewEncoder(file).Encode(record) +} +``` + +```go +func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRequest) connection.QueryResult { + preview, err := jvm.BuildChangePreview(cfg, req) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: preview} +} + +func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequest) connection.QueryResult { + preview, err := jvm.BuildChangePreview(cfg, req) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if !preview.Allowed { + return connection.QueryResult{Success: false, Message: preview.BlockingReason} + } + provider, err := newJVMProvider(req.ProviderMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + result, err := provider.ApplyChange(a.ctx, cfg, req) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + _ = jvm.NewAuditStore(filepath.Join(a.configDir, "jvm_audit.jsonl")).Append(jvm.AuditRecord{ + ConnectionID: cfg.ID, + ProviderMode: req.ProviderMode, + ResourceID: req.ResourceID, + Action: req.Action, + Reason: req.Reason, + Result: result.Status, + }) + return connection.QueryResult{Success: true, Data: result} +} +``` + +- [ ] **Step 4: 实现前端预览弹窗与审计页签** + +```tsx +export const JVMChangePreviewModal: React.FC<{ + open: boolean; + preview: JVMChangePreview | null; + onCancel: () => void; + onConfirm: () => Promise; +}> = ({ open, preview, onCancel, onConfirm }) => ( + void onConfirm()} + okText="确认执行" + cancelText="取消" + okButtonProps={{ danger: preview?.riskLevel === 'high' }} + > + + {preview?.summary} + {preview?.riskLevel} + {preview?.blockingReason || '无'} + + + {JSON.stringify(preview?.before?.value ?? {}, null, 2)} + {JSON.stringify(preview?.after?.value ?? {}, null, 2)} + +); +``` + +```tsx +const handleApply = async () => { + const previewRes = await (window as any).go.app.App.JVMPreviewChange(config, draftPlan); + if (!previewRes.success) { + message.error(previewRes.message || '预览失败'); + return; + } + setPreview(previewRes.data); + setPreviewOpen(true); +}; +``` + +- [ ] **Step 5: 跑写入链路单测** + +Run: `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestJVMApplyChange' -count=1` + +Expected: PASS + +- [ ] **Step 6: 提交预览与审计链路** + +```bash +git add internal/jvm/guard.go internal/jvm/audit_store.go internal/jvm/types.go internal/app/methods_jvm.go internal/app/methods_jvm_test.go frontend/src/components/jvm/JVMChangePreviewModal.tsx frontend/src/components/JVMAuditViewer.tsx frontend/src/components/JVMResourceBrowser.tsx +git commit -m "feat(jvm): 增加 JVM 写入预览与审计" +``` + +## Task 6: 接入 AI 结构化变更计划 + +**Files:** +- Create: `frontend/src/utils/jvmAiPlan.ts` +- Create: `frontend/src/utils/jvmAiPlan.test.ts` +- Modify: `frontend/src/components/AIChatPanel.tsx` +- Modify: `frontend/src/components/ai/AIMessageBubble.tsx` +- Modify: `frontend/src/components/JVMResourceBrowser.tsx` + +- [ ] **Step 1: 写失败测试,锁定 AI 计划 JSON 解析规则** + +```ts +import { describe, expect, it } from 'vitest'; +import { extractJVMChangePlan } from './jvmAiPlan'; + +describe('extractJVMChangePlan', () => { + it('parses fenced json plan', () => { + const message = [ + '建议先预览再执行:', + '```json', + '{"targetType":"cacheEntry","selector":{"namespace":"orders","key":"user:1"},"action":"updateValue","payload":{"format":"json","value":{"status":"ACTIVE"}},"reason":"修复缓存脏值"}', + '```', + ].join('\n'); + + const plan = extractJVMChangePlan(message); + expect(plan?.action).toBe('updateValue'); + expect(plan?.selector.namespace).toBe('orders'); + }); + + it('returns null for malformed plan', () => { + expect(extractJVMChangePlan('```json\n{"action":1}\n```')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: 运行测试,确认 AI 计划解析器尚未存在** + +Run: `cd frontend && npm test -- src/utils/jvmAiPlan.test.ts` + +Expected: FAIL,提示 `extractJVMChangePlan` 未定义。 + +- [ ] **Step 3: 实现 AI 计划解析器** + +```ts +export type JVMAIChangePlan = { + targetType: 'cacheEntry' | 'managedBean'; + selector: { namespace?: string; key?: string; resourcePath?: string }; + action: 'updateValue' | 'evict' | 'clear'; + payload?: { format: 'json' | 'text'; value: unknown }; + reason: string; +}; + +export const extractJVMChangePlan = (content: string): JVMAIChangePlan | null => { + const match = String(content || '').match(/```json\s*([\s\S]*?)```/i); + if (!match) return null; + try { + const parsed = JSON.parse(match[1]); + if (!parsed || typeof parsed !== 'object') return null; + if (!parsed.targetType || !parsed.selector || !parsed.action || !parsed.reason) return null; + return parsed as JVMAIChangePlan; + } catch { + return null; + } +}; +``` + +- [ ] **Step 4: 在 AI 气泡里识别 JVM 计划并提供“应用到预览”按钮** + +```tsx +const jvmPlan = extractJVMChangePlan(msg.content || ''); + +{jvmPlan && ( + +)} +``` + +```tsx +useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail?.plan) return; + setDraftPlan({ + providerMode: tab.providerMode || 'endpoint', + resourceID: detail.plan.selector.resourcePath || `${detail.plan.selector.namespace}/${detail.plan.selector.key}`, + action: detail.plan.action, + payload: detail.plan.payload?.value ?? {}, + reason: detail.plan.reason, + }); + }; + window.addEventListener('gonavi:jvm-apply-ai-plan', handler as EventListener); + return () => window.removeEventListener('gonavi:jvm-apply-ai-plan', handler as EventListener); +}, [tab.providerMode]); +``` + +- [ ] **Step 5: 跑 AI 计划解析测试** + +Run: `cd frontend && npm test -- src/utils/jvmAiPlan.test.ts` + +Expected: PASS + +- [ ] **Step 6: 提交 AI 集成** + +```bash +git add frontend/src/utils/jvmAiPlan.ts frontend/src/utils/jvmAiPlan.test.ts frontend/src/components/AIChatPanel.tsx frontend/src/components/ai/AIMessageBubble.tsx frontend/src/components/JVMResourceBrowser.tsx +git commit -m "feat(jvm): 支持 AI 生成 JVM 变更计划" +``` + +## Task 7: 全量回归、文档回填与交付检查 + +**Files:** +- Modify: `docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md` +- Regenerate/Verify: `frontend/wailsjs/go/app/App.d.ts` +- Regenerate/Verify: `frontend/wailsjs/go/app/App.js` +- Regenerate/Verify: `frontend/wailsjs/go/models.ts` + +- [ ] **Step 1: 更新需求追踪文档,写入计划路径与实施阶段** + +```md +## 3. 里程碑与进度 +- [x] 阶段 1(需求澄清):完成 +- [x] 阶段 2(影响分析):完成 +- [x] 阶段 3(方案设计):完成 +- [x] 阶段 4(实施计划):完成 +- [ ] 阶段 5(实现与自检): + +## 7. 验证记录 +- 证据(日志/截图/链接): + - `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md` + - `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md` +``` + +- [ ] **Step 2: 运行后端全量测试** + +Run: `go test ./...` + +Expected: PASS,全仓 Go 测试通过。 + +- [ ] **Step 3: 运行前端全量测试** + +Run: `cd frontend && npm test` + +Expected: PASS,全量 Vitest 通过。 + +- [ ] **Step 4: 运行前端生产构建** + +Run: `cd frontend && npm run build` + +Expected: PASS,生成最新 `frontend/dist`。 + +- [ ] **Step 5: 运行 Wails 生产构建,确认绑定与嵌入资源完整** + +Run: `wails build -clean` + +Expected: PASS,命令退出码为 0。 + +- [ ] **Step 6: 提交最终计划内实现** + +```bash +git add docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md frontend/wailsjs/go/app/App.d.ts frontend/wailsjs/go/app/App.js frontend/wailsjs/go/models.ts +git commit -m "feat(jvm): 完成 JVM Connector MVP" +``` + +## Self-Review Notes + +- Spec coverage: + - `JMX + Management Endpoint`:Task 2 / Task 4 / Task 5 + - `统一连接入口`:Task 1 / Task 3 + - `资源浏览`:Task 4 + - `受控修改 + 预览 + 审计`:Task 5 + - `AI 生成修改计划`:Task 6 + - `验证与文档回填`:Task 7 +- Placeholder scan: + - 无 `TODO` / `TBD` / “后续补充” 占位语 +- Type consistency: + - 统一使用 `JVMConfig` / `Capability` / `ResourceSummary` / `ChangeRequest` / `ChangePreview` + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach?** diff --git a/docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md b/docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md new file mode 100644 index 0000000..2c2011b --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md @@ -0,0 +1,483 @@ +# JVM 缓存可视化编辑设计 + +## 1. 背景 + +当前用户在公司 Java 项目中经常把缓存或运行时状态直接保存在 JVM 内存中。出现数据脏值、缓存穿透、临时纠偏或排障时,通常只有两种方式: + +- 为特定业务临时补管理接口 +- 重启应用并依赖重新初始化 + +这两种方式都存在明显问题: + +- 临时接口会污染业务代码,并带来后续维护和权限风险 +- 重启应用成本高,且不适合用于精确修复单个缓存项 + +GoNavi 现有已具备三类可复用基础: + +- 统一连接与工作台能力:`frontend/src/components/ConnectionModal.tsx`、`frontend/src/components/Sidebar.tsx`、`frontend/src/components/TabManager.tsx` +- 独立运行时能力样板:Redis 通过 `internal/app/methods_redis.go` 和专用前端视图实现,不依赖 SQL `Database` 抽象 +- AI 与日志能力底座:`frontend/src/components/AIChatPanel.tsx`、`frontend/src/components/QueryEditor.tsx`、`frontend/src/components/LogPanel.tsx` + +因此,GoNavi 有条件扩展出 JVM 运行时连接与受控编辑能力,但不能简单把该需求理解为“新数据库驱动”。 + +## 2. 目标 + +- 为 GoNavi 增加统一的 `JVM Connector` 子系统,用于连接和浏览 Java 服务的运行时缓存/管理对象 +- 在同一套 UI 下支持多种接入模式,并根据目标 JVM 能力自动协商或手动切换 +- 提供结构化的缓存浏览、值检查、受控修改、操作预览和审计记录 +- 允许 AI 参与解释、分析和生成修改计划,但不默认开放 AI 自动执行 +- 尽量避免强依赖 `-javaagent` 或运行时动态 attach,适配企业内对生产进程注入普遍敏感的环境 + +## 3. 非目标 + +- 不承诺“任意 JVM 内任意对象均可直接读写” +- 不在首期支持任意 Java 表达式执行、任意反射路径写值或任意 classloader 深度探测 +- 不把 JVM 功能强行塞进现有 SQL `Database` / driver-agent 抽象 +- 不承诺通过 Agent 模式支持所有缓存框架或任意深层对象写入 +- 不绕过目标服务现有认证、鉴权和网络边界 + +## 4. 需求与约束 + +### 4.1 需求清单 + +- 统一配置 JVM 连接 +- 探测当前 JVM 支持的接入模式与可用能力 +- 浏览缓存空间、管理对象和受控操作 +- 查看值快照与元数据 +- 执行受控修改,并提供 before/after 预览 +- 将操作结果写入审计记录 +- 支持 AI 对资源结构和修改方案进行分析 + +### 4.2 已确认约束 + +- 用户倾向通用型产品形态,但目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach +- 企业环境下,稳定性与安全性优先级高于“黑科技式通用能力” +- 一期应优先基于标准协议和业务可控接入面,而不是侵入式 runtime 操作 + +## 5. 现状分析 + +### 5.1 GoNavi 架构启示 + +- `internal/db/database.go` 面向标准化数据源 CRUD,适合 SQL 类资源 +- `internal/app/methods_redis.go` 证明 GoNavi 已支持“独立运行时系统能力线” +- `frontend/src/components/RedisViewer.tsx` 与 `frontend/src/components/RedisCommandEditor.tsx` 提供了树形浏览、结构化值编辑和控制台交互样板 +- `frontend/src/components/AIChatPanel.tsx` 与 `frontend/src/components/ai/AIMessageBubble.tsx` 已具备 AI 交互和危险执行确认能力 + +### 5.2 结论 + +JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector` 子系统,而不是复用 SQL `Database` 接口。 + +## 6. 方案比较 + +### 方案 A:单一路径通用 Agent + +- 描述:统一要求目标 JVM 通过 `-javaagent` 或运行时 attach 暴露运行时对象访问能力 +- 优点: + - 理论能力上限最高 + - 可覆盖更多自研缓存和深层对象 +- 缺点: + - 与已知企业约束直接冲突 + - 风险最高,部署与安全成本高 + - 与首期产品化目标不匹配 + +### 方案 B:多接入模式 + 能力协商 + +- 描述:统一做 `JVM Connector`,底层同时支持 `JMX`、`Management Endpoint`、`Agent` +- 优点: + - 产品形态统一 + - 能根据目标 JVM 能力降级 + - 可先做低风险路径,后续再扩展高级模式 +- 缺点: + - 不同模式能力不一致,UI 与权限模型更复杂 + +### 方案 C:只做业务侧管理端点 + +- 描述:完全放弃通用接入,只提供官方 Starter/管理端点接入 +- 优点: + - 结构最稳,AI 最容易接入 + - 权限、审计、预览、回滚最好做 +- 缺点: + - 不满足“尽量通用”的产品定位 + - 无法覆盖仅开放 JMX 的存量系统 + +## 7. 选型 + +采用方案 B。当前已落地: + +- `JMX Provider` +- `Management Endpoint Provider` +- `Agent Provider`(高级可选模式,要求目标 Java 服务显式预埋 GoNavi Java Agent) + +## 8. 目标架构 + +### 8.1 总体结构 + +新增统一的 `JVM Connector` 子系统,分为五层: + +- `Connection Layer` + - 新增 `jvm` 连接类型 + - 保存目标地址、认证、允许模式、首选模式、环境标签等配置 +- `Capability Layer` + - 建立连接后探测当前支持的 provider 与能力矩阵 +- `Provider Layer` + - `JMX Provider` + - `Management Endpoint Provider` + - `Agent Provider`(预留) +- `Resource Layer` + - 将不同来源统一映射为结构化资源 +- `Guard Layer` + - 统一负责预览、确认、审计、回读验证、错误归一化 + +### 8.2 设计原则 + +- UI 统一,协议多态 +- 读写分离,修改必须经过 Guard Layer +- provider 不得自行绕过权限与审计链路 +- 能力不足时显式降级,不提供“看似可用、实际不可执行”的假入口 + +## 9. Provider 设计 + +### 9.1 JMX Provider + +- 负责: + - 建立 JMX/RMI 连接 + - 发现 MBean + - 读取属性 + - 调用白名单操作 + - 写入允许修改的白名单属性 +- 适用场景: + - 目标 JVM 已开放 JMX + - 缓存或管理对象已暴露为 MBean +- 特点: + - 低侵入、标准化、可落地 + - key/value 级资源能力通常有限 + +### 9.2 Management Endpoint Provider + +- 负责: + - 调用业务服务暴露的 GoNavi 管理端点或 Starter + - 返回结构化缓存资源、元数据和受控动作 + - 提供修改预览与回滚信息 +- 适用场景: + - 业务方愿意接入轻量 Starter/管理端点 + - 需要更强的 key/value 级浏览与修改能力 +- 特点: + - 最适合产品化和 AI 协同 + - 权限、脱敏、审计、回滚最容易做 + +### 9.3 Agent Provider + +- 负责: + - 在特定环境下通过 GoNavi Java Agent 暴露受控管理端口 + - 提供比 JMX 更贴近缓存资源模型的结构化浏览、预览与写入能力 +- 定位: + - 高级模式 + - 不默认启用 + - 需要目标 Java 服务以 `-javaagent` 方式显式启动 + +## 10. 统一资源模型 + +建议统一抽象以下资源: + +- `runtime` + - 目标 JVM 实例 +- `cacheNamespace` + - 缓存空间,如某个 CacheManager 下的 cacheName +- `cacheEntry` + - 具体缓存项 key/value +- `managedBean` + - 可读写的托管对象或 MBean +- `operation` + - 受控操作,如 `evict`、`put`、`refresh`、`clear` +- `auditRecord` + - 每次读写与 AI 建议的审计记录 + +统一资源模型要求: + +- 每个资源都有稳定 ID、显示名、provider 来源、能力标签、敏感级别 +- 值快照必须区分原始值、展示值和可编辑值 +- 资源定位信息必须可写入审计 + +## 11. AI 协同设计 + +### 11.1 AI 的角色 + +AI 在 JVM 场景中只能作为“受控编排者”,不能作为直接执行者。 + +AI 可以: + +- 解释缓存/Bean 的结构和当前状态 +- 生成筛选条件和定位建议 +- 生成结构化修改计划 +- 生成风险说明和回滚建议 +- 对执行前后结果做对比分析 + +AI 不应默认做: + +- 直接执行 JVM 修改 +- 自由生成任意脚本并直写内存 +- 绕过人工确认直接调用 provider + +### 11.2 AI 输出形态 + +AI 不直接输出脚本,而输出结构化变更计划,例如: + +```json +{ + "targetType": "cacheEntry", + "selector": { + "namespace": "userSessionCache", + "key": "user:1001" + }, + "action": "updateValue", + "payload": { + "format": "json", + "value": { + "status": "ACTIVE" + } + }, + "reason": "修复错误缓存态" +} +``` + +### 11.3 AI 执行链路 + +1. AI 读取结构化上下文 +2. AI 产出结构化变更计划 +3. Guard Layer 校验目标资源、能力和权限 +4. UI 展示修改预览与风险提示 +5. 用户确认 +6. provider 执行 +7. 系统回读验证并写审计 + +### 11.4 一期 AI 边界 + +- 支持 AI 分析资源 +- 支持 AI 生成修改计划 +- 不默认支持 AI 自动执行修改 + +## 12. 页面与交互设计 + +### 12.1 连接层 + +在 `ConnectionModal` 中新增 `JVM` 类型,建议配置: + +- 连接名称 +- 目标地址/端口 +- 认证信息 +- 允许模式列表 +- 首选模式 +- 环境标签(DEV/UAT/PROD) +- 默认权限级别(只读/读写) + +### 12.2 侧边栏 + +展示结构: + +- 连接 +- 模式能力 +- 资源类型 +- `cacheNamespace` / `managedBean` / `operation` + +每个连接或节点显示能力徽标,例如: + +- `JMX` +- `Endpoint` +- `Agent` +- `只读` +- `可写` + +### 12.3 主工作区 Tab + +建议新增以下 Tab 类型: + +- `概览` +- `资源浏览` +- `值检查器` +- `修改预览` +- `AI 助手` +- `审计记录` + +### 12.4 标准操作流 + +1. 用户连接 JVM +2. 系统探测 provider 能力 +3. 用户选择资源并读取快照 +4. 用户手工修改或让 AI 生成计划 +5. 系统生成 before/after 预览 +6. 用户二次确认 +7. provider 执行 +8. 系统回读验证 +9. 写入审计与操作日志 + +## 13. 权限与审计 + +### 13.1 权限模型 + +权限建议分四层: + +- `连接级` + - 决定默认 `readonly` / `readwrite` +- `模式级` + - 决定某 provider 支持哪些动作 +- `资源级` + - 某些资源永远只读 +- `环境级` + - `PROD` 默认强制二次确认,禁用 AI 自动执行 + +### 13.2 审计要求 + +JVM 审计日志不应复用 SQL 日志数据结构,但可以复用现有 LogPanel 样式。 + +建议记录: + +- 连接 ID / 名称 +- provider 类型 +- 资源定位信息 +- 动作类型 +- 修改原因 +- AI 是否参与 +- 执行前摘要 +- 执行后摘要 +- 结果状态 +- 耗时 +- 错误信息 + +建议本地独立落盘为 `jvm_audit.jsonl` 或等价结构,不混入 `sqlLogs`。 + +## 14. 错误处理与兼容性边界 + +### 14.1 错误分层 + +- `连接层失败` + - 认证失败、证书失败、JMX/RMI 不通、端点 401/403 +- `能力层失败` + - 连接成功但不支持列 key、写值或批量操作 +- `执行层失败` + - 资源不存在、值格式非法、provider 拒绝写入 +- `验证层失败` + - 执行返回成功但回读校验不一致 + +所有错误都应显式标明是哪个 provider、哪一层失败,避免泛化为“修改失败”。 + +### 14.2 首期兼容性承诺 + +优先承诺以下边界: + +- Java 8 / 11 / 17 / 21 +- Spring Boot 服务优先 +- JMX 标准 MBean +- Management Endpoint 模式下优先支持: + - Caffeine + - Ehcache + - Guava Cache + - Spring Cache 抽象下可枚举缓存 + - 接入 GoNavi Starter 的自研缓存 +- 值类型首期优先: + - string + - number + - boolean + - JSON object / JSON array + - map / list 的结构化展示 + +### 14.3 首期不承诺 + +- 任意 Java 对象深度反射编辑 +- 无类型信息的二进制对象直接改写 +- 跨 classloader 任意对象定位 +- 生产环境默认开放批量危险写入 + +## 15. MVP 分期 + +### Phase 1:连接与只读探测 + +- JVM 连接类型 +- JMX / Endpoint 能力探测 +- 资源树浏览 +- 值查看 +- 概览页与能力徽标 +- 不开放写入 + +### Phase 2:受控修改与审计 + +- 白名单资源写入 +- before/after 预览 +- 二次确认 +- 审计日志 +- 回读验证 +- 环境级保护策略 + +### Phase 3:AI 协同 + +- AI 解释资源 +- AI 生成修改计划 +- AI 风险分析 +- AI 回滚建议 +- 仍默认不允许 AI 自动执行 + +### Phase 4:高级模式 + +- Agent Provider +- 预埋 Java Agent 的 runtime 资源治理能力 +- 仅在特殊环境启用 + +## 16. 验证策略 + +### 16.1 功能验证 + +- 能连接 JMX 目标 +- 能连接 Endpoint 目标 +- 能列出缓存空间 +- 能查看 key/value +- 能完成受控修改并回读成功 + +### 16.2 兼容性验证 + +- Java 8 / 11 / 17 / 21 +- 本地、容器、K8s 内网场景 +- 开启认证 / 不开启认证 +- 仅 JMX、仅 Endpoint、双模式并存 + +### 16.3 安全验证 + +- 只读连接无法写入 +- `PROD` 环境必须二次确认 +- AI 无法绕过人工确认直接执行 +- 审计日志完整记录修改链路 + +### 16.4 稳定性验证 + +- 目标 JVM 不可达时 UI 不假死 +- 资源树大数量时支持分页或懒加载 +- 回读失败时标识“不确定状态” +- provider 超时、部分失败、降级路径清晰 + +## 17. 风险与缓解 + +### 17.1 风险 + +- 多 provider 模式会带来能力不一致,用户可能误解“所有 JVM 都能随便改” +- JMX 模式的 key/value 级能力可能明显不足 +- 管理端点模式需要业务接入,推广成本高于纯客户端方案 +- 若未来引入 Agent 模式,可能引入新的安全审核和兼容性成本 + +### 17.2 缓解 + +- 在 UI 中显式展示能力矩阵和当前 provider 来源 +- 所有修改都强制经过预览、确认与审计 +- 首期将“通用”定义为“统一入口 + 多模式协商”,而不是“单通道万能能力” +- Agent 仅作为高级扩展位,避免污染 MVP 边界 + +## 18. 最终结论 + +JVM 缓存可视化编辑能力在 GoNavi 中具备落地基础,但必须采用“统一入口、多 provider、能力协商、强 Guard Layer”的产品化方案。 + +推荐结论如下: + +- 新增独立的 `JVM Connector` 子系统 +- 首期支持 `JMX + Management Endpoint` +- `Agent` 作为高级可选模式交付 +- AI 首期支持分析与生成修改计划,不默认开放自动执行 +- 所有修改必须经过预览、确认、审计和回读验证 + +这一路径能够在兼顾企业安全约束的前提下,为用户提供可持续演进的 JVM 运行时缓存治理能力。 diff --git a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md new file mode 100644 index 0000000..0aa46fc --- /dev/null +++ b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md @@ -0,0 +1,246 @@ +# 需求进度追踪 - JVM缓存可视化编辑 + +## 1. 需求摘要 +- 需求名称:JVM缓存可视化编辑 +- 提出日期:2026-04-22 +- 负责人:Codex +- 目标:完成 GoNavi 连接 Java JVM、可视化查看并修改 JVM 内缓存/对象值的通用能力交付,降低“改缓存只能写接口或重启应用”的运维与排障成本 +- 非目标:不承诺覆盖所有 Java 框架/所有对象类型,不绕过目标应用现有安全控制,不在首期开放脚本式任意表达式执行 + +## 2. 范围与验收 +- 范围: + - 交付 JVM 共享契约、连接配置、provider 注册、连接测试与能力探测 + - 交付 Endpoint / JMX / Agent 三种接入模式及其资源浏览、读值、预览、执行链路 + - 交付 JVM 资源页、预览弹窗、审计查看、AI 草稿生成与回填能力 + - 交付 Guard、审计、来源标记、真实集成测试与构建验证 +- 验收标准: + - 可以在 GoNavi 中新增 JVM 连接并完成连接测试 + - 可以按资源树浏览 JVM 对象并查看结构化快照 + - 可以对支持写入的资源执行预览和确认写入,且带 Guard 与审计 + - 可以通过 AI 生成结构化修改草稿,但不会跳过人工确认直接执行 + - 可以通过真实 JMX 与真实 HTTP contract 完成端到端验证,并通过前后端构建回归 +- 依赖与约束: + - 需复用 GoNavi 当前 Wails + React + driver-agent 架构 + - 新能力不得破坏现有数据库/Redis 工作流 + - 高风险写操作必须具备明确鉴权、审计与回滚思路 + - JMX 模式要求 GoNavi 运行机器本地可用 `java` 可执行文件 + +## 3. 里程碑与进度 +- [x] 阶段 1(需求澄清):完成 +- [x] 阶段 2(影响分析):完成 +- [x] 阶段 3(方案设计):完成(已形成正式设计文档) +- [x] 阶段 4(实施计划):完成(已形成正式实施计划) +- [x] 阶段 5(实现与自检):完成(Task 1 至 Task 7 已完成,代码与构建回归通过) +- [x] 阶段 6(评审与交付):完成(已完成契约复核、上下文隔离修正、文档回填与交付检查) +- [ ] 阶段 7(发布与观察):未开始 + +## 4. 变更清单 +- 已完成: + - 确认 GoNavi 当前存在统一驱动接口与可选 driver-agent 机制 + - 确认前端已有 Redis 结构化浏览、命令编辑器、Monaco 编辑器、DataGrid 编辑能力可复用 + - 初步判断 JVM 运行时对象编辑不适合直接复用 SQL/Database 抽象,需新增非数据库协议层 + - 用户已确认目标方向为“通用型 JVM 接入” + - 用户已确认升级到完整模式,开始高风险架构评估 + - 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach + - 已形成 JVM 缓存可视化编辑正式设计文档 + - 已形成 JVM Connector MVP 正式实施计划文档 + - 已完成 Task 1:JVM 共享契约与配置归一化 + - 已完成 Task 2:Provider 注册、连接测试与能力探测 API + - 已完成 Task 3:JVM 连接表单、图标与展示文案接入 + - 已完成 Task 4:只读资源浏览与 JVM Tab + - 已完成 Task 5:写入预览、Guard 和审计记录 + - 已完成 Task 6:AI 结构化变更计划 + - 已完成 Task 7:全量回归、文档回填与交付检查 + - 已完成 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 能力探测遵循只读配置,概览页能力矩阵补齐模式能力探测与多行错误展示,能力探测失败与风险/结果状态文案统一收口为中文业务语义 +- 待处理: + - 无阻塞性交付项;后续仅保留复杂对象参数、`CompositeData` / `TabularData` 等高级类型写入扩展作为增强项 + +## 5. 风险与阻塞 +- 风险: + - 直接修改 JVM 内对象属于高风险运行时操作,误改可能造成业务状态污染 + - 不同缓存框架(Caffeine/Ehcache/Guava/自研 Map)缺少统一标准协议 + - 若依赖 attach agent 或表达式执行,需严格控制安全边界与可观测性 + - 若目标 JVM 不允许预埋或动态注入 Agent,则“通用型”能力边界会明显收缩 + - 多接入模式会带来能力不一致问题,UI 与权限模型必须显式展示“当前模式支持什么/不支持什么” + - 当前 AI 能力边界仍是“分析 + 生成结构化计划 + 回填预览草稿”,不直接执行 JVM 写入,真实执行仍取决于 Guard、人工确认和 provider 能力 + - 当前 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 资源页重新生成计划 +- 阻塞: + - 当前开发收口阶段无新增阻塞 +- 缓解措施: + - 优先收敛到标准接入面(JMX / Spring Actuator / Java Agent 三选一) + - 首期只支持白名单对象类型与受控写操作 + - 要求变更审计、预览、确认与失败回滚路径 + - 在交付说明中明确“AI 只生成草稿,不直接执行 JVM 写入” + - JMX helper 改为内嵌 runtime jar,默认写入用户缓存目录;必要时允许通过 `GONAVI_JMX_HELPER_CLASSPATH` 覆盖 classpath + - 对复杂参数调用保持白名单和人工确认,不开放脚本式自由执行 + +## 6. 决策记录 +- 决策 1:先做可行性评估与方案设计,不直接进入实现 +- 决策 2:默认优先复用 GoNavi 现有 driver-agent 与前端编辑器能力,避免侵入式重构主流程 +- 决策 3:已按完整模式推进,后续方案将优先评估通用 Agent 路径是否成立 +- 决策 4:由于目标服务大概率不允许 agent/attach,后续推荐方向转为“多接入模式 + 能力协商” +- 决策 5:AI 在 JVM 场景中只负责分析与生成结构化计划,不直接执行运行时写入 +- 决策 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. 验证记录 +- 验证项: + - GoNavi 驱动代理机制核查 + - GoNavi 现有 Redis/编辑器/UI 复用能力核查 + - JVM Connector 正式设计文档自检 + - JVM Connector 实施计划文档自检 + - 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:前端生产构建 + - Task 7:Wails 生产构建 +- 结果: + - 已确认存在可复用的连接桥接与编辑器基础设施 + - 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突 + - 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节 + - 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测 + - Task 1 已完成规格审查与代码质量审查,结论均通过 + - 已完成 JVM Provider 工厂、JMX/Endpoint provider 骨架、App 层连接测试与能力探测 API + - Task 2 已完成规格审查与代码质量审查,结论均通过 + - 已完成 JVM 连接类型卡片、最小表单字段、连接测试分发与展示文案接入 + - Task 3 已完成规格审查与代码质量审查;过程中修复了 JVM 标题文案偏差、模式选项暴露范围、编辑态模式静默降级和 endpoint timeout 失真问题 + - 已完成 JVM 只读资源浏览链路:后端新增 `JVMListResources` / `JVMGetValue`,前端新增 `jvm-overview` / `jvm-resource` tab 与侧边栏 JVM 模式/资源节点 + - Task 4 已完成规格复审;代码质量复审确认真实 provider 浏览能力仍为后续任务范围,另外已修正 JVM 资源 tab 同名问题 + - 已完成 Task 5:后端新增 `JVMPreviewChange` / `JVMApplyChange` / `JVMListAuditRecords`,补齐 Guard、审计 JSONL 落盘与审计读取能力 + - Task 5 已补齐只读拦截、`prod` 环境确认、provider preview 错误透出、审计写入失败显式回传、连接 `allowedModes` 约束和局部快照合并保底 + - 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤 + - 已完成 Task 6:AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测 + - 已完成 Task 6:AI 聊天消息与 JVM 来源页签绑定,AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递 + - 已完成 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,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` + - `frontend/src/components/RedisViewer.tsx` + - `frontend/src/components/RedisCommandEditor.tsx` + - `frontend/src/components/QueryEditor.tsx` + - `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md` + - `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md` + - `internal/connection/types.go` + - `internal/jvm/types.go` + - `internal/jvm/config.go` + - `internal/jvm/config_test.go` + - `frontend/src/types.ts` + - `frontend/src/utils/jvmConnectionConfig.ts` + - `frontend/src/utils/jvmConnectionConfig.test.ts` + - `go test ./internal/jvm -count=1` + - `go test ./...` + - `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts` + - `cd frontend && npm test -- --run` + - `cd frontend && npm run build` + - `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` + - `frontend/wailsjs/go/app/App.js` + - `frontend/wailsjs/go/models.ts` + - `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` + - `go test ./internal/jvm ./internal/app -count=1` + - `wails build -clean` + - `frontend/src/components/DatabaseIcons.tsx` + - `frontend/src/components/ConnectionModal.tsx` + - `frontend/src/utils/jvmRuntimePresentation.ts` + - `frontend/src/utils/jvmRuntimePresentation.test.ts` + - `frontend/src/utils/jvmConnectionConfig.ts` + - `frontend/src/utils/jvmConnectionConfig.test.ts` + - `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts` + - `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts` + - `cd frontend && npm run build` + - `internal/app/methods_jvm.go` + - `internal/app/methods_jvm_test.go` + - `frontend/src/components/Sidebar.tsx` + - `frontend/src/components/TabManager.tsx` + - `frontend/src/components/JVMOverview.tsx` + - `frontend/src/components/JVMResourceBrowser.tsx` + - `frontend/src/components/jvm/JVMModeBadge.tsx` + - `frontend/src/store.ts` + - `frontend/src/types.ts` + - `go test ./internal/app -run 'TestJVM(ListResources|GetValue)' -count=1` + - `go test ./internal/app -run 'TestJVMProbeCapabilities|TestTestJVMConnection' -count=1` + - `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts` + - `cd frontend && npm run build` + - `internal/jvm/guard.go` + - `internal/jvm/guard_test.go` + - `internal/jvm/audit_store.go` + - `internal/jvm/audit_store_test.go` + - `internal/app/methods_jvm.go` + - `internal/app/methods_jvm_test.go` + - `frontend/src/components/JVMAuditViewer.tsx` + - `frontend/src/components/jvm/JVMChangePreviewModal.tsx` + - `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestPreviewChangeReturnsProviderPreviewErrorWhenWriteAllowed|TestPreviewChangeMarksProdWritesAsConfirmationRequired|TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults|TestJVMApplyChangeReturnsProviderPayload|TestJVMPreviewChangeRejectsModeOutsideAllowedModes|TestJVMListAuditRecordsReturnsLatestRecords|TestJVMApplyChangeSurfacesAuditWriteFailure' -count=1` + - `go test ./internal/jvm ./internal/app -count=1` + - `cd frontend && npm run build` + - `frontend/src/utils/jvmAiPlan.ts` + - `frontend/src/utils/jvmAiPlan.test.ts` + - `frontend/src/components/AIChatPanel.tsx` + - `frontend/src/components/ai/AIMessageBubble.tsx` + - `frontend/src/components/JVMResourceBrowser.tsx` + - `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. 下一步 +- 下一步行动:由用户按真实 JVM / endpoint 场景执行验收验证;若验收通过,再决定是否提交、推送或继续扩展高级类型写入 +- 负责人:Codex diff --git a/docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md b/docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md new file mode 100644 index 0000000..404541b --- /dev/null +++ b/docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md @@ -0,0 +1,24 @@ +# SQL 方言适配需求进度追踪 + +## 背景 + +- Oracle 等非 MySQL 数据源在表设计 DDL 预览中可能回落到 MySQL 语法,导致修改字段名、字段属性等操作执行失败。 +- GitHub 相关问题:Refs #402(金仓字段类型/DDL 方言)、Refs #409(Oracle 删除数据 DATE 字面量)。 + +## 范围 + +- 表设计 ALTER TABLE 预览:按 MySQL-family、PostgreSQL-family、Oracle/Dameng、SQL Server、SQLite、DuckDB、ClickHouse、TDengine 分支生成。 +- 新建表 DDL 预览:避免 Oracle/Dameng/SQL Server/SQLite/DuckDB/ClickHouse/TDengine 输出 MySQL 表选项。 +- SQL 自动补全:按当前连接方言解析关键字和函数,避免 Oracle/SQL Server 出现 MySQL-only 提示。 +- 表设计字段类型:按数据源给出候选类型,不再大量回退到 MySQL 通用类型。 +- Oracle/Dameng 数据复制/删除 SQL:DATE/TIMESTAMP 字段使用 Oracle 时间构造函数。 + +## 验证 + +- `npm test -- tableDesignerSchemaSql.test.ts sqlDialect.test.ts dataGridCopyInsert.test.ts` +- `npm run build` + +## 风险与后续 + +- ClickHouse/TDengine 的字段约束、默认值、备注语法差异较大,当前策略是生成有限原生 ALTER,并用中文注释阻止 MySQL 专属子句外溢。 +- SQL Server 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。 diff --git a/docs/需求追踪/需求进度追踪-发布脚本测试版号与Mac打包无交互-20260424.md b/docs/需求追踪/需求进度追踪-发布脚本测试版号与Mac打包无交互-20260424.md new file mode 100644 index 0000000..51d555c --- /dev/null +++ b/docs/需求追踪/需求进度追踪-发布脚本测试版号与Mac打包无交互-20260424.md @@ -0,0 +1,71 @@ +# 需求进度追踪 - 发布脚本测试版号与 Mac 打包无交互 + +## 1. 需求摘要 +- 需求名称:发布脚本测试版号与 Mac 打包无交互 +- 提出日期:2026-04-24 +- 负责人:Codex +- 目标: + - `build-release.sh` 不再触发 macOS DMG/Finder 排版交互。 + - `build-release.sh` 与开发态应用内版本号统一使用测试版号来源。 +- 非目标: + - 不调整 GitHub Release 工作流。 + - 不修改正式发布 tag 版本策略。 + +## 2. 范围与验收 +- 范围: + - 发布脚本 `build-release.sh` + - 版本解析逻辑 `internal/app/version.go` + - 共享测试版号文件 +- 验收标准: + - `bash build-release.sh` 的 macOS 打包不再调用 `create-dmg` 或触发 Finder 排版。 + - 本地开发态版本显示与发布脚本默认版本号一致。 + - 保留环境变量覆盖版本号能力。 +- 依赖与约束: + - 维持现有 Windows/Linux 构建逻辑不变。 + +## 3. 里程碑与进度 +- [x] 阶段 1(需求澄清):确认去掉 DMG 排版,统一测试版号来源 +- [x] 阶段 2(影响分析):锁定 `build-release.sh` 与 `internal/app/version.go` +- [x] 阶段 3(方案设计):共享 `version/dev-version.txt`,macOS 改 ZIP 打包 +- [x] 阶段 4(实施计划):先补版本回归测试,再改实现 +- [ ] 阶段 5(实现与自检): +- [ ] 阶段 6(评审与交付): +- [ ] 阶段 7(发布与观察): + +## 4. 变更清单 +- 已完成: + - 新增共享测试版号文件。 + - 新增版本回归测试。 + - 改造发布脚本 macOS 打包为无交互 ZIP。 +- 进行中: + - 自检验证。 +- 待处理: + - 无。 + +## 5. 风险与阻塞 +- 风险: + - 正式发版若未覆盖 `GONAVI_VERSION`,默认会使用测试版号。 +- 阻塞: + - 无。 +- 缓解措施: + - 允许通过 `GONAVI_VERSION` 环境变量显式覆盖。 + +## 6. 决策记录 +- 决策 1:以 `version/dev-version.txt` 作为本地开发/测试共享版本号来源。 +- 决策 2:发布脚本的 macOS 产物改为 ZIP,避免 `create-dmg` 的 Finder 交互。 + +## 7. 验证记录 +- 验证项: + - 版本回归测试 + - 发布脚本语法检查 + - 发布脚本运行输出 +- 结果: + - 进行中 +- 证据(日志/截图/链接): + - 待补充 + +## 8. 下一步 +- 下一步行动: + - 跑通回归测试和脚本验证,确认输出产物与版本号 +- 负责人: + - Codex diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 848588e..fa8a8b2 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -26a843d5fd071d0c7e9d8022e98eb4e3 \ No newline at end of file +571d014306268cf67665967059cda912 \ No newline at end of file diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 754aafe..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 { AIChatMessage, AIToolCall } from '../types'; +import type { + AIChatMessage, + AIToolCall, + JVMAIPlanContext, + JVMDiagnosticPlanContext, +} from '../types'; import { DownOutlined } from '@ant-design/icons'; import './AIChatPanel.css'; @@ -231,6 +236,8 @@ export const AIChatPanel: React.FC = ({ const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数 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); @@ -248,6 +255,50 @@ export const AIChatPanel: React.FC = ({ const activeTabId = useStore(state => state.activeTabId); const aiPanelVisible = useStore(state => state.aiPanelVisible); + const getCurrentJVMPlanContext = useCallback((): JVMAIPlanContext | undefined => { + const state = useStore.getState(); + const activeTab = state.tabs.find(t => t.id === state.activeTabId); + if (!activeTab || activeTab.type !== 'jvm-resource') { + return undefined; + } + + const activeConnection = state.connections.find(c => c.id === activeTab.connectionId); + if (activeConnection?.config?.type !== 'jvm') { + return undefined; + } + + const resourcePath = String(activeTab.resourcePath || '').trim(); + if (!resourcePath) { + return undefined; + } + + return { + tabId: activeTab.id, + connectionId: activeTab.connectionId, + providerMode: (activeTab.providerMode || activeConnection.config.jvm?.preferredMode || 'jmx') as JVMAIPlanContext['providerMode'], + resourcePath, + }; + }, []); + + 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; @@ -306,10 +357,15 @@ export const AIChatPanel: React.FC = ({ const messages = aiChatHistory[sid] || []; const getConnectionName = useCallback(() => { - if (!activeContext?.connectionId) return ''; - const conn = connections.find(c => c.id === activeContext.connectionId); + let connectionId = activeContext?.connectionId; + if (!connectionId) { + const activeTab = tabs.find(t => t.id === activeTabId); + connectionId = activeTab?.connectionId; + } + if (!connectionId) return ''; + const conn = connections.find(c => c.id === connectionId); return conn ? conn.name : ''; - }, [activeContext, connections]); + }, [activeContext, activeTabId, connections, tabs]); const activeConnName = getConnectionName(); @@ -493,7 +549,16 @@ export const AIChatPanel: React.FC = ({ if (assistantMsgId) { updateAIChatMessage(sid, assistantMsgId, { content: `❌ 错误: ${cleanErr}`, phase: 'idle', loading: false, rawError: rawErr }); } else { - addAIChatMessage(sid, { id: genId(), role: 'assistant', phase: 'idle', content: `❌ 错误: ${cleanErr}`, rawError: rawErr, timestamp: Date.now() }); + addAIChatMessage(sid, { + id: genId(), + role: 'assistant', + phase: 'idle', + content: `❌ 错误: ${cleanErr}`, + rawError: rawErr, + timestamp: Date.now(), + jvmPlanContext: pendingJVMPlanContextRef.current, + jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, + }); } assistantMsgId = ''; setSending(false); @@ -505,7 +570,17 @@ export const AIChatPanel: React.FC = ({ updateAIChatMessage(sid, assistantMsgId, { tool_calls: data.tool_calls, phase: 'tool_calling' }); } else { assistantMsgId = genId(); - addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'tool_calling', content: '', tool_calls: data.tool_calls, timestamp: Date.now(), loading: true }); + addAIChatMessage(sid, { + id: assistantMsgId, + role: 'assistant', + phase: 'tool_calling', + content: '', + tool_calls: data.tool_calls, + timestamp: Date.now(), + loading: true, + jvmPlanContext: pendingJVMPlanContextRef.current, + jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, + }); } } @@ -513,7 +588,17 @@ export const AIChatPanel: React.FC = ({ if (data.thinking) { if (!assistantMsgId) { assistantMsgId = genId(); - addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'thinking', content: '', thinking: data.thinking, timestamp: Date.now(), loading: true }); + addAIChatMessage(sid, { + id: assistantMsgId, + role: 'assistant', + phase: 'thinking', + content: '', + thinking: data.thinking, + timestamp: Date.now(), + loading: true, + jvmPlanContext: pendingJVMPlanContextRef.current, + jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, + }); if (sending) setSending(false); } else { streamBuffer.thinking += data.thinking; @@ -524,7 +609,16 @@ export const AIChatPanel: React.FC = ({ if (data.content) { if (!assistantMsgId) { assistantMsgId = genId(); - addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'generating', content: data.content, timestamp: Date.now(), loading: true }); + addAIChatMessage(sid, { + id: assistantMsgId, + role: 'assistant', + phase: 'generating', + content: data.content, + timestamp: Date.now(), + loading: true, + jvmPlanContext: pendingJVMPlanContextRef.current, + jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, + }); setSending(false); const currentHistory = useStore.getState().aiChatHistory[sid] || []; if (currentHistory.length <= 1) isFirstCompletion = true; @@ -584,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(); + const sysMessages = await buildSystemContextMessages( + existing.jvmPlanContext, + existing.jvmDiagnosticPlanContext, + ); // 追加催促消息 messagesPayload.push({ role: 'user', content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。' }); const allMsg = [...sysMessages, ...messagesPayload]; @@ -685,13 +782,20 @@ export const AIChatPanel: React.FC = ({ toolCallRoundRef.current = 0; totalToolRoundRef.current = 0; nudgeCountRef.current = 0; + const retryJVMPlanContext = msg.jvmPlanContext || getCurrentJVMPlanContext(); + const retryJVMDiagnosticPlanContext = + msg.jvmDiagnosticPlanContext || getCurrentJVMDiagnosticPlanContext(); + pendingJVMPlanContextRef.current = retryJVMPlanContext; + pendingJVMDiagnosticPlanContextRef.current = retryJVMDiagnosticPlanContext; setSending(true); // 插入 connecting 过渡消息(波纹动画),与 handleSend 保持一致 const connectingMsg: AIChatMessage = { id: genId(), role: 'assistant', phase: 'connecting', content: '', - timestamp: Date.now(), loading: true + timestamp: Date.now(), loading: true, + jvmPlanContext: retryJVMPlanContext, + jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext, }; addAIChatMessage(sid, connectingMsg); @@ -699,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(); + const sysMessages = await buildSystemContextMessages( + retryJVMPlanContext, + retryJVMDiagnosticPlanContext, + ); const allMessages = [...sysMessages, ...messagesPayload]; const Service = (window as any).go?.aiservice?.Service; @@ -713,7 +820,9 @@ export const AIChatPanel: React.FC = ({ id: genId(), role: 'assistant', content: result?.success ? result.content : `❌ ${errClean}`, rawError: (!result?.success && errClean !== errRaw) ? errRaw : undefined, - timestamp: Date.now() + timestamp: Date.now(), + jvmPlanContext: retryJVMPlanContext, + jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext, }); setSending(false); } else { @@ -722,24 +831,134 @@ export const AIChatPanel: React.FC = ({ } catch(e: any) { const rawE = e?.message || String(e); const cleanE = sanitizeErrorMsg(rawE); - addAIChatMessage(sid, { id: genId(), role: 'assistant', content: `❌ 发送失败: ${cleanE}`, rawError: cleanE !== rawE ? rawE : undefined, timestamp: Date.now() }); + addAIChatMessage(sid, { + id: genId(), + role: 'assistant', + content: `❌ 发送失败: ${cleanE}`, + rawError: cleanE !== rawE ? rawE : undefined, + timestamp: Date.now(), + jvmPlanContext: retryJVMPlanContext, + jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext, + }); setSending(false); } } - }, [sid, truncateAIChatMessages, addAIChatMessage]); + }, [ + sid, + truncateAIChatMessages, + addAIChatMessage, + getCurrentJVMPlanContext, + getCurrentJVMDiagnosticPlanContext, + ]); - const buildSystemContextMessages = useCallback(async () => { + 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 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 === overrideJVMDiagnosticPlanContext.tabId && matchesDiagnosticContext(t)) || + allTabs.find(t => matchesDiagnosticContext(t)) + ) + : 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') && + activeConnection?.config?.type === 'jvm' + ) { + const providerMode = activeTab.providerMode || activeConnection.config.jvm?.preferredMode || 'jmx'; + const resourcePath = activeTab.resourcePath || ''; + const readOnly = activeConnection.config.jvm?.readOnly !== false; + const environment = activeConnection.config.jvm?.environment || 'unknown'; + systemMessages.push({ + role: 'system', + content: `你是 GoNavi 的 JVM 运行时分析助手。当前上下文不是 SQL,而是 JVM 资源工作台。 + +当前连接:${activeConnection.name} +目标主机:${activeConnection.config.host || '-'} +Provider 模式:${providerMode} +运行环境:${environment} +连接策略:${readOnly ? '只读连接,只能分析和生成变更计划,绝不能假设已执行写入。' : '可写连接,但任何修改都必须先生成预览并等待人工确认。'} +${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体资源路径。'} + +回答规则: +1. 你可以解释资源结构、风险、修改建议和回滚建议。 +2. 如果用户要求生成 JVM 修改方案,必须输出一个唯一的 \`\`\`json 代码块,并且 JSON 字段严格限定为 targetType、selector、action、payload、reason。 +3. action 优先使用当前资源快照或元数据里已经声明的 supportedActions;如果当前资源没有声明,再基于快照内容谨慎推断。 +4. selector.resourcePath 优先使用当前资源路径;如果当前路径未知,就明确说明无法精确定位,不要编造路径。 +5. payload 只能使用 {"format":"json","value":{...}} 或 {"format":"text","value":"..."} 这两种包装形式,不要输出脚本、命令或裸值。 +6. 不要输出脚本、命令或“已经执行成功”之类的表述。` + }); + return systemMessages; + } let targetConnId = ctx?.connectionId; let targetDbName = ctx?.dbName; if (!targetConnId || !targetDbName) { - const activeTab = allTabs.find(t => t.id === tabId); if (activeTab && activeTab.connectionId && activeTab.dbName) { targetConnId = activeTab.connectionId; targetDbName = activeTab.dbName; @@ -804,6 +1023,13 @@ SELECT * FROM users WHERE status = 1; const toolContextMapRef = useRef>(new Map()); 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; totalToolRoundRef.current += 1; @@ -813,6 +1039,8 @@ SELECT * FROM users WHERE status = 1; id: genId(), role: 'assistant', content: `⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`, timestamp: Date.now(), + jvmPlanContext: inheritedJVMPlanContext, + jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, }); setSending(false); return; @@ -1001,6 +1229,8 @@ SELECT * FROM users WHERE status = 1; id: genId(), role: 'assistant', content: '⚠️ 探针连续 3 轮执行失败,自动终止。请检查连接状态后重试。', timestamp: Date.now(), + jvmPlanContext: inheritedJVMPlanContext, + jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, }); setSending(false); return; @@ -1014,7 +1244,9 @@ SELECT * FROM users WHERE status = 1; const chainConnectingMsg: AIChatMessage = { id: genId(), role: 'assistant', phase: 'connecting', content: '汇总探针执行结果中', - timestamp: Date.now(), loading: true + timestamp: Date.now(), loading: true, + jvmPlanContext: inheritedJVMPlanContext, + jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, }; useStore.getState().addAIChatMessage(sid, chainConnectingMsg); @@ -1041,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(); + const sysMessages = await buildSystemContextMessages( + inheritedJVMPlanContext, + inheritedJVMDiagnosticPlanContext, + ); let finalMessagesPayload = messagesPayload; // 在这里加入长度检查和自动摘要(带上动态限额) @@ -1079,6 +1314,8 @@ SELECT * FROM users WHERE status = 1; content: result?.success ? result.content : `❌ ${errC}`, rawError: (!result?.success && errC !== errR) ? errR : undefined, timestamp: Date.now(), + jvmPlanContext: inheritedJVMPlanContext, + jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, }); setSending(false); } @@ -1106,6 +1343,10 @@ SELECT * FROM users WHERE status = 1; toolCallRoundRef.current = 0; // 重置工具调用轮次计数 totalToolRoundRef.current = 0; // 重置总轮次计数 nudgeCountRef.current = 0; // 重置催促计数 + const currentJVMPlanContext = getCurrentJVMPlanContext(); + const currentJVMDiagnosticPlanContext = getCurrentJVMDiagnosticPlanContext(); + pendingJVMPlanContextRef.current = currentJVMPlanContext; + pendingJVMDiagnosticPlanContextRef.current = currentJVMDiagnosticPlanContext; const currentImages = [...draftImages]; setInput(''); @@ -1124,11 +1365,16 @@ SELECT * FROM users WHERE status = 1; const connectingMsg: AIChatMessage = { id: genId(), role: 'assistant', phase: 'connecting', content: '', - timestamp: Date.now(), loading: true + 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: '模型接入中' }); @@ -1176,6 +1422,8 @@ SELECT * FROM users WHERE status = 1; content: result?.success ? result.content : `❌ ${errC2}`, rawError: (!result?.success && errC2 !== errR2) ? errR2 : undefined, timestamp: Date.now(), + jvmPlanContext: currentJVMPlanContext, + jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, }; addAIChatMessage(sid, assistantMsg); setSending(false); @@ -1185,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]); + }, [ + 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 ff1b4b6..1784394 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1,112 +1,216 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd'; -import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined, BgColorsOutlined } from '@ant-design/icons'; -import { 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, + Select, + Alert, + Card, + Row, + Col, + Typography, + Space, + Table, + Tag, + Switch, +} from "antd"; +import { + DatabaseOutlined, + FileTextOutlined, + CloudOutlined, + CheckCircleFilled, + CloseCircleFilled, + LinkOutlined, + EditOutlined, + AppstoreOutlined, + BgColorsOutlined, + ApiOutlined, + ClusterOutlined, + CodeOutlined, + GatewayOutlined, + SafetyCertificateOutlined, + ThunderboltOutlined, +} 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 { + getConnectionConfigLayoutKindLabel, + getConnectionConfigSectionCopy, 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'; -import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App'; -import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types'; + resolveConnectionConfigLayout, + summarizeConnectionTestFailureMessage, + type ConnectionConfigSectionKey, +} 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"; -const { Meta } = Card; const { Text } = Typography; +type EditableJVMMode = (typeof JVM_EDITABLE_MODES)[number]; +type ChoiceCardOption = { + value: string; + label: string; + description?: string; +}; const MAX_URI_LENGTH = 4096; 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 '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", + "doris", + "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; @@ -116,9 +220,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; }; @@ -135,1292 +241,2057 @@ 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 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 sslMode = Form.useWatch("sslMode", form) || "preferred"; + const proxyType = Form.useWatch("proxyType", form) || "socks5"; + const mongoReadPreference = + Form.useWatch("mongoReadPreference", form) || "primary"; + const mongoAuthMechanism = Form.useWatch("mongoAuthMechanism", form) || ""; + const jvmEnvironment = Form.useWatch("jvmEnvironment", form) || "dev"; + 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 === "doris" || + 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 jvmSectionCardStyle = (): React.CSSProperties => ({ + ...modalInnerSectionStyle, + padding: 16, + }); + + const renderJvmSectionHeader = ( + icon: React.ReactNode, + title: string, + description: string, + badge?: React.ReactNode, + ) => ( +
+
+
+ {icon} +
+
+
+ {title} +
+
+ {description} +
+
+
+ {badge ?
{badge}
: null} +
+ ); + + const configSectionCardStyle = (): React.CSSProperties => ({ + padding: 16, + borderRadius: 16, + border: darkMode + ? "1px solid rgba(255,255,255,0.08)" + : "1px solid rgba(16,24,40,0.08)", + background: darkMode + ? "rgba(255,255,255,0.025)" + : "rgba(255,255,255,0.70)", + boxShadow: darkMode + ? "inset 0 1px 0 rgba(255,255,255,0.04)" + : "inset 0 1px 0 rgba(255,255,255,0.90)", + }); + + const renderConfigSectionCard = ({ + sectionKey, + icon, + children, + badge, + }: { + sectionKey: ConnectionConfigSectionKey; + icon: React.ReactNode; + children: React.ReactNode; + badge?: React.ReactNode; + }) => { + const copy = getConnectionConfigSectionCopy(sectionKey); + return ( +
+ {renderJvmSectionHeader(icon, copy.title, copy.description, badge)} + {children} +
+ ); + }; + + const clearConnectionTestResultForChoice = () => { + if (testResult) { + setTestResult(null); + setTestErrorLogOpen(false); + } + }; + + const setChoiceFieldValue = (fieldName: string, value: string | boolean) => { + clearConnectionTestResultForChoice(); + form.setFieldValue(fieldName, value); + if ( + fieldName === "mongoTopology" || + fieldName === "mongoSrv" || + fieldName === "host" || + fieldName === "port" + ) { + setMongoMembers([]); + } + if (fieldName === "redisTopology") { + 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 (fieldName === "proxyType") { + const nextType = String(value || "socks5").toLowerCase(); + const currentPort = Number(form.getFieldValue("proxyPort") || 0); + if (nextType === "http") { + if (!currentPort || currentPort === 1080) { + form.setFieldValue("proxyPort", 8080); + } + } else if (!currentPort || currentPort === 8080) { + form.setFieldValue("proxyPort", 1080); } - 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 renderChoiceCards = ({ + fieldName, + value, + options, + minWidth = 180, + onSelect, + }: { + fieldName: string; + value: string; + options: ChoiceCardOption[]; + minWidth?: number; + onSelect?: (value: string) => void; + }) => ( + <> + +
+ {options.map((option) => { + const active = String(value ?? "") === option.value; + return ( + + ); + })} +
+ + ); + + const applyJvmModeSelection = ( + nextModes: EditableJVMMode[], + preferredMode?: EditableJVMMode, + ) => { + const normalizedModes = normalizeEditableJVMModes(nextModes); + const resolvedModes = normalizedModes.length ? normalizedModes : ["jmx"]; + const resolvedPreferred = + preferredMode && resolvedModes.includes(preferredMode) + ? preferredMode + : resolvedModes.includes(jvmPreferredMode as EditableJVMMode) + ? (jvmPreferredMode as EditableJVMMode) + : resolvedModes[0]; + form.setFieldsValue({ + jvmAllowedModes: resolvedModes, + jvmPreferredMode: resolvedPreferred, + jvmEndpointEnabled: resolvedModes.includes("endpoint"), + jvmAgentEnabled: resolvedModes.includes("agent"), + }); + }; + + const handleJvmModeCardSelect = (mode: EditableJVMMode) => { + const enabled = normalizedJvmAllowedModes.includes(mode); + applyJvmModeSelection( + enabled ? normalizedJvmAllowedModes : [...normalizedJvmAllowedModes, mode], + mode, + ); + }; + + const handleJvmModeToggle = ( + mode: EditableJVMMode, + event: React.MouseEvent, + ) => { + event.stopPropagation(); + const enabled = normalizedJvmAllowedModes.includes(mode); + if (!enabled) { + applyJvmModeSelection([...normalizedJvmAllowedModes, mode], mode); + return; + } + if (normalizedJvmAllowedModes.length <= 1) { + return; + } + const nextModes = normalizedJvmAllowedModes.filter((item) => item !== mode); + applyJvmModeSelection(nextModes, nextModes[0]); + }; + + 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 defaultPort = getDefaultPortByType(configType); - const isFileDbConfigType = isFileDatabaseType(configType); - 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 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: config.timeout || 30, - 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 || '' - }); - 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); @@ -1429,22 +2300,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(); @@ -1452,1775 +2327,4218 @@ 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 - const isRedisType = values.type === 'redis'; - const res = await withClientTimeout( - 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 { - // 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 }; - const parsedUriValues = parseUriToValues(mergedValues.uri, mergedValues.type); - const isEmptyField = (value: unknown) => ( - value === undefined - || value === null - || value === '' - || value === 0 - || (Array.isArray(value) && value.length === 0) + 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 后再测试或保存", + ); + } + 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 (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 isCustom = dbType === "custom"; + const isRedis = dbType === "redis"; + const isJVM = dbType === "jvm"; + const connectionConfigLayout = resolveConnectionConfigLayout(dbType); + 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: '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 getDbTypeHint = (type: string) => { + switch (type) { + case "jvm": + return "JMX / Endpoint / Agent"; + case "custom": + return "自定义驱动与 DSN"; + case "redis": + return "单机 / 集群"; + case "mongodb": + return "单机 / 副本集"; + case "sqlite": + case "duckdb": + return "本地文件连接"; + default: + return "标准连接配置"; + } + }; const renderStep1 = () => ( -
-
-
选择数据源
-
先选择目标数据库或中间件类型,再进入详细连接参数配置。
-
- {typeSelectWarning && ( - - {typeSelectWarning.reason} - - - )} - onClose={() => setTypeSelectWarning(null)} - /> - )} -
- {/* 左侧分类导航 */} -
- {dbTypeGroups.map((group, idx) => ( +
+
+
+ 选择数据源 +
+
+ 先选择目标数据库或中间件类型,再进入详细连接参数配置。 +
+
+ {typeSelectWarning && ( + + {typeSelectWarning.reason} + + + } + onClose={() => setTypeSelectWarning(null)} + /> + )} +
+ {/* 左侧分类导航 */} +
+ {dbTypeGroups.map((group, idx) => ( +
setActiveGroup(idx)} + style={{ + padding: "11px 12px", + cursor: "pointer", + borderRadius: 12, + marginBottom: 6, + background: + activeGroup === idx ? step1SidebarActiveBg : "transparent", + color: + activeGroup === idx ? step1SidebarActiveColor : undefined, + fontWeight: activeGroup === idx ? 700 : 500, + transition: "all 0.2s", + fontSize: 13, + }} + > + {group.label} +
+ ))} +
+ {/* 右侧数据源卡片 */} +
+ + {dbTypeGroups[activeGroup]?.items.map((item) => ( + + { + void handleTypeSelect(item.key); + }} + style={{ + cursor: "pointer", + minHeight: 92, + borderRadius: 16, + border: darkMode + ? "1px solid rgba(255,255,255,0.08)" + : "1px solid rgba(16,24,40,0.08)", + background: darkMode + ? "rgba(255,255,255,0.03)" + : "rgba(255,255,255,0.80)", + }} + styles={{ + body: { + padding: 14, + display: "flex", + alignItems: "flex-start", + gap: 12, + height: "100%", + }, + }} + >
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, - }} + style={{ + width: 44, + height: 44, + borderRadius: 14, + display: "grid", + placeItems: "center", + flexShrink: 0, + background: darkMode + ? "rgba(255,255,255,0.05)" + : "rgba(22,119,255,0.08)", + }} > - {group.label} + {item.icon}
- ))} -
- {/* 右侧数据源卡片 */} -
- - {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} + + + {getDbTypeHint(item.key)} + +
+ + + ))} + +
+
); const renderStep2 = () => { - const baseInfoSection = ( -
-
基础信息
-
常用参数集中在左侧,优先完成连接建立所需的最小输入。
+ const baseInfoSection = ( +
+
+ 基础信息 +
+
+ 常用参数集中在左侧,优先完成连接建立所需的最小输入。 +
- - +
+ {renderConfigSectionCard({ + sectionKey: "identity", + icon: , + badge: ( + + {getConnectionConfigLayoutKindLabel(connectionConfigLayout.kind)} + + ), + children: ( + + + ), + })} - {!isCustom && ( + {!isCustom && + !isJVM && + renderConfigSectionCard({ + sectionKey: "uri", + icon: , + children: ( + <> + + + + + + + + + {uriFeedback && ( + setUriFeedback(null)} + style={{ marginBottom: 16 }} + /> + )} + {renderStoredSecretControls({ + fieldName: "uri", + clearKey: "opaqueURI", + hasStoredSecret: initialValues?.hasOpaqueURI, + clearLabel: "清除已保存 URI", + description: + "当前已保存连接 URI。留空表示继续沿用,输入新值表示替换。", + })} + + ), + })} + + {isCustom ? ( + <> + {renderConfigSectionCard({ + sectionKey: "customDriver", + icon: , + children: ( + + + + ), + })} + {renderConfigSectionCard({ + sectionKey: "customDsn", + icon: , + children: ( <> - + + + {renderStoredSecretControls({ + fieldName: "dsn", + clearKey: "opaqueDSN", + hasStoredSecret: initialValues?.hasOpaqueDSN, + clearLabel: "清除已保存 DSN", + description: + "当前已保存连接字符串。留空表示继续沿用,输入新值表示替换。", + })} + + ), + })} + + ) : isJVM ? ( + <> + {unsupportedJvmModeMessage && ( + + )} +
+
+ {renderJvmSectionHeader( + , + "目标 JVM", + "定义连接树中的主机入口和基础运行环境。", + )} +
+ + + + + + +
+
+
+ 环境 + {renderChoiceCards({ + fieldName: "jvmEnvironment", + value: String(jvmEnvironment), + minWidth: 120, + options: [ + { + value: "dev", + label: "开发 / 测试", + description: "本地或测试环境。", + }, + { + value: "uat", + label: "预发 / 验收", + description: "上线前验证环境。", + }, + { + value: "prod", + label: "生产", + description: "生产 JVM,默认更谨慎。", + }, + ], + })} +
+ + + + + 只读优先 + +
+
+ +
+ {renderJvmSectionHeader( + , + "接入模式", + "通过卡片选择允许使用的 JVM 通道;已启用卡片再次点击会设为首选。", + )} + +
+ {JVM_EDITABLE_MODES.map((mode) => { + const meta = resolveJVMModeMeta(mode); + const enabled = normalizedJvmAllowedModes.includes(mode); + const preferred = jvmPreferredMode === mode; + return ( +
handleJvmModeCardSelect(mode)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleJvmModeCardSelect(mode); + } + }} + aria-pressed={enabled} + style={{ + textAlign: "left", + padding: 14, + borderRadius: 16, + border: enabled + ? darkMode + ? "1px solid rgba(255,214,102,0.36)" + : "1px solid rgba(22,119,255,0.34)" + : darkMode + ? "1px solid rgba(255,255,255,0.08)" + : "1px solid rgba(16,24,40,0.08)", + background: enabled + ? darkMode + ? "rgba(255,214,102,0.08)" + : "rgba(22,119,255,0.06)" + : darkMode + ? "rgba(255,255,255,0.03)" + : "rgba(16,24,40,0.03)", + boxShadow: preferred + ? darkMode + ? "0 0 0 2px rgba(255,214,102,0.12)" + : "0 0 0 2px rgba(22,119,255,0.10)" + : "none", + color: darkMode ? "#f5f7ff" : "#162033", + cursor: "pointer", + transition: "all 120ms ease", + }} > - - - - - - - - {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: '当前已保存连接字符串。留空表示继续沿用,输入新值表示替换。', - })} - - ) : ( - <> -
- - - - {isFileDb ? ( - - - - ) : ( - Number(value) > 0)]} - style={{ marginBottom: 0 }} - > - - - )} + + + {meta.label} + + {preferred ? 首选 : null} + {!enabled ? 未启用 : null} + +
+ {mode === "jmx" + ? "标准 MBean 与线程、内存、类加载等运行时指标。" + : mode === "endpoint" + ? "通过服务端管理接口读取 JVM 资源与配置。" + : "通过 GoNavi Java Agent 提供更完整的增强能力。"} +
+
+ ); + })} +
+
+ 当前首选: + {resolveJVMModeMeta(String(jvmPreferredMode || "jmx")).label} + 。至少保留一种接入模式,停用首选模式时会自动切换到剩余模式。 +
+
- {(dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase') && ( +
+ {renderJvmSectionHeader( + , + "JMX", + "标准 JVM 管理通道,可覆盖主机/端口并配置认证。", + + {normalizedJvmAllowedModes.includes("jmx") ? "已启用" : "未启用"} + , + )} +
+ + + + + + +
+
+ + + + + + +
+
+ +
+ {renderJvmSectionHeader( + , + "Endpoint", + "连接应用暴露的 JVM 管理端点,适合已有运维 API 的服务。", + + {normalizedJvmAllowedModes.includes("endpoint") + ? "已启用" + : "未启用"} + , + )} + + + + + + +
+ +
+ {renderJvmSectionHeader( + , + "Agent", + "连接 GoNavi Java Agent 管理端口,用于增强采集和诊断链路。", + + {normalizedJvmAllowedModes.includes("agent") ? "已启用" : "未启用"} + , + )} + + + + + + +
+ +
+ {renderJvmSectionHeader( + , + "诊断增强", + "开启后可创建 JVM 诊断会话并执行受控 Arthas/诊断命令。", + + + , + )} + {jvmDiagnosticEnabled ? ( + <> +
+
+ 诊断传输 + {renderChoiceCards({ + fieldName: "jvmDiagnosticTransport", + value: String(jvmDiagnosticTransport), + options: [ + { + value: "agent-bridge", + label: "Agent Bridge", + description: "通过 GoNavi Agent 桥接诊断命令。", + }, + { + value: "arthas-tunnel", + label: "Arthas Tunnel", + description: "连接官方 Tunnel / Web Console。", + }, + ], + })} +
+ + + +
+
+ + + + + + +
+ + + +
+ {[ + { + name: "jvmDiagnosticAllowObserveCommands", + label: "观察类命令", + description: "thread、dashboard、jvm 等只读排查命令。", + }, + { + name: "jvmDiagnosticAllowTraceCommands", + label: "跟踪类命令", + description: "trace、watch 等对目标有额外开销的命令。", + }, + { + name: "jvmDiagnosticAllowMutatingCommands", + label: "高风险命令", + description: "可能改变运行态或造成明显性能影响的命令。", + }, + ].map((item) => ( +
- + {item.label} - )} - - {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 && ( - <> -
- - - - - - - {dbType === 'mongodb' && ( - - - {dbList.map(db => {db})} - - - )} +
+ ))} +
- )} - - ); + ) : ( +
+ 关闭时只保存 JVM 连接与监控能力,不显示诊断会话入口。 +
+ )} + + + + ) : ( + <> + {renderConfigSectionCard({ + sectionKey: isFileDb ? "fileTarget" : "target", + icon: isFileDb ? : , + children: ( +
+ + + + {isFileDb ? ( + + + + ) : ( + Number(value) > 0, + ), + ]} + style={{ marginBottom: 0 }} + > + + + )} +
+ ), + })} - const networkSecuritySection = !isFileDb ? (() => { - const networkItems: Array<{ - key: 'ssl' | 'ssh' | 'proxy' | 'httpTunnel'; + {(dbType === "postgres" || + dbType === "kingbase" || + dbType === "highgo" || + dbType === "vastbase") && + renderConfigSectionCard({ + sectionKey: "service", + icon: , + children: ( + + + + ), + })} + + {dbType === "oracle" && + renderConfigSectionCard({ + sectionKey: "service", + icon: , + children: ( + + + + ), + })} + + {isMySQLLike && + renderConfigSectionCard({ + sectionKey: "connectionMode", + icon: , + children: renderChoiceCards({ + fieldName: "mysqlTopology", + value: String(mysqlTopology), + options: [ + { + value: "single", + label: "单机模式", + description: "只连接一个主库地址,适合本地和单实例。", + }, + { + value: "replica", + label: "主从模式", + description: "主库优先,可配置从库地址用于切换。", + }, + ], + }), + })} + + {isMySQLLike && + mysqlTopology === "replica" && + renderConfigSectionCard({ + sectionKey: "replica", + icon: , + children: ( + <> + + + + + + + + {renderStoredSecretControls({ + fieldName: "mysqlReplicaPassword", + clearKey: "mysqlReplicaPassword", + hasStoredSecret: initialValues?.hasMySQLReplicaPassword, + clearLabel: "清除已保存从库密码", + description: + "当前已保存从库密码。留空表示继续沿用,输入新值表示替换。", + })} + + ), + })} + + {dbType === "mongodb" && + renderConfigSectionCard({ + sectionKey: "connectionMode", + icon: , + children: renderChoiceCards({ + fieldName: "mongoTopology", + value: String(mongoTopology), + options: [ + { + value: "single", + label: "单机模式", + description: "只连接一个 MongoDB 节点。", + }, + { + value: "replica", + label: "副本集 / 多节点", + description: "配置副本集名称和多个候选节点。", + }, + ], + }), + })} + + {dbType === "mongodb" && + renderConfigSectionCard({ + sectionKey: "mongoDiscovery", + icon: , + children: ( + <> + +
+ {[ + { + value: false, + label: "标准地址", + description: "使用 host:port 直连或副本集节点列表。", + }, + { + value: true, + label: "SRV 地址", + description: + "使用 mongodb+srv,由 DNS 发现目标节点。", + }, + ].map((option) => { + const active = mongoSrv === option.value; + return ( + + ); + })} +
+ {mongoSrv && useSSH && ( + + )} + + ), + })} + + {dbType === "mongodb" && + mongoTopology === "replica" && + renderConfigSectionCard({ + sectionKey: "replica", + icon: , + children: ( + <> + + + + + + + + + + + {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 ? "正常" : "异常"} + + ), + }, + ]} + /> + )} + + ), + })} + + {dbType === "mongodb" && + renderConfigSectionCard({ + sectionKey: "mongoPolicy", + icon: , + children: ( +
+ + + +
+ 读偏好 (readPreference) + {renderChoiceCards({ + fieldName: "mongoReadPreference", + value: String(mongoReadPreference), + minWidth: 130, + options: [ + { + value: "primary", + label: "primary", + description: "只读主节点。", + }, + { + value: "primaryPreferred", + label: "primaryPreferred", + description: "主节点优先。", + }, + { + value: "secondary", + label: "secondary", + description: "只读从节点。", + }, + { + value: "secondaryPreferred", + label: "secondaryPreferred", + description: "从节点优先。", + }, + { + value: "nearest", + label: "nearest", + description: "选择最近节点。", + }, + ], + })} +
+
+ ), + })} + + {isRedis && + renderConfigSectionCard({ + sectionKey: "connectionMode", + icon: , + children: ( + <> + {renderChoiceCards({ + fieldName: "redisTopology", + value: String(redisTopology), + options: [ + { + value: "single", + label: "单机模式", + description: "只连接一个 Redis 节点。", + }, + { + value: "cluster", + label: "集群模式", + description: "Redis Cluster,配置多个种子节点。", + }, + ], + })} + {redisTopology === "cluster" && ( + + + {redisDbList.map((db) => ( + + db{db} + + ))} + + + ), + })} + + {!isFileDb && + !isRedis && + renderConfigSectionCard({ + sectionKey: "credentials", + icon: , + children: ( + <> +
+ + + + + + + {dbType === "mongodb" && ( +
+ 验证方式 + {renderChoiceCards({ + fieldName: "mongoAuthMechanism", + value: String(mongoAuthMechanism), + minWidth: 150, + options: [ + { + value: "", + label: "自动协商", + description: "交给驱动按服务端能力选择。", + }, + { + value: "NONE", + label: "无认证", + description: "不发送认证信息。", + }, + { + value: "SCRAM-SHA-1", + label: "SCRAM-SHA-1", + description: "兼容旧版本 MongoDB。", + }, + { + value: "SCRAM-SHA-256", + label: "SCRAM-SHA-256", + description: "推荐的 SCRAM 认证。", + }, + { + value: "MONGODB-AWS", + label: "MONGODB-AWS", + description: "AWS IAM 认证。", + }, + ], + })} +
+ )} +
+ {renderStoredSecretControls({ + fieldName: "password", + clearKey: "primaryPassword", + hasStoredSecret: initialValues?.hasPrimaryPassword, + clearLabel: "清除已保存密码", + description: + "当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。", + })} + {dbType === "mongodb" && ( + + 保存密码 + + )} + + ), + })} + + {!isFileDb && + !isRedis && + renderConfigSectionCard({ + sectionKey: "databaseScope", + icon: , + children: ( + + + + ), + })} + + )} + + + ); + + 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”后,可在这里配置模式、证书与校验策略。
- ); + ) : ( +
+
+ SSL 模式 + {renderChoiceCards({ + fieldName: "sslMode", + value: String(sslMode), + options: [ + { + value: "preferred", + label: "Preferred", + description: "优先使用 SSL,失败后按驱动策略处理。", + }, + { + value: "required", + label: "Required", + description: "必须使用 SSL,并进行证书校验。", + }, + { + value: "skip-verify", + label: "Skip Verify", + description: "必须使用 SSL,但跳过证书校验。", + }, + ], + })} +
+ {dbType === "dameng" && ( + <> + + + + + + + + )} + + {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 ? ( +
+ 左侧勾选“代理”后,可在这里选择代理类型并填写主机、端口与认证信息。
- ); + ) : ( +
+ + + +
+
+ 代理类型 + {renderChoiceCards({ + fieldName: "proxyType", + value: String(proxyType), + minWidth: 150, + options: [ + { + value: "socks5", + label: "SOCKS5", + description: "常见本地代理和网关代理。", + }, + { + value: "http", + label: "HTTP CONNECT", + description: "通过 HTTP CONNECT 建立隧道。", + }, + ], + })} +
+ + + +
+
+ + + + + + +
+ {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: isJVM + ? "JVM 目标、接入模式、JMX、Endpoint、Agent 与诊断增强" + : "名称、地址、认证、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.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 ? [{ 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; - 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 || "暂无失败日志")}
+        
); }; export default ConnectionModal; - - diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index bb1edbf..9ae88f6 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -77,4 +77,50 @@ describe('DataGrid layout', () => { expect(markup).toContain('data-grid-secondary-actions="true"'); expect(markup).toContain('data-grid-view-switcher="true"'); }); + + it('renders row copy and paste actions in editable table toolbar', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-grid-copy-row-action="true"'); + expect(markup).toContain('data-grid-paste-row-action="true"'); + expect(markup).toContain('复制行'); + expect(markup).toContain('粘贴行'); + }); + + it('renders a quick WHERE condition editor when table filters are visible', () => { + const markup = renderToStaticMarkup( + {}} + />, + ); + + expect(markup).toContain('data-grid-quick-where="true"'); + expect(markup).toContain('WHERE'); + expect(markup).toContain('输入 WHERE 后面的条件'); + }); }); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 42740e9..d3ac6cc 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1,7 +1,7 @@ // cspell:ignore anticon sqls uuidv uuidv4 hscroll import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd'; +import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker, AutoComplete } from 'antd'; import dayjs from 'dayjs'; import type { SortOrder, ColumnType } from 'antd/es/table/interface'; import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined } from '@ant-design/icons'; @@ -50,6 +50,7 @@ import { } from './dataGridCopyInsert'; import { calculateAutoFitColumnWidth } from './dataGridAutoWidth'; import { buildSelectedCellClipboardText } from './dataGridSelectionCopy'; +import { buildCopiedRowsForPaste, buildPastedRowsFromCopiedRows } from './dataGridRowClipboard'; import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap'; import { TEMPORAL_FORMATS, @@ -60,6 +61,13 @@ import { resolveTemporalEditorSaveValue, type TemporalPickerType, } from './dataGridTemporal'; +import { + buildEffectiveFilterConditions, + normalizeQuickWhereCondition, + resolveWhereConditionSelectedValue, + resolveWhereConditionSuggestions, + validateQuickWhereCondition, +} from '../utils/dataGridWhereFilter'; // --- Error Boundary --- interface DataGridErrorBoundaryState { @@ -888,6 +896,8 @@ interface DataGridProps { exportSqlWithFilter?: string; onApplyFilter?: (conditions: GridFilterCondition[]) => void; appliedFilterConditions?: FilterCondition[]; + quickWhereCondition?: string; + onApplyQuickWhereCondition?: (condition: string) => void; scrollSnapshot?: { top: number; left: number }; onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void; } @@ -913,7 +923,8 @@ const VIRTUAL_CELL_WRAPPER_STYLE: React.CSSProperties = { margin: -8, padding: ' const DataGrid: React.FC = ({ data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false, - onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, + onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition, + onApplyQuickWhereCondition, scrollSnapshot, onScrollSnapshotChange }) => { const connections = useStore(state => state.connections); @@ -1221,6 +1232,7 @@ const DataGrid: React.FC = ({ const lastTableScrollLeftRef = useRef(0); const lastExternalScrollLeftRef = useRef(0); const pendingScrollToBottomRef = useRef(false); + const pastedRowSequenceRef = useRef(0); const lastReportedScrollRef = useRef<{ top: number; left: number }>({ top: 0, left: 0 }); const didRestoreScrollRef = useRef(false); @@ -1228,6 +1240,7 @@ const DataGrid: React.FC = ({ const [cellEditMode, setCellEditMode] = useState(false); const [selectedCells, setSelectedCells] = useState>(new Set()); const [copiedCellPatch, setCopiedCellPatch] = useState<{ sourceRowKey: string; values: Record } | null>(null); + const [copiedRowsForPaste, setCopiedRowsForPaste] = useState>>([]); const [batchEditModalOpen, setBatchEditModalOpen] = useState(false); const [batchEditValue, setBatchEditValue] = useState(''); const [batchEditSetNull, setBatchEditSetNull] = useState(false); @@ -2196,6 +2209,7 @@ const DataGrid: React.FC = ({ // Filter State const [filterConditions, setFilterConditions] = useState([]); const [nextFilterId, setNextFilterId] = useState(1); + const [quickWhereDraft, setQuickWhereDraft] = useState(() => normalizeQuickWhereCondition(quickWhereCondition)); const filterPanelRef = useRef(null); useEffect(() => { @@ -2205,6 +2219,29 @@ const DataGrid: React.FC = ({ setNextFilterId(Math.max(1, maxId + 1)); }, [appliedFilterConditions, normalizeGridFilterConditions]); + useEffect(() => { + setQuickWhereDraft(normalizeQuickWhereCondition(quickWhereCondition)); + }, [quickWhereCondition]); + + const quickWhereSuggestionOptions = useMemo(() => { + const columnSuggestionSource = allTableColumnNames.length > 0 ? allTableColumnNames : displayColumnNames; + return resolveWhereConditionSuggestions({ + input: quickWhereDraft, + columnNames: columnSuggestionSource, + dbType, + }).map((item) => ({ + value: item.value, + insertText: item.insertText, + suggestionKind: item.kind, + label: ( +
+ {item.label} + {item.detail} +
+ ), + })); + }, [allTableColumnNames, displayColumnNames, quickWhereDraft, dbType, darkMode]); + useEffect(() => { if (!showFilter) { return; @@ -2251,6 +2288,7 @@ const DataGrid: React.FC = ({ setDeletedRowKeys(new Set()); setSelectedRowKeys([]); setCopiedCellPatch(null); + setCopiedRowsForPaste([]); setRowEditorOpen(false); setRowEditorRowKey(''); rowEditorBaseRawRef.current = {}; @@ -3622,6 +3660,55 @@ const DataGrid: React.FC = ({ pendingScrollToBottomRef.current = true; setAddedRows(prev => [...prev, newRow]); }; + + const handleCopySelectedRowsForPaste = useCallback(() => { + if (selectedRowKeys.length === 0) { + void message.info('请先选择要复制的行'); + return; + } + + const copiedRows = buildCopiedRowsForPaste({ + rows: mergedDisplayData as Array>, + selectedRowKeys, + columnNames, + rowKeyField: GONAVI_ROW_KEY, + rowKeyToString: rowKeyStr, + }); + if (copiedRows.length === 0) { + void message.info('未识别到可复制的行'); + return; + } + + setCopiedRowsForPaste(copiedRows); + void message.success(`已复制 ${copiedRows.length} 行,可粘贴为新增行`); + }, [selectedRowKeys, mergedDisplayData, columnNames, rowKeyStr]); + + const handlePasteCopiedRowsAsNew = useCallback(() => { + if (copiedRowsForPaste.length === 0) { + void message.info('请先复制行'); + return; + } + + const nextRows = buildPastedRowsFromCopiedRows({ + rows: copiedRowsForPaste, + columnNames, + rowKeyField: GONAVI_ROW_KEY, + createRowKey: (index) => { + pastedRowSequenceRef.current += 1; + return `paste-${Date.now()}-${pastedRowSequenceRef.current}-${index}`; + }, + }); + if (nextRows.length === 0) { + void message.info('没有可粘贴的行'); + return; + } + + pendingScrollToBottomRef.current = true; + setAddedRows(prev => [...prev, ...nextRows]); + setSelectedRowKeys(nextRows.map(row => row[GONAVI_ROW_KEY])); + void message.success(`已粘贴 ${nextRows.length} 行为新增行,请检查后提交事务`); + }, [copiedRowsForPaste, columnNames]); + const handleDeleteSelected = () => { setDeletedRowKeys(prev => { const newDeleted = new Set(prev); @@ -3979,9 +4066,10 @@ const DataGrid: React.FC = ({ return clauses.join(' OR '); }, [pkColumns, tableName]); - const buildCurrentPageSql = useCallback((dbType: string) => { + const buildCurrentPageSql = useCallback((dbType: string) => { if (!tableName || !pagination) return ''; - const whereSQL = buildWhereSQL(dbType, filterConditions); + const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition); + const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions); const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns); const normalizedType = String(dbType || '').trim().toLowerCase(); @@ -3992,7 +4080,7 @@ const DataGrid: React.FC = ({ sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024); } return sql; - }, [tableName, pagination, filterConditions, sortInfo, pkColumns]); + }, [tableName, pagination, filterConditions, quickWhereCondition, sortInfo, pkColumns]); // Context Menu Export const handleExportSelected = useCallback(async (format: string, record: any) => { @@ -4224,7 +4312,25 @@ const DataGrid: React.FC = ({ const removeFilter = (id: number) => { setFilterConditions(prev => prev.filter(c => c.id !== id)); }; + const applyQuickWhereCondition = useCallback((condition: string = quickWhereDraft): boolean => { + const normalized = normalizeQuickWhereCondition(condition); + const validation = validateQuickWhereCondition(normalized); + if (!validation.ok) { + void message.warning(validation.message); + return false; + } + setQuickWhereDraft(normalized); + if (onApplyQuickWhereCondition) onApplyQuickWhereCondition(normalized); + return true; + }, [quickWhereDraft, onApplyQuickWhereCondition]); + + const clearQuickWhereCondition = useCallback(() => { + setQuickWhereDraft(''); + if (onApplyQuickWhereCondition) onApplyQuickWhereCondition(''); + }, [onApplyQuickWhereCondition]); + const applyFilters = () => { + if (!applyQuickWhereCondition()) return; if (onApplyFilter) onApplyFilter(filterConditions); }; @@ -4921,6 +5027,22 @@ const DataGrid: React.FC = ({ <>
+ + {selectedRowKeys.length > 0 && 已选 {selectedRowKeys.length}}
@@ -5080,6 +5202,73 @@ const DataGrid: React.FC = ({ display: 'flex', flexDirection: 'column', }}> +
+ + WHERE + + { + setQuickWhereDraft(resolveWhereConditionSelectedValue({ + selectedValue: value, + currentInput: quickWhereDraft, + insertText: (option as any)?.insertText, + })); + }} + style={{ flex: '1 1 320px', minWidth: 220 }} + popupMatchSelectWidth={420} + > + { + if (!event.shiftKey) { + event.preventDefault(); + applyQuickWhereCondition(); + } + }} + /> + + + +
{/* 筛选条件 + 排序区域:固定最大高度,超出后可滚动,避免条件过多挤压数据表 */}
{filterConditions.map((cond, condIndex) => ( @@ -5247,6 +5436,7 @@ const DataGrid: React.FC = ({ diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index bcdf1bf..cb259c1 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -10,6 +10,11 @@ import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveA import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities'; import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { + buildEffectiveFilterConditions, + normalizeQuickWhereCondition, + validateQuickWhereCondition, +} from '../utils/dataGridWhereFilter'; type ViewerPaginationState = { current: number; @@ -135,6 +140,7 @@ const reverseOrderBySQL = (orderBySQL: string): string => { type ViewerFilterSnapshot = { showFilter: boolean; conditions: FilterCondition[]; + quickWhereCondition: string; currentPage: number; pageSize: number; sortInfo: Array<{ columnKey: string, order: string, enabled?: boolean }>; @@ -165,11 +171,12 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => { const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim()); if (!cached) { - return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 }; + return { showFilter: false, conditions: [], quickWhereCondition: '', currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 }; } return { showFilter: cached.showFilter === true, conditions: normalizeViewerFilterConditions(cached.conditions), + quickWhereCondition: normalizeQuickWhereCondition(cached.quickWhereCondition), currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1, pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100, sortInfo: Array.isArray(cached.sortInfo) @@ -226,6 +233,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct const [showFilter, setShowFilter] = useState(initialViewerSnapshot.showFilter); const [filterConditions, setFilterConditions] = useState(initialViewerSnapshot.conditions); + const [quickWhereCondition, setQuickWhereCondition] = useState(initialViewerSnapshot.quickWhereCondition); const duckdbSafeSelectCacheRef = useRef>({}); const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config; const currentConnCaps = getDataSourceCapabilities(currentConnConfig); @@ -239,6 +247,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct viewerFilterSnapshotsByTab.set(normalizedTabId, { showFilter, conditions: normalizeViewerFilterConditions(filterConditions), + quickWhereCondition: normalizeQuickWhereCondition(quickWhereCondition), currentPage: pagination.current, pageSize: pagination.pageSize, sortInfo, @@ -246,12 +255,13 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct scrollLeft: scrollSnapshotRef.current.left, ...overrides, }); - }, [showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]); + }, [showFilter, filterConditions, quickWhereCondition, pagination.current, pagination.pageSize, sortInfo]); useEffect(() => { const snapshot = getViewerFilterSnapshot(tab.id); setShowFilter(snapshot.showFilter); setFilterConditions(snapshot.conditions); + setQuickWhereCondition(snapshot.quickWhereCondition); setSortInfo(snapshot.sortInfo); scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft }; initialLoadRef.current = false; @@ -259,7 +269,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct useEffect(() => { persistViewerSnapshot(tab.id); - }, [tab.id, persistViewerSnapshot]); + }, [persistViewerSnapshot]); useEffect(() => { return () => { @@ -399,6 +409,14 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct const dbType = resolveDataSourceType(config); const dbTypeLower = String(dbType || '').trim().toLowerCase(); const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros'; + const normalizedQuickWhereCondition = normalizeQuickWhereCondition(quickWhereCondition); + const quickWhereValidation = validateQuickWhereCondition(normalizedQuickWhereCondition); + if (!quickWhereValidation.ok) { + message.error(quickWhereValidation.message); + if (fetchSeqRef.current === seq) setLoading(false); + return; + } + const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, normalizedQuickWhereCondition); const dbName = tab.dbName || ''; const tableName = tab.tableName || ''; @@ -406,7 +424,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct let mongoFilter: Record | undefined; if (isMongoDB) { try { - mongoFilter = buildMongoFilter(filterConditions); + mongoFilter = buildMongoFilter(effectiveFilterConditions); } catch (e: any) { message.error(`Mongo 筛选条件无效:${String(e?.message || e || '解析失败')}`); if (fetchSeqRef.current === seq) setLoading(false); @@ -416,7 +434,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct const whereSQL = isMongoDB ? JSON.stringify(mongoFilter || {}) - : buildWhereSQL(dbType, filterConditions); + : buildWhereSQL(dbType, effectiveFilterConditions); const countSql = isMongoDB ? buildMongoCountCommand(tableName, mongoFilter || {}) : `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; @@ -824,7 +842,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct }); } if (fetchSeqRef.current === seq) setLoading(false); - }, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]); + }, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]); // 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。 // 主键信息只会在首次加载后更新一次,避免循环查询。 @@ -852,13 +870,23 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]); const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []); const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []); + const handleApplyQuickWhereCondition = useCallback((condition: string) => { + const normalized = normalizeQuickWhereCondition(condition); + const validation = validateQuickWhereCondition(normalized); + if (!validation.ok) { + message.error(validation.message); + return; + } + setQuickWhereCondition(normalized); + }, []); const exportSqlWithFilter = useMemo(() => { const tableName = String(tab.tableName || '').trim(); const dbType = resolveDataSourceType(currentConnConfig); if (!tableName || !dbType) return ''; - const whereSQL = buildWhereSQL(dbType, filterConditions); + const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition); + const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions); if (!whereSQL) return ''; let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; @@ -869,7 +897,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024); } return sql; - }, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, sortInfo, pkColumns]); + }, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, quickWhereCondition, sortInfo, pkColumns]); useEffect(() => { const action = resolveDataViewerAutoFetchAction({ @@ -886,7 +914,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct return; } fetchData(1, pagination.pageSize); - }, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions]); // Initial load and re-load on sort/filter + }, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions, quickWhereCondition]); // Initial load and re-load on sort/filter return (
@@ -909,6 +937,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct onToggleFilter={handleToggleFilter} onApplyFilter={handleApplyFilter} appliedFilterConditions={filterConditions} + quickWhereCondition={quickWhereCondition} + onApplyQuickWhereCondition={handleApplyQuickWhereCondition} readOnly={forceReadOnly} sortInfoExternal={sortInfo} exportSqlWithFilter={exportSqlWithFilter || undefined} diff --git a/frontend/src/components/DatabaseIcons.tsx b/frontend/src/components/DatabaseIcons.tsx index bbc0628..1dbf45f 100644 --- a/frontend/src/components/DatabaseIcons.tsx +++ b/frontend/src/components/DatabaseIcons.tsx @@ -15,6 +15,7 @@ const DB_DEFAULT_COLORS: Record = { postgres: '#336791', redis: '#DC382D', mongodb: '#47A248', + jvm: '#1677FF', kingbase: '#1890FF', dameng: '#E6002D', oracle: '#F80000', @@ -136,6 +137,9 @@ const HighGoIcon: React.FC = ({ size = 16, color }) => ( const TDengineIcon: React.FC = ({ size = 16, color }) => ( ); +const JVMIcon: React.FC = ({ size = 16, color }) => ( + +); /** Custom — 齿轮图标 */ const CustomIcon: React.FC = ({ size = 16, color }) => { @@ -166,6 +170,7 @@ const DB_ICON_MAP: Record> = { postgres: PostgresIcon, redis: RedisIcon, mongodb: MongoDBIcon, + jvm: JVMIcon, kingbase: KingBaseIcon, dameng: DamengIcon, oracle: OracleIcon, @@ -181,7 +186,7 @@ const DB_ICON_MAP: Record> = { /** 可选图标类型列表(用于图标选择器 UI) */ export const DB_ICON_TYPES: string[] = [ - 'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', + 'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'jvm', 'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse', 'kingbase', 'dameng', 'vastbase', 'highgo', 'tdengine', 'custom', ]; @@ -200,7 +205,8 @@ export const getDbIcon = (type: string, color?: string, size?: number): React.Re export const getDbIconLabel = (type: string): string => { const labels: Record = { mysql: 'MySQL', mariadb: 'MariaDB', postgres: 'PostgreSQL', - redis: 'Redis', mongodb: 'MongoDB', oracle: 'Oracle', + redis: 'Redis', mongodb: 'MongoDB', jvm: 'JVM', + oracle: 'Oracle', sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite', duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦', vastbase: 'VastBase', highgo: '瀚高', tdengine: 'TDengine', diff --git a/frontend/src/components/JVMAuditViewer.test.tsx b/frontend/src/components/JVMAuditViewer.test.tsx new file mode 100644 index 0000000..09877ca --- /dev/null +++ b/frontend/src/components/JVMAuditViewer.test.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import JVMAuditViewer from "./JVMAuditViewer"; + +vi.mock("../store", () => ({ + useStore: (selector: (state: any) => any) => + selector({ + connections: [ + { + id: "conn-jvm-1", + name: "orders-jvm", + config: { + host: "localhost", + port: 10990, + jvm: { + preferredMode: "endpoint", + readOnly: false, + }, + }, + }, + ], + theme: "light", + }), +})); + +describe("JVMAuditViewer", () => { + it("renders a unified JVM workspace audit shell", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-jvm-workspace-shell="true"'); + expect(markup).toContain('data-jvm-workspace-hero="true"'); + expect(markup).toContain("JVM 变更审计"); + expect(markup).toContain("审计记录"); + expect(markup).toContain("最近 50 条"); + }); +}); diff --git a/frontend/src/components/JVMAuditViewer.tsx b/frontend/src/components/JVMAuditViewer.tsx new file mode 100644 index 0000000..ca573ec --- /dev/null +++ b/frontend/src/components/JVMAuditViewer.tsx @@ -0,0 +1,271 @@ +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 { + formatJVMAuditResultLabel, + formatJVMActionDisplayText, + resolveJVMAuditResultColor, +} from "../utils/jvmResourcePresentation"; +import JVMModeBadge from "./jvm/JVMModeBadge"; +import { + getJVMWorkspaceCardStyle, + JVMWorkspaceHero, + JVMWorkspaceShell, +} from "./jvm/JVMWorkspaceLayout"; + +const { Text } = Typography; + +type JVMAuditViewerProps = { + tab: TabData; +}; + +const LIMIT_OPTIONS = [20, 50, 100, 200]; + +const normalizeAuditRecords = (value: any): JVMAuditRecord[] => { + if (Array.isArray(value)) { + return value as JVMAuditRecord[]; + } + if (Array.isArray(value?.data)) { + return value.data as JVMAuditRecord[]; + } + return []; +}; + +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, + ); +}; + +const formatTimestamp = (timestamp: number): string => { + if (!timestamp) { + 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 JVMAuditViewer: React.FC = ({ tab }) => { + const connection = useStore((state) => + state.connections.find((item) => item.id === tab.connectionId), + ); + const theme = useStore((state) => state.theme); + const darkMode = theme === "dark"; + const [limit, setLimit] = useState(50); + const [loading, setLoading] = useState(true); + const [records, setRecords] = useState([]); + const [error, setError] = useState(""); + + const columns = useMemo>( + () => [ + { + title: "时间", + dataIndex: "timestamp", + key: "timestamp", + width: 180, + render: (value: number) => formatTimestamp(value), + }, + { + title: "模式", + dataIndex: "providerMode", + key: "providerMode", + width: 120, + render: (value: string) => ( + + ), + }, + { + 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) => { + const normalized = String(value || "") + .trim() + .toLowerCase(); + if (normalized === "ai-plan") { + return AI 辅助; + } + return 手工; + }, + }, + { + title: "结果", + dataIndex: "result", + key: "result", + width: 140, + render: (value: string) => ( + + {formatJVMAuditResultLabel(value)} + + ), + }, + ], + [tab.providerMode], + ); + + const loadRecords = async () => { + if (!connection) { + setLoading(false); + setRecords([]); + setError("连接不存在或已被删除"); + return; + } + + const backendApp = (window as any).go?.app?.App; + if (typeof backendApp?.JVMListAuditRecords !== "function") { + setLoading(false); + setRecords([]); + setError("JVMListAuditRecords 后端方法不可用"); + return; + } + + setLoading(true); + setError(""); + try { + const result = await backendApp.JVMListAuditRecords(connection.id, limit); + if (result?.success === false) { + setRecords([]); + setError(String(result?.message || "读取 JVM 审计记录失败")); + return; + } + setRecords( + filterAuditRecordsByMode( + normalizeAuditRecords(result), + tab.providerMode, + ), + ); + } catch (err: any) { + setRecords([]); + setError(err?.message || "读取 JVM 审计记录失败"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadRecords(); + }, [connection, limit, tab.connectionId]); + + if (!connection) { + return ( + + ); + } + + const activeMode = + tab.providerMode || connection.config.jvm?.preferredMode || "jmx"; + const cardStyle = getJVMWorkspaceCardStyle(darkMode); + + return ( + + + {connection.name} + · {connection.id} + · 当前范围:最近 {limit} 条 + + } + badges={} + actions={ + <> + + + setDraft(tab.id, { reason: event.target.value }) + } + /> + + 用于审计记录和 AI 上下文理解,不会作为 Arthas 命令发送到目标 JVM。 + +
+
+ + + , "命令模板")} + variant="borderless" + style={cardStyle} + styles={compactCardStyles} + > + + setDraft(tab.id, { + command: preset.command, + reason: preset.description, + source: "manual", + }) + } + /> + + + )} + + {hasSession || chunks.length ? ( + , + "实时输出", + "按后端事件流追加显示", + )} + variant="borderless" + style={cardStyle} + styles={compactCardStyles} + > + + + ) : null} +
+ +
+ , + "会话与能力", + "当前通道、权限与快捷维护", + )} + variant="borderless" + style={cardStyle} + styles={compactCardStyles} + > + +
+ + + {hasSession ? "会话已建立" : "未建会话"} + + {formatJVMDiagnosticTransportLabel(diagnosticTransport)} + + {commandRunning ? "命令执行中" : "空闲"} + + + {effectiveSession?.sessionId ? ( + + {effectiveSession.sessionId} + + ) : ( + 创建会话后会在这里显示会话 ID。 + )} +
+ + 检查能力不会执行命令;执行命令前必须先建会话。审计历史展示最近命令记录,未建会话时也可能包含过去会话的记录。 + + + + + + {renderCapabilityContent()} +
+
+ + , + "审计历史", + "最近命令和执行状态", + )} + variant="borderless" + style={cardStyle} + styles={compactCardStyles} + > + + +
+
+
+ ); +}; + +export default JVMDiagnosticConsole; diff --git a/frontend/src/components/JVMMonitoringDashboard.test.tsx b/frontend/src/components/JVMMonitoringDashboard.test.tsx new file mode 100644 index 0000000..014ae8a --- /dev/null +++ b/frontend/src/components/JVMMonitoringDashboard.test.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import JVMMonitoringDashboard from "./JVMMonitoringDashboard"; + +vi.mock("../store", () => ({ + useStore: (selector: (state: any) => any) => + selector({ + theme: "light", + connections: [ + { + id: "conn-1", + name: "orders-jvm", + config: { + host: "orders.internal", + port: 9010, + jvm: { + preferredMode: "jmx", + allowedModes: ["jmx"], + }, + }, + }, + ], + }), +})); + +describe("JVMMonitoringDashboard", () => { + it("shows start action and empty-state guidance before monitoring starts", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("开始监控"); + expect(markup).toContain("当前尚未开始持续监控"); + expect(markup).toContain("堆内存"); + expect(markup).toContain("暂无堆内存采样数据"); + expect(markup).not.toContain("暂无 Heap 采样数据"); + expect(markup).not.toContain("当前 provider 未提供 Heap 指标"); + }); + + it("renders a dedicated vertical scroll shell for tall monitoring content", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-jvm-monitoring-dashboard-scroll-shell="true"'); + expect(markup).toContain("height:100%"); + expect(markup).toContain("overflow-y:auto"); + }); + + it("stacks monitoring charts before detail panels so charts keep full content width", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-jvm-monitoring-content-stack="true"'); + expect(markup).toContain("gap:24px"); + expect(markup).not.toContain("minmax(min(100%, 320px), 1fr)"); + }); +}); diff --git a/frontend/src/components/JVMMonitoringDashboard.tsx b/frontend/src/components/JVMMonitoringDashboard.tsx new file mode 100644 index 0000000..4b9a2c0 --- /dev/null +++ b/frontend/src/components/JVMMonitoringDashboard.tsx @@ -0,0 +1,392 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Alert, Button, Card, Empty, Space, Spin, Tag, Typography } from "antd"; +import { DashboardOutlined, PauseCircleOutlined, PlayCircleOutlined, ReloadOutlined } from "@ant-design/icons"; + +import { useStore } from "../store"; +import type { JVMMonitoringSessionState, TabData } from "../types"; +import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig"; +import { + buildMonitoringAvailabilityText, + normalizeMonitoringProviderMode, + type JVMMonitoringProviderMode, +} from "../utils/jvmMonitoringPresentation"; +import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation"; +import JVMMonitoringCharts from "./jvm/JVMMonitoringCharts"; +import JVMMonitoringDetailPanel from "./jvm/JVMMonitoringDetailPanel"; +import JVMMonitoringStatusCards from "./jvm/JVMMonitoringStatusCards"; + +const { Paragraph, Text, Title } = Typography; + +const POLL_INTERVAL_MS = 2000; + +type JVMMonitoringDashboardProps = { + tab: TabData; +}; + +const isMonitoringSessionMissing = (message: string): boolean => + /monitoring session not found/i.test(String(message || "")); + +const createEmptySession = ( + connectionId: string, + providerMode: JVMMonitoringProviderMode, +): JVMMonitoringSessionState => ({ + connectionId, + providerMode, + running: false, + points: [], + recentGcEvents: [], + availableMetrics: [], + missingMetrics: [], + providerWarnings: [], +}); + +const normalizeMonitoringSession = ( + payload: any, + connectionId: string, + providerMode: JVMMonitoringProviderMode, +): JVMMonitoringSessionState => ({ + connectionId: String(payload?.connectionId || connectionId), + providerMode: normalizeMonitoringProviderMode(payload?.providerMode, providerMode), + running: payload?.running === true, + points: Array.isArray(payload?.points) ? payload.points : [], + recentGcEvents: Array.isArray(payload?.recentGcEvents) ? payload.recentGcEvents : [], + availableMetrics: Array.isArray(payload?.availableMetrics) + ? payload.availableMetrics + : [], + missingMetrics: Array.isArray(payload?.missingMetrics) ? payload.missingMetrics : [], + providerWarnings: Array.isArray(payload?.providerWarnings) + ? payload.providerWarnings + : [], +}); + +const resolveBackendApp = () => + typeof window === "undefined" ? undefined : (window as any).go?.app?.App; + +const JVMMonitoringDashboard: React.FC = ({ tab }) => { + const theme = useStore((state) => state.theme); + const connection = useStore((state) => + state.connections.find((item) => item.id === tab.connectionId), + ); + const darkMode = theme === "dark"; + const providerMode = normalizeMonitoringProviderMode( + tab.providerMode, + normalizeMonitoringProviderMode(connection?.config.jvm?.preferredMode, "jmx"), + ); + const [session, setSession] = useState(() => + createEmptySession(tab.connectionId, providerMode), + ); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [actionLoading, setActionLoading] = useState(false); + const [pollSeed, setPollSeed] = useState(0); + + const rpcConnectionConfig = useMemo(() => { + if (!connection) { + return null; + } + return buildRpcConnectionConfig(connection.config, { + database: "", + jvm: { + ...(connection.config.jvm || {}), + preferredMode: providerMode, + allowedModes: [providerMode], + }, + }); + }, [connection, providerMode]); + + const latestPoint = useMemo(() => { + const points = session.points || []; + return points.length > 0 ? points[points.length - 1] : undefined; + }, [session.points]); + + useEffect(() => { + setSession(createEmptySession(tab.connectionId, providerMode)); + }, [tab.connectionId, providerMode]); + + useEffect(() => { + if (!connection || !rpcConnectionConfig) { + setLoading(false); + return; + } + + let cancelled = false; + let timer: ReturnType | null = null; + const backendApp = resolveBackendApp(); + + const poll = async () => { + if (cancelled) { + return; + } + setLoading(true); + + if (typeof backendApp?.JVMGetMonitoringHistory !== "function") { + setError("JVMGetMonitoringHistory 后端方法不可用"); + setLoading(false); + return; + } + + try { + const result = await backendApp.JVMGetMonitoringHistory( + rpcConnectionConfig, + providerMode, + ); + + if (cancelled) { + return; + } + + if (result?.success === false) { + const message = String(result?.message || "读取监控历史失败"); + if (isMonitoringSessionMissing(message)) { + setSession(createEmptySession(tab.connectionId, providerMode)); + setError(""); + setLoading(false); + return; + } + throw new Error(message); + } + + const nextSession = normalizeMonitoringSession( + result?.data, + tab.connectionId, + providerMode, + ); + setSession(nextSession); + setError(""); + setLoading(false); + + if (nextSession.running) { + timer = setTimeout(poll, POLL_INTERVAL_MS); + } + } catch (fetchError: any) { + if (!cancelled) { + setError(fetchError?.message || "读取监控历史失败"); + setLoading(false); + } + } + }; + + void poll(); + + return () => { + cancelled = true; + if (timer) { + clearTimeout(timer); + } + }; + }, [connection, providerMode, rpcConnectionConfig, tab.connectionId, pollSeed]); + + if (!connection) { + return ; + } + + const backendApp = resolveBackendApp(); + const availabilityText = buildMonitoringAvailabilityText(session); + const modeMeta = resolveJVMModeMeta(providerMode); + const emptyState = !session.running && (session.points || []).length === 0; + + const handleStart = async () => { + if (!rpcConnectionConfig || typeof backendApp?.JVMStartMonitoring !== "function") { + setError("JVMStartMonitoring 后端方法不可用"); + return; + } + + setActionLoading(true); + setError(""); + try { + const result = await backendApp.JVMStartMonitoring(rpcConnectionConfig); + if (result?.success === false) { + throw new Error(String(result?.message || "开始监控失败")); + } + setSession( + normalizeMonitoringSession(result?.data, tab.connectionId, providerMode), + ); + setPollSeed((current) => current + 1); + } catch (startError: any) { + setError(startError?.message || "开始监控失败"); + } finally { + setActionLoading(false); + } + }; + + const handleStop = async () => { + if (!rpcConnectionConfig || typeof backendApp?.JVMStopMonitoring !== "function") { + setError("JVMStopMonitoring 后端方法不可用"); + return; + } + + setActionLoading(true); + setError(""); + try { + const result = await backendApp.JVMStopMonitoring( + rpcConnectionConfig, + providerMode, + ); + if (result?.success === false) { + throw new Error(String(result?.message || "停止监控失败")); + } + setSession((current) => ({ ...current, running: false })); + setPollSeed((current) => current + 1); + } catch (stopError: any) { + setError(stopError?.message || "停止监控失败"); + } finally { + setActionLoading(false); + } + }; + + return ( +
+ + + +
+ + <DashboardOutlined style={{ color: "#1677ff", marginRight: 8 }} /> + JVM 持续监控 + + + {connection.name} + + {" "} + · {connection.config.host}:{connection.config.port} + + +
+ + + {modeMeta.label} + + {session.running ? ( + 采样中 + ) : ( + 未运行 + )} + + {session.running ? ( + + ) : ( + + )} + +
+ + {(session.missingMetrics?.length || session.providerWarnings?.length) ? ( + + ) : null} + {error ? : null} +
+
+ + {loading && emptyState ? ( +
+ +
+ ) : null} + + {emptyState ? ( +
+ + + + 点击“开始监控”后,GoNavi 会在当前会话内持续保留该连接的采样结果;切换页签不会停止采样。 + + + + + +
+ ) : ( +
+ + + +
+ )} +
+ ); +}; + +export default JVMMonitoringDashboard; diff --git a/frontend/src/components/JVMOverview.test.tsx b/frontend/src/components/JVMOverview.test.tsx new file mode 100644 index 0000000..33c2a58 --- /dev/null +++ b/frontend/src/components/JVMOverview.test.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import JVMOverview from "./JVMOverview"; + +vi.mock("../../wailsjs/go/app/App", () => ({ + JVMProbeCapabilities: vi.fn(), +})); + +vi.mock("../store", () => ({ + useStore: (selector: (state: any) => any) => + selector({ + connections: [ + { + id: "conn-jvm-1", + name: "orders-jvm", + config: { + host: "localhost", + port: 10990, + jvm: { + preferredMode: "jmx", + allowedModes: ["jmx", "endpoint", "agent"], + readOnly: true, + environment: "dev", + endpoint: { + enabled: true, + baseUrl: "http://localhost:8080/actuator", + }, + agent: { + enabled: true, + baseUrl: "http://localhost:8563", + }, + }, + }, + }, + ], + theme: "light", + }), +})); + +describe("JVMOverview", () => { + it("renders a unified JVM workspace overview shell", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-jvm-workspace-shell="true"'); + expect(markup).toContain('data-jvm-workspace-hero="true"'); + expect(markup).toContain("JVM 运行时概览"); + expect(markup).toContain("连接摘要"); + expect(markup).toContain("模式能力"); + expect(markup).toContain("JMX 地址"); + expect(markup).toContain("Endpoint"); + expect(markup).toContain("Agent"); + }); +}); diff --git a/frontend/src/components/JVMOverview.tsx b/frontend/src/components/JVMOverview.tsx new file mode 100644 index 0000000..d69c31e --- /dev/null +++ b/frontend/src/components/JVMOverview.tsx @@ -0,0 +1,239 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Alert, + Card, + Descriptions, + Empty, + Skeleton, + Space, + Tag, + Typography, +} from "antd"; + +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"; +import { + getJVMWorkspaceCardStyle, + JVMWorkspaceHero, + JVMWorkspaceShell, +} from "./jvm/JVMWorkspaceLayout"; + +const { 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 theme = useStore((state) => state.theme); + const darkMode = theme === "dark"; + 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 ""; + } + const endpoint = connection.config.jvm.endpoint; + if (!endpoint.enabled && !endpoint.baseUrl) { + return ""; + } + 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 ( + + ); + } + + const jmxHost = connection.config.jvm?.jmx?.host || connection.config.host; + const jmxPort = connection.config.jvm?.jmx?.port || connection.config.port; + + const cardStyle = getJVMWorkspaceCardStyle(darkMode); + + return ( + + + {connection.name} + + {" "} + · {connection.config.host}:{connection.config.port} + + + } + badges={ + <> + + + {readOnly ? "只读连接" : "可写连接"} + + {connection.config.jvm?.environment || "dev"} + + } + /> + + + + + {resolveJVMModeMeta(providerMode).label} + + + {allowedModeSummary} + + {`${jmxHost}:${jmxPort}`} + + {endpointSummary || "未配置"} + + + {agentSummary || "未配置"} + + + {"通过侧边栏展开模式节点后懒加载"} + + + + + + {capabilityLoading ? ( + + ) : capabilityError ? ( + + {capabilityError} + + } + /> + ) : capabilities.length === 0 ? ( + + ) : ( + + {capabilities.map((capability) => ( +
+ + + + {capability.canBrowse ? "可浏览" : "不可浏览"} + + + {capability.canWrite ? "可写" : "只读"} + + + {capability.canPreview ? "支持预览" : "不支持预览"} + + + {capability.reason ? ( + + {capability.reason} + + ) : null} +
+ ))} +
+ )} +
+
+ ); +}; + +export default JVMOverview; diff --git a/frontend/src/components/JVMResourceBrowser.layout.test.tsx b/frontend/src/components/JVMResourceBrowser.layout.test.tsx new file mode 100644 index 0000000..e81e40e --- /dev/null +++ b/frontend/src/components/JVMResourceBrowser.layout.test.tsx @@ -0,0 +1,118 @@ +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('data-jvm-workspace-shell="true"'); + expect(markup).toContain('data-jvm-workspace-hero="true"'); + expect(markup).toContain('data-jvm-resource-workbench="true"'); + expect(markup).toContain('height:100%'); + expect(markup).toContain('overflow-y:auto'); + expect(markup).toContain('grid-template-columns:minmax(0, 1fr) minmax(360px, 440px)'); + }); + + 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 new file mode 100644 index 0000000..71505fb --- /dev/null +++ b/frontend/src/components/JVMResourceBrowser.tsx @@ -0,0 +1,946 @@ +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 type { + JVMActionDefinition, + JVMApplyResult, + JVMChangePreview, + JVMChangeRequest, + JVMAIPlanContext, + JVMValueSnapshot, + SavedConnection, + TabData, +} from "../types"; +import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig"; +import { + buildJVMChangeDraftFromAIPlan, + buildJVMAIPlanPrompt, + matchesJVMAIPlanTargetTab, + type JVMAIChangeDraft, + type JVMAIChangePlan, +} 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"; +import { + getJVMWorkspaceCardStyle, + JVMWorkspaceHero, + JVMWorkspaceShell, +} from "./jvm/JVMWorkspaceLayout"; + +const { Text } = Typography; +const DESCRIPTION_STYLES = { label: { width: 120 } } as const; +const { TextArea } = Input; +const DEFAULT_PAYLOAD_TEXT = "{\n \n}"; + +type JVMResourceBrowserProps = { + tab: TabData; +}; + +const buildJVMRuntimeConfig = ( + connection: SavedConnection, + providerMode: string, +) => { + const sourceJVM = connection.config.jvm || {}; + return buildRpcConnectionConfig(connection.config, { + jvm: { + ...sourceJVM, + preferredMode: providerMode, + allowedModes: [providerMode], + }, + }); +}; + +const snapshotBlockStyle = (background: string): React.CSSProperties => ({ + margin: 0, + borderRadius: 8, + background, + overflow: "auto", +}); + +const formatValue = (value: unknown): string => { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +const formatDraftPayload = (draft: JVMAIChangeDraft): string => { + try { + return JSON.stringify(draft.payload ?? {}, null, 2); + } catch { + 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" + ) { + return value as JVMChangePreview; + } + if (value?.data && typeof value.data.allowed === "boolean") { + return value.data as JVMChangePreview; + } + return null; +}; + +const normalizeApplyResult = (value: any): JVMApplyResult | null => { + if (value && typeof value === "object" && typeof value.status === "string") { + return value as JVMApplyResult; + } + if (value?.data && typeof value.data.status === "string") { + return value.data as JVMApplyResult; + } + return null; +}; + +const JVMResourceBrowser: React.FC = ({ tab }) => { + const connection = useStore((state) => + state.connections.find((item) => item.id === tab.connectionId), + ); + const addTab = useStore((state) => state.addTab); + 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(""); + const [reason, setReason] = useState(""); + const [payloadText, setPayloadText] = useState(DEFAULT_PAYLOAD_TEXT); + 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 [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("连接不存在或已被删除"); + return; + } + + if (!resourcePath) { + setLoading(false); + setSnapshot(null); + setError("资源路径为空"); + return; + } + + const backendApp = (window as any).go?.app?.App; + if (typeof backendApp?.JVMGetValue !== "function") { + setLoading(false); + setSnapshot(null); + setError("JVMGetValue 后端方法不可用"); + return; + } + + setLoading(true); + setError(""); + try { + const result = await backendApp.JVMGetValue( + buildJVMRuntimeConfig(connection, providerMode), + resourcePath, + ); + if (!result?.success) { + setSnapshot(null); + setError(String(result?.message || "读取 JVM 资源失败")); + return; + } + setSnapshot((result.data || null) as JVMValueSnapshot | null); + } catch (err: any) { + setSnapshot(null); + setError(err?.message || "读取 JVM 资源失败"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadSnapshot(); + }, [connection, providerMode, resourcePath, tab.connectionId]); + + useEffect(() => { + setAction(""); + setReason(""); + setPayloadText(DEFAULT_PAYLOAD_TEXT); + 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 + | { + plan?: JVMAIChangePlan; + targetTabId?: string; + connectionId?: string; + providerMode?: JVMAIPlanContext["providerMode"]; + resourcePath?: string; + } + | undefined; + const plan = detail?.plan; + if (!plan || (detail?.targetTabId && detail.targetTabId !== tab.id)) { + return; + } + + const planContext = + detail?.targetTabId && + detail?.connectionId && + detail?.providerMode && + detail?.resourcePath + ? { + tabId: detail.targetTabId, + connectionId: detail.connectionId, + providerMode: detail.providerMode, + resourcePath: detail.resourcePath, + } + : undefined; + + if (!planContext) { + setDraftError( + "AI 计划缺少来源上下文,请在目标 JVM 资源页重新生成后再应用。", + ); + setApplyMessage(""); + setPreviewOpen(false); + setPreviewResult(null); + return; + } + + if (!matchesJVMAIPlanTargetTab(tab, planContext)) { + setDraftError( + "当前 JVM 页签与 AI 计划的来源上下文不一致,已拒绝自动应用。", + ); + setApplyMessage(""); + setPreviewOpen(false); + setPreviewResult(null); + return; + } + + let draftFromPlan: JVMAIChangeDraft; + try { + draftFromPlan = buildJVMChangeDraftFromAIPlan(plan); + } catch (err: any) { + setDraftError(err?.message || "AI 计划暂时无法转换为 JVM 预览草稿"); + setApplyMessage(""); + setPreviewOpen(false); + setPreviewResult(null); + return; + } + + setDraftResourceId(draftFromPlan.resourceId); + setAction(draftFromPlan.action); + setReason(draftFromPlan.reason); + setPayloadText(formatDraftPayload(draftFromPlan)); + 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, + ); + }, [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(); + if (!trimmedReason) { + throw new Error("请填写变更原因"); + } + + const rawPayload = String(payloadText || "").trim(); + let payload: Record = {}; + if (rawPayload) { + const parsed = JSON.parse(rawPayload); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Payload 必须是 JSON 对象"); + } + payload = parsed as Record; + } + + const resourceId = String( + draftResourceId || snapshot?.resourceId || resourcePath, + ).trim(); + if (!resourceId) { + throw new Error("资源 ID 为空,无法生成变更草稿"); + } + + return { + providerMode, + resourceId, + action: trimmedAction, + reason: trimmedReason, + source: draftSource, + expectedVersion: snapshot?.version || undefined, + payload, + }; + }; + + const handleOpenAudit = () => { + if (!connection) { + return; + } + + addTab({ + id: `jvm-audit-${connection.id}-${providerMode}`, + title: buildJVMTabTitle(connection.name, "audit", providerMode), + type: "jvm-audit", + connectionId: connection.id, + providerMode, + }); + }; + + const handleAskAIForPlan = () => { + if (!connection) { + setDraftError("连接不存在或已被删除"); + return; + } + + const prompt = buildJVMAIPlanPrompt({ + connectionName: connection.name, + host: connection.config.host, + providerMode, + resourcePath, + readOnly, + environment: connection.config.jvm?.environment, + snapshot, + }); + + const store = useStore.getState(); + const wasClosed = !store.aiPanelVisible; + if (wasClosed) { + store.setAIPanelVisible(true); + } + setTimeout( + () => { + window.dispatchEvent( + new CustomEvent("gonavi:ai:inject-prompt", { detail: { prompt } }), + ); + }, + wasClosed ? 350 : 0, + ); + }; + + const handlePreview = async () => { + if (!connection) { + setDraftError("连接不存在或已被删除"); + return; + } + + const backendApp = (window as any).go?.app?.App; + if (typeof backendApp?.JVMPreviewChange !== "function") { + setDraftError("JVMPreviewChange 后端方法不可用"); + return; + } + + let draftPlan: JVMChangeRequest; + try { + draftPlan = buildDraftPlan(); + } catch (err: any) { + setDraftError(err?.message || "变更草稿不合法"); + return; + } + + setPreviewLoading(true); + setDraftError(""); + setApplyMessage(""); + try { + const result = await backendApp.JVMPreviewChange( + buildJVMRuntimeConfig(connection, providerMode), + draftPlan, + ); + if (result?.success === false) { + setPreviewResult(null); + setPreviewOpen(false); + setDraftError(String(result?.message || "预览 JVM 变更失败")); + return; + } + + const preview = normalizePreviewResult(result); + if (!preview) { + setPreviewResult(null); + setPreviewOpen(false); + setDraftError("预览结果格式不正确"); + return; + } + + setPreviewResult(preview); + setPreviewOpen(true); + } catch (err: any) { + setPreviewResult(null); + setPreviewOpen(false); + setDraftError(err?.message || "预览 JVM 变更失败"); + } finally { + setPreviewLoading(false); + } + }; + + const handleApply = async () => { + if (!connection) { + setDraftError("连接不存在或已被删除"); + return; + } + + const backendApp = (window as any).go?.app?.App; + if (typeof backendApp?.JVMApplyChange !== "function") { + setDraftError("JVMApplyChange 后端方法不可用"); + return; + } + + let draftPlan: JVMChangeRequest; + try { + draftPlan = buildDraftPlan(); + } catch (err: any) { + setDraftError(err?.message || "变更草稿不合法"); + return; + } + + setApplyLoading(true); + setDraftError(""); + setApplyMessage(""); + try { + const result = await backendApp.JVMApplyChange( + buildJVMRuntimeConfig(connection, providerMode), + draftPlan, + ); + if (result?.success === false) { + setDraftError(String(result?.message || "执行 JVM 变更失败")); + return; + } + + const applyResult = normalizeApplyResult(result); + if (applyResult?.updatedValue) { + setSnapshot(applyResult.updatedValue); + } + + setPreviewOpen(false); + setPreviewResult(null); + setApplyMessage( + applyResult?.message || result?.message || "JVM 变更已执行", + ); + await loadSnapshot(); + } catch (err: any) { + setDraftError(err?.message || "执行 JVM 变更失败"); + } finally { + setApplyLoading(false); + } + }; + + if (!connection) { + return ( + + ); + } + + const cardStyle = getJVMWorkspaceCardStyle(darkMode); + + return ( + <> + + + + {connection.name} + · {resourcePath || "-"} + + } + badges={ + <> + + + {readOnly ? "只读连接" : "可写连接"} + + + } + actions={ + <> + + + + + } + /> + +
+ + {loading ? ( + + ) : ( + + {error ? : null} + {snapshot ? ( + <> + + + {snapshot.resourceId || "-"} + + + {snapshot.kind || tab.resourceKind || "-"} + + + {snapshot.format || "-"} + + + {snapshot.version || "-"} + + + {formatJVMActionSummary(supportedActions)} + + + {snapshot.description ? ( + {snapshot.description} + ) : null} +
+ + 资源值 + +
+ +
+
+ {metadataText ? ( +
+ + 元数据 + +
+ +
+
+ ) : null} + + ) : error ? null : ( + + )} +
+ )} +
+ + {!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 + ? " 已按当前动作填充推荐模板。" + : ""} + +