Release/0.7.0

This commit is contained in:
Syngnat
2026-04-26 20:56:39 +08:00
committed by GitHub
150 changed files with 34498 additions and 4052 deletions

3
.gitignore vendored
View File

@@ -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
.tmp_superpowers_edit

View File

@@ -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
# 防御性:即使生成了目标文件,也要确保不是 UDRWUDRW 在 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}"

File diff suppressed because it is too large Load Diff

View File

@@ -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 3AI 协同
- 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 运行时缓存治理能力。

View File

@@ -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 1JVM 共享契约与配置归一化
- 已完成 Task 2Provider 注册、连接测试与能力探测 API
- 已完成 Task 3JVM 连接表单、图标与展示文案接入
- 已完成 Task 4只读资源浏览与 JVM Tab
- 已完成 Task 5写入预览、Guard 和审计记录
- 已完成 Task 6AI 结构化变更计划
- 已完成 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后续推荐方向转为“多接入模式 + 能力协商”
- 决策 5AI 在 JVM 场景中只负责分析与生成结构化计划,不直接执行运行时写入
- 决策 6AI 计划应用入口只回填 JVM 预览草稿,后续仍必须经过 `JVMPreviewChange`、Guard 校验和人工确认
- 决策 7当前 MVP 中 `updateValue` 会映射到现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象
- 决策 8JVM AI 计划必须绑定生成时的 JVM 上下文,只允许投递到匹配的 `tabId + connectionId + providerMode + resourcePath`
- 决策 9JMX helper 采用 Java 8 兼容的预编译 runtime jar 内嵌分发,运行时只依赖本地 `java`
- 决策 10Agent 模式按“预埋 GoNavi Java Agent + 独立 Agent Base URL 接入”落地,不在当前版本实现动态 attach
## 7. 验证记录
- 验证项:
- GoNavi 驱动代理机制核查
- GoNavi 现有 Redis/编辑器/UI 复用能力核查
- JVM Connector 正式设计文档自检
- JVM Connector 实施计划文档自检
- Task 1JVM 共享契约与配置归一化
- Task 2Provider 注册、连接测试与能力探测 API
- Task 6AI 计划解析、资源定位解析、契约映射与页签上下文隔离
- Task 7Java Endpoint fixture 真实集成验证
- Task 7JMX helper 内嵌分发与运行时缓存验证
- Task 7Agent provider 与真实 Java Agent 集成验证
- Task 7后端全量测试
- Task 7前端全量测试
- Task 7前端生产构建
- Task 7Wails 生产构建
- 结果:
- 已确认存在可复用的连接桥接与编辑器基础设施
- 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突
- 已完成正式实施计划落盘与自检,已补齐共享 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 6AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测
- 已完成 Task 6AI 聊天消息与 JVM 来源页签绑定AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递
- 已完成 Task 7Java Endpoint fixture可真实验证 `resources / value / preview / apply` 四个 endpoint contract
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 通过
- 已完成 Task 7JMX helper 改为预编译 jar 内嵌分发,并补齐 classpath 覆盖与缓存落盘单测
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` 通过
- 已完成 Task 7Agent 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 files259 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

View File

@@ -0,0 +1,24 @@
# SQL 方言适配需求进度追踪
## 背景
- Oracle 等非 MySQL 数据源在表设计 DDL 预览中可能回落到 MySQL 语法,导致修改字段名、字段属性等操作执行失败。
- GitHub 相关问题Refs #402(金仓字段类型/DDL 方言、Refs #409Oracle 删除数据 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 数据复制/删除 SQLDATE/TIMESTAMP 字段使用 Oracle 时间构造函数。
## 验证
- `npm test -- tableDesignerSchemaSql.test.ts sqlDialect.test.ts dataGridCopyInsert.test.ts`
- `npm run build`
## 风险与后续
- ClickHouse/TDengine 的字段约束、默认值、备注语法差异较大,当前策略是生成有限原生 ALTER并用中文注释阻止 MySQL 专属子句外溢。
- SQL Server 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。

View File

@@ -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

View File

@@ -1 +1 @@
26a843d5fd071d0c7e9d8022e98eb4e3
571d014306268cf67665967059cda912

View File

@@ -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<AIChatPanelProps> = ({
const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数
const panelRef = useRef<HTMLDivElement>(null); // 面板 DOM ref用于拖拽时直接操作宽度
const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染)
const pendingJVMPlanContextRef = useRef<JVMAIPlanContext | undefined>(undefined);
const pendingJVMDiagnosticPlanContextRef = useRef<JVMDiagnosticPlanContext | undefined>(undefined);
const aiChatHistory = useStore(state => state.aiChatHistory);
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
@@ -248,6 +255,50 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
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<AIChatPanelProps> = ({
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<AIChatPanelProps> = ({
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<AIChatPanelProps> = ({
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<AIChatPanelProps> = ({
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<AIChatPanelProps> = ({
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<AIChatPanelProps> = ({
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<AIChatPanelProps> = ({
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<AIChatPanelProps> = ({
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<AIChatPanelProps> = ({
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<AIChatPanelProps> = ({
} 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<Map<string, { connectionId: string; dbName: string; tables: string[] }>>(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) {

File diff suppressed because it is too large Load Diff

View File

@@ -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(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
/>,
);
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(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
showFilter
quickWhereCondition="name like 'a%'"
onApplyQuickWhereCondition={() => {}}
/>,
);
expect(markup).toContain('data-grid-quick-where="true"');
expect(markup).toContain('WHERE');
expect(markup).toContain('输入 WHERE 后面的条件');
});
});

View File

@@ -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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
const [cellEditMode, setCellEditMode] = useState(false);
const [selectedCells, setSelectedCells] = useState<Set<string>>(new Set());
const [copiedCellPatch, setCopiedCellPatch] = useState<{ sourceRowKey: string; values: Record<string, any> } | null>(null);
const [copiedRowsForPaste, setCopiedRowsForPaste] = useState<Array<Record<string, any>>>([]);
const [batchEditModalOpen, setBatchEditModalOpen] = useState(false);
const [batchEditValue, setBatchEditValue] = useState('');
const [batchEditSetNull, setBatchEditSetNull] = useState(false);
@@ -2196,6 +2209,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// Filter State
const [filterConditions, setFilterConditions] = useState<GridFilterCondition[]>([]);
const [nextFilterId, setNextFilterId] = useState(1);
const [quickWhereDraft, setQuickWhereDraft] = useState(() => normalizeQuickWhereCondition(quickWhereCondition));
const filterPanelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -2205,6 +2219,29 @@ const DataGrid: React.FC<DataGridProps> = ({
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: (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<span>{item.label}</span>
<span style={{ color: darkMode ? 'rgba(255,255,255,0.46)' : 'rgba(0,0,0,0.42)', fontSize: 12 }}>{item.detail}</span>
</div>
),
}));
}, [allTableColumnNames, displayColumnNames, quickWhereDraft, dbType, darkMode]);
useEffect(() => {
if (!showFilter) {
return;
@@ -2251,6 +2288,7 @@ const DataGrid: React.FC<DataGridProps> = ({
setDeletedRowKeys(new Set());
setSelectedRowKeys([]);
setCopiedCellPatch(null);
setCopiedRowsForPaste([]);
setRowEditorOpen(false);
setRowEditorRowKey('');
rowEditorBaseRawRef.current = {};
@@ -3622,6 +3660,55 @@ const DataGrid: React.FC<DataGridProps> = ({
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<Record<string, any>>,
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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
<>
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
<Button icon={<PlusOutlined />} onClick={handleAddRow}></Button>
<Button
data-grid-copy-row-action="true"
icon={<CopyOutlined />}
disabled={selectedRowKeys.length === 0}
onClick={handleCopySelectedRowsForPaste}
>
</Button>
<Button
data-grid-paste-row-action="true"
icon={<VerticalAlignBottomOutlined />}
disabled={copiedRowsForPaste.length === 0}
onClick={handlePasteCopiedRowsAsNew}
>
{copiedRowsForPaste.length > 0 ? `粘贴行 (${copiedRowsForPaste.length})` : '粘贴行'}
</Button>
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}></Button>
{selectedRowKeys.length > 0 && <span style={{ fontSize: '12px', color: '#888' }}> {selectedRowKeys.length}</span>}
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
@@ -5080,6 +5202,73 @@ const DataGrid: React.FC<DataGridProps> = ({
display: 'flex',
flexDirection: 'column',
}}>
<div
data-grid-quick-where="true"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 12px',
marginBottom: 10,
borderRadius: Math.max(10, panelRadius - 2),
border: `1px solid ${panelFrameColor}`,
background: darkMode ? 'rgba(255,255,255,0.035)' : 'rgba(255,255,255,0.72)',
boxSizing: 'border-box',
minWidth: 0,
}}
>
<span
style={{
flex: '0 0 auto',
minWidth: 58,
height: 28,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 999,
background: darkMode ? 'rgba(24,144,255,0.18)' : 'rgba(24,144,255,0.10)',
border: `1px solid ${darkMode ? 'rgba(24,144,255,0.32)' : 'rgba(24,144,255,0.22)'}`,
color: selectionAccentHex,
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.03em',
}}
>
WHERE
</span>
<AutoComplete
value={quickWhereDraft}
options={quickWhereSuggestionOptions}
onChange={setQuickWhereDraft}
onSelect={(value, option) => {
setQuickWhereDraft(resolveWhereConditionSelectedValue({
selectedValue: value,
currentInput: quickWhereDraft,
insertText: (option as any)?.insertText,
}));
}}
style={{ flex: '1 1 320px', minWidth: 220 }}
popupMatchSelectWidth={420}
>
<Input
{...noAutoCapInputProps}
allowClear
placeholder={dbType === 'mongodb' ? '输入 MongoDB JSON 查询对象,例如 {"status":"A"}' : '输入 WHERE 后面的条件,例如 status = 1 AND name LIKE \'A%\''}
onPressEnter={(event) => {
if (!event.shiftKey) {
event.preventDefault();
applyQuickWhereCondition();
}
}}
/>
</AutoComplete>
<Button size="small" type="primary" onClick={() => applyQuickWhereCondition()}>
WHERE
</Button>
<Button size="small" onClick={clearQuickWhereCondition} disabled={!quickWhereDraft && !quickWhereCondition}>
</Button>
</div>
{/* 筛选条件 + 排序区域:固定最大高度,超出后可滚动,避免条件过多挤压数据表 */}
<div style={{ maxHeight: 200, overflowY: 'auto', overflowX: 'hidden', flex: '0 1 auto' }}>
{filterConditions.map((cond, condIndex) => (
@@ -5247,6 +5436,7 @@ const DataGrid: React.FC<DataGridProps> = ({
<Button type="primary" onClick={applyFilters} size="small"></Button>
<Button size="small" icon={<ClearOutlined />} onClick={() => {
setFilterConditions([]);
clearQuickWhereCondition();
if (onApplyFilter) onApplyFilter([]);
if (onSort) onSort('', '');
}}></Button>

View File

@@ -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<boolean>(initialViewerSnapshot.showFilter);
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
const [quickWhereCondition, setQuickWhereCondition] = useState<string>(initialViewerSnapshot.quickWhereCondition);
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
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<string, unknown> | 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 (
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
@@ -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}

View File

@@ -15,6 +15,7 @@ const DB_DEFAULT_COLORS: Record<string, string> = {
postgres: '#336791',
redis: '#DC382D',
mongodb: '#47A248',
jvm: '#1677FF',
kingbase: '#1890FF',
dameng: '#E6002D',
oracle: '#F80000',
@@ -136,6 +137,9 @@ const HighGoIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
const TDengineIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.tdengine} label="TD" />
);
const JVMIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.jvm} label="JVM" />
);
/** Custom — 齿轮图标 */
const CustomIcon: React.FC<DbIconProps> = ({ size = 16, color }) => {
@@ -166,6 +170,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
postgres: PostgresIcon,
redis: RedisIcon,
mongodb: MongoDBIcon,
jvm: JVMIcon,
kingbase: KingBaseIcon,
dameng: DamengIcon,
oracle: OracleIcon,
@@ -181,7 +186,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
/** 可选图标类型列表(用于图标选择器 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<string, string> = {
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',

View File

@@ -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(
<JVMAuditViewer
tab={{
id: "tab-jvm-audit",
type: "jvm-audit",
title: "[orders-jvm] JVM 审计",
connectionId: "conn-jvm-1",
providerMode: "endpoint",
} as any}
/>,
);
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 条");
});
});

View File

@@ -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<JVMAuditViewerProps> = ({ 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<JVMAuditRecord[]>([]);
const [error, setError] = useState("");
const columns = useMemo<ColumnsType<JVMAuditRecord>>(
() => [
{
title: "时间",
dataIndex: "timestamp",
key: "timestamp",
width: 180,
render: (value: number) => formatTimestamp(value),
},
{
title: "模式",
dataIndex: "providerMode",
key: "providerMode",
width: 120,
render: (value: string) => (
<JVMModeBadge mode={value || tab.providerMode || "jmx"} />
),
},
{
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 <Tag color="purple">AI </Tag>;
}
return <Tag></Tag>;
},
},
{
title: "结果",
dataIndex: "result",
key: "result",
width: 140,
render: (value: string) => (
<Tag color={resolveJVMAuditResultColor(value)}>
{formatJVMAuditResultLabel(value)}
</Tag>
),
},
],
[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 (
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
);
}
const activeMode =
tab.providerMode || connection.config.jvm?.preferredMode || "jmx";
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
return (
<JVMWorkspaceShell darkMode={darkMode}>
<JVMWorkspaceHero
darkMode={darkMode}
eyebrow="JVM Audit"
title="JVM 变更审计"
description={
<>
<Text strong>{connection.name}</Text>
<Text type="secondary"> · {connection.id}</Text>
<Text type="secondary"> · {limit} </Text>
</>
}
badges={<JVMModeBadge mode={activeMode} />}
actions={
<>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => void loadRecords()}
>
</Button>
<Select
size="small"
value={limit}
onChange={setLimit}
options={LIMIT_OPTIONS.map((item) => ({
value: item,
label: `最近 ${item}`,
}))}
style={{ width: 132 }}
/>
</>
}
/>
<Card title="审计记录" variant="borderless" style={cardStyle}>
<Space direction="vertical" size={16} style={{ width: "100%" }}>
{error ? <Alert type="error" showIcon message={error} /> : null}
<Table<JVMAuditRecord>
rowKey={(record) =>
`${record.timestamp}-${record.resourceId}-${record.action}`
}
loading={loading}
columns={columns}
dataSource={records}
pagination={false}
locale={{
emptyText: error ? "当前无法加载审计记录" : "暂无审计记录",
}}
scroll={{ x: 960 }}
size="small"
/>
</Space>
</Card>
</JVMWorkspaceShell>
);
};
export default JVMAuditViewer;

View File

@@ -0,0 +1,272 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import JVMDiagnosticConsole, {
createJVMDiagnosticLocalPendingChunk,
createJVMDiagnosticRunningRecord,
isJVMDiagnosticTerminalPhase,
} from "./JVMDiagnosticConsole";
const baseState = {
connections: [
{
id: "conn-1",
name: "orders-jvm",
config: {
host: "orders.internal",
jvm: {
diagnostic: {
enabled: true,
transport: "agent-bridge",
},
},
},
},
],
jvmDiagnosticDrafts: {},
jvmDiagnosticOutputs: {},
setJVMDiagnosticDraft: vi.fn(),
appendJVMDiagnosticOutput: vi.fn(),
clearJVMDiagnosticOutput: vi.fn(),
};
let mockState: any = baseState;
let registeredCompletionProvider: any = null;
const mockMonaco = {
Range: class {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
constructor(
startLineNumber: number,
startColumn: number,
endLineNumber: number,
endColumn: number,
) {
this.startLineNumber = startLineNumber;
this.startColumn = startColumn;
this.endLineNumber = endLineNumber;
this.endColumn = endColumn;
}
},
KeyMod: { CtrlCmd: 2048 },
KeyCode: { Enter: 3 },
editor: {
setTheme: vi.fn(),
},
languages: {
CompletionItemKind: {
Keyword: 1,
Snippet: 2,
Value: 3,
},
CompletionItemInsertTextRule: {
InsertAsSnippet: 4,
},
register: vi.fn(),
registerCompletionItemProvider: vi.fn((language: string, provider: any) => {
if (language === "jvm-diagnostic") {
registeredCompletionProvider = provider;
}
return { dispose: vi.fn() };
}),
},
};
const mockEditor = {
addCommand: vi.fn(),
};
vi.mock("@monaco-editor/react", () => ({
default: ({
beforeMount,
language,
onMount,
value,
}: {
beforeMount?: (monaco: any) => void;
language?: string;
onMount?: (editor: any, monaco: any) => void;
value?: string;
}) => {
beforeMount?.(mockMonaco);
onMount?.(mockEditor, mockMonaco);
return (
<div
data-before-mount={beforeMount ? "true" : "false"}
data-monaco-editor-mock="true"
data-language={language}
>
{value}
</div>
);
},
}));
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) => selector(mockState),
}));
describe("JVMDiagnosticConsole", () => {
beforeEach(() => {
registeredCompletionProvider = null;
mockMonaco.editor.setTheme.mockClear();
mockMonaco.languages.register.mockClear();
mockMonaco.languages.registerCompletionItemProvider.mockClear();
mockEditor.addCommand.mockClear();
});
it("builds local pending output and history while a command is waiting for backend events", () => {
const chunk = createJVMDiagnosticLocalPendingChunk({
sessionId: "session-1",
commandId: "cmd-1",
command: "thread -n 5",
});
const record = createJVMDiagnosticRunningRecord({
connectionId: "conn-1",
sessionId: "session-1",
commandId: "cmd-1",
transport: "arthas-tunnel",
command: "thread -n 5",
source: "manual",
reason: "排查线程",
});
expect(chunk).toMatchObject({
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
});
expect(chunk.content).toContain("thread -n 5");
expect(record).toMatchObject({
connectionId: "conn-1",
sessionId: "session-1",
commandId: "cmd-1",
transport: "arthas-tunnel",
command: "thread -n 5",
status: "running",
reason: "排查线程",
});
expect(isJVMDiagnosticTerminalPhase("completed")).toBe(true);
expect(isJVMDiagnosticTerminalPhase("failed")).toBe(true);
expect(isJVMDiagnosticTerminalPhase("running")).toBe(false);
});
it("keeps a stable workbench shell and hides command inputs before session creation", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("开始一次诊断");
expect(markup).toContain("命令输入将在会话建立后显示");
expect(markup).toContain("先建立会话,再显示命令编辑器和模板");
expect(markup).toContain("会话与能力");
expect(markup).toContain("审计历史");
expect(markup).not.toContain("命令模板");
expect(markup).not.toContain("实时输出");
expect(markup).not.toContain('data-monaco-editor-mock="true"');
});
it("shows command input, reason field, and presets after a session exists", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
reason: "排查 CPU 线程",
},
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("overflow:auto");
expect(markup).toContain("JVM 诊断工作台");
expect(markup).toContain("会话与能力");
expect(markup).toContain("实时输出");
expect(markup).toContain("审计历史");
expect(markup.indexOf("命令输入")).toBeGreaterThanOrEqual(0);
expect(markup).toContain("诊断命令");
expect(markup).toContain("诊断原因(可选)");
expect(markup).toContain("用于审计记录");
expect(markup.indexOf("命令输入")).toBeLessThan(markup.indexOf("实时输出"));
expect(markup).toContain("观察类命令");
expect(markup).toContain("thread");
expect(markup).toContain("执行命令");
expect(markup).toContain('data-monaco-editor-mock="true"');
expect(markup).toContain('data-language="jvm-diagnostic"');
});
it("uses the same styled editor shell and registers command completion before mount", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thr",
reason: "排查 CPU 线程",
},
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain(
'data-jvm-diagnostic-command-editor-shell="true"',
);
expect(markup).toContain('data-before-mount="true"');
expect(markup).toContain("border-radius:14px");
expect(registeredCompletionProvider).toBeTruthy();
const result = registeredCompletionProvider.provideCompletionItems(
{
getValueInRange: () => "thr",
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 4 }),
},
{ lineNumber: 1, column: 4 },
);
expect(result.suggestions).toEqual(
expect.arrayContaining([
expect.objectContaining({
label: "thread",
insertText: "thread ",
}),
]),
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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(
<JVMMonitoringDashboard
tab={{
id: "tab-monitor-1",
title: "持续监控",
type: "jvm-monitoring",
connectionId: "conn-1",
providerMode: "jmx",
}}
/>,
);
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(
<JVMMonitoringDashboard
tab={{
id: "tab-monitor-scroll",
title: "持续监控",
type: "jvm-monitoring",
connectionId: "conn-1",
providerMode: "jmx",
}}
/>,
);
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(
<JVMMonitoringDashboard
tab={{
id: "tab-monitor-layout",
title: "持续监控",
type: "jvm-monitoring",
connectionId: "conn-1",
providerMode: "jmx",
}}
/>,
);
expect(markup).toContain('data-jvm-monitoring-content-stack="true"');
expect(markup).toContain("gap:24px");
expect(markup).not.toContain("minmax(min(100%, 320px), 1fr)");
});
});

View File

@@ -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<JVMMonitoringDashboardProps> = ({ 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<JVMMonitoringSessionState>(() =>
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<typeof setTimeout> | 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 <Empty description="连接不存在或已被删除" style={{ marginTop: 80 }} />;
}
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 (
<div
className="jvm-monitoring-dashboard-scroll-shell"
data-jvm-monitoring-dashboard-scroll-shell="true"
style={{
height: "100%",
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
padding: 20,
display: "grid",
gap: 16,
alignContent: "start",
background: darkMode ? "#141414" : "#f5f7fb",
}}
>
<Card variant="borderless" style={{ borderRadius: 12 }}>
<Space
direction="vertical"
size={12}
style={{ width: "100%", alignItems: "stretch" }}
>
<Space size={12} wrap style={{ justifyContent: "space-between" }}>
<div>
<Title level={3} style={{ margin: 0 }}>
<DashboardOutlined style={{ color: "#1677ff", marginRight: 8 }} />
JVM
</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
<Text strong>{connection.name}</Text>
<Text type="secondary">
{" "}
· {connection.config.host}:{connection.config.port}
</Text>
</Paragraph>
</div>
<Space wrap>
<Tag color={modeMeta.color} style={{ marginInlineEnd: 0 }}>
{modeMeta.label}
</Tag>
{session.running ? (
<Tag color="green"></Tag>
) : (
<Tag></Tag>
)}
<Button
icon={<ReloadOutlined />}
onClick={() => setPollSeed((current) => current + 1)}
>
</Button>
{session.running ? (
<Button
danger
type="primary"
icon={<PauseCircleOutlined />}
loading={actionLoading}
onClick={() => void handleStop()}
>
</Button>
) : (
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading}
onClick={() => void handleStart()}
>
</Button>
)}
</Space>
</Space>
{(session.missingMetrics?.length || session.providerWarnings?.length) ? (
<Alert
type="warning"
showIcon
message="监控能力存在降级"
description={availabilityText}
/>
) : null}
{error ? <Alert type="error" showIcon message={error} /> : null}
</Space>
</Card>
{loading && emptyState ? (
<div style={{ display: "flex", justifyContent: "center", padding: "24px 0" }}>
<Spin />
</div>
) : null}
{emptyState ? (
<div
data-jvm-monitoring-content-stack="true"
style={{
display: "grid",
gap: 24,
alignItems: "start",
}}
>
<Card variant="borderless" style={{ borderRadius: 12 }}>
<Empty
description="当前尚未开始持续监控"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Paragraph type="secondary" style={{ maxWidth: 520, margin: "0 auto 16px" }}>
GoNavi
</Paragraph>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading}
onClick={() => void handleStart()}
>
</Button>
</Empty>
</Card>
<JVMMonitoringCharts
points={session.points || []}
session={session}
darkMode={darkMode}
/>
</div>
) : (
<div
data-jvm-monitoring-content-stack="true"
style={{
display: "grid",
gap: 24,
alignItems: "start",
}}
>
<JVMMonitoringStatusCards
latestPoint={latestPoint}
session={session}
darkMode={darkMode}
/>
<JVMMonitoringCharts
points={session.points || []}
session={session}
darkMode={darkMode}
/>
<JVMMonitoringDetailPanel
session={session}
latestPoint={latestPoint}
darkMode={darkMode}
/>
</div>
)}
</div>
);
};
export default JVMMonitoringDashboard;

View File

@@ -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(
<JVMOverview
tab={{
id: "tab-jvm-overview",
type: "jvm-overview",
title: "[orders-jvm] JVM 概览",
connectionId: "conn-jvm-1",
providerMode: "jmx",
} as any}
/>,
);
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");
});
});

View File

@@ -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<JVMOverviewProps> = ({ 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<JVMCapability[]>([]);
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 (
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
);
}
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 (
<JVMWorkspaceShell darkMode={darkMode}>
<JVMWorkspaceHero
darkMode={darkMode}
eyebrow="JVM Runtime"
title="JVM 运行时概览"
description={
<>
<Text strong>{connection.name}</Text>
<Text type="secondary">
{" "}
· {connection.config.host}:{connection.config.port}
</Text>
</>
}
badges={
<>
<JVMModeBadge mode={providerMode} />
<Tag color={readOnly ? "blue" : "red"}>
{readOnly ? "只读连接" : "可写连接"}
</Tag>
<Tag>{connection.config.jvm?.environment || "dev"}</Tag>
</>
}
/>
<Card title="连接摘要" variant="borderless" style={cardStyle}>
<Descriptions column={1} size="small" styles={DESCRIPTION_STYLES}>
<Descriptions.Item label="当前模式">
{resolveJVMModeMeta(providerMode).label}
</Descriptions.Item>
<Descriptions.Item label="允许模式">
{allowedModeSummary}
</Descriptions.Item>
<Descriptions.Item label="JMX 地址">{`${jmxHost}:${jmxPort}`}</Descriptions.Item>
<Descriptions.Item label="Endpoint">
{endpointSummary || "未配置"}
</Descriptions.Item>
<Descriptions.Item label="Agent">
{agentSummary || "未配置"}
</Descriptions.Item>
<Descriptions.Item label="资源浏览">
{"通过侧边栏展开模式节点后懒加载"}
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="模式能力" variant="borderless" style={cardStyle}>
{capabilityLoading ? (
<Skeleton active paragraph={{ rows: 3 }} />
) : capabilityError ? (
<Alert
type="error"
showIcon
message="读取 JVM 模式能力失败"
description={
<span style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{capabilityError}
</span>
}
/>
) : capabilities.length === 0 ? (
<Empty description="暂无模式能力数据" />
) : (
<Space direction="vertical" size={12} style={{ width: "100%" }}>
{capabilities.map((capability) => (
<div
key={capability.mode}
style={{
border: "1px solid rgba(5, 5, 5, 0.08)",
borderRadius: 8,
padding: 12,
}}
>
<Space size={8} wrap>
<JVMModeBadge mode={capability.mode} />
<Tag color={capability.canBrowse ? "green" : "default"}>
{capability.canBrowse ? "可浏览" : "不可浏览"}
</Tag>
<Tag color={capability.canWrite ? "red" : "blue"}>
{capability.canWrite ? "可写" : "只读"}
</Tag>
<Tag color={capability.canPreview ? "gold" : "default"}>
{capability.canPreview ? "支持预览" : "不支持预览"}
</Tag>
</Space>
{capability.reason ? (
<Text
type="secondary"
style={{
display: "block",
marginTop: 8,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{capability.reason}
</Text>
) : null}
</div>
))}
</Space>
)}
</Card>
</JVMWorkspaceShell>
);
};
export default JVMOverview;

View File

@@ -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 }) => (
<div data-monaco-editor-mock="true" data-language={language}>
{value}
</div>
),
}));
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 }) => <span>{mode}</span>,
}));
vi.mock('./jvm/JVMChangePreviewModal', () => ({
default: () => null,
}));
describe('JVMResourceBrowser layout', () => {
it('renders a dedicated vertical scroll shell for tall snapshot content', () => {
const markup = renderToStaticMarkup(
<JVMResourceBrowser
tab={{
id: 'tab-jvm-resource-1',
type: 'jvm-resource',
title: '[localhost] JVM 资源',
connectionId: 'conn-jvm-1',
providerMode: 'jmx',
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
resourceKind: 'mbean',
} as any}
/>,
);
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(
<JVMResourceBrowser
tab={{
id: 'tab-jvm-resource-2',
type: 'jvm-resource',
title: '[localhost] JVM 资源',
connectionId: 'conn-jvm-2',
providerMode: 'jmx',
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
resourceKind: 'mbean',
} as any}
/>,
);
expect(markup).toContain('动作');
expect(markup).not.toContain('>Action<');
});
it('hides the change draft form entirely for read-only JVM connections', () => {
const markup = renderToStaticMarkup(
<JVMResourceBrowser
tab={{
id: 'tab-jvm-resource-3',
type: 'jvm-resource',
title: '[localhost] JVM 资源',
connectionId: 'conn-jvm-1',
providerMode: 'jmx',
resourcePath: 'jmx:/mbean/com.alibaba.druid:type=DruidDriver',
resourceKind: 'mbean',
} as any}
/>,
);
expect(markup).not.toContain('变更草稿');
expect(markup).not.toContain('预览变更');
expect(markup).not.toContain('Payload(JSON)');
});
});

View File

@@ -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<JVMResourceBrowserProps> = ({ 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<JVMValueSnapshot | null>(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<JVMChangePreview | null>(
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<string, any> = {};
if (rawPayload) {
const parsed = JSON.parse(rawPayload);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Payload 必须是 JSON 对象");
}
payload = parsed as Record<string, any>;
}
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 (
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
);
}
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
return (
<>
<style>{`
.jvm-resource-browser-scroll-shell {
scrollbar-width: thin;
}
.jvm-resource-browser-scroll-shell::-webkit-scrollbar,
.jvm-resource-browser-code-block::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.jvm-resource-browser-scroll-shell::-webkit-scrollbar-thumb,
.jvm-resource-browser-code-block::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.22);
border-radius: 999px;
}
.jvm-resource-browser-scroll-shell::-webkit-scrollbar-track,
.jvm-resource-browser-code-block::-webkit-scrollbar-track {
background: transparent;
}
@media (max-width: 1120px) {
.jvm-resource-workbench {
grid-template-columns: 1fr !important;
}
}
`}</style>
<JVMWorkspaceShell
darkMode={darkMode}
className="jvm-resource-browser-scroll-shell"
data-jvm-resource-browser-scroll-shell="true"
>
<JVMWorkspaceHero
darkMode={darkMode}
eyebrow="JVM Resource"
title="JVM 资源工作台"
description={
<>
<Text strong>{connection.name}</Text>
<Text type="secondary"> · {resourcePath || "-"}</Text>
</>
}
badges={
<>
<JVMModeBadge mode={providerMode} />
<Tag color={readOnly ? "blue" : "red"}>
{readOnly ? "只读连接" : "可写连接"}
</Tag>
</>
}
actions={
<>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => void loadSnapshot()}
>
</Button>
<Button
size="small"
icon={<FileSearchOutlined />}
onClick={handleOpenAudit}
>
</Button>
<Button
size="small"
icon={<RobotOutlined />}
onClick={handleAskAIForPlan}
>
AI
</Button>
</>
}
/>
<div
className="jvm-resource-workbench"
data-jvm-resource-workbench="true"
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) minmax(360px, 440px)",
gap: 18,
alignItems: "start",
}}
>
<Card
title="资源快照"
variant="borderless"
style={{
...cardStyle,
gridColumn: readOnly ? "1 / -1" : undefined,
}}
>
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : (
<Space direction="vertical" size={16} style={{ width: "100%" }}>
{error ? <Alert type="error" showIcon message={error} /> : null}
{snapshot ? (
<>
<Descriptions
column={1}
size="small"
styles={DESCRIPTION_STYLES}
>
<Descriptions.Item label="资源 ID">
{snapshot.resourceId || "-"}
</Descriptions.Item>
<Descriptions.Item label="资源类型">
{snapshot.kind || tab.resourceKind || "-"}
</Descriptions.Item>
<Descriptions.Item label="格式">
{snapshot.format || "-"}
</Descriptions.Item>
<Descriptions.Item label="版本">
{snapshot.version || "-"}
</Descriptions.Item>
<Descriptions.Item label="可用动作">
{formatJVMActionSummary(supportedActions)}
</Descriptions.Item>
</Descriptions>
{snapshot.description ? (
<Text type="secondary">{snapshot.description}</Text>
) : null}
<div>
<Text
strong
style={{ display: "block", marginBottom: 8 }}
>
</Text>
<div
className="jvm-resource-browser-code-block"
style={{
...snapshotBlockStyle("rgba(0, 0, 0, 0.04)"),
height: estimateJVMResourceEditorHeight(displayValue),
}}
>
<Editor
height="100%"
language={displayLanguage}
theme={
darkMode ? "transparent-dark" : "transparent-light"
}
value={displayValue}
options={{
readOnly: true,
minimap: { enabled: false },
lineNumbers: "on",
wordWrap: "on",
scrollBeyondLastLine: false,
automaticLayout: true,
folding: true,
renderValidationDecorations: "off",
}}
/>
</div>
</div>
{metadataText ? (
<div>
<Text
strong
style={{ display: "block", marginBottom: 8 }}
>
</Text>
<div
className="jvm-resource-browser-code-block"
style={{
...snapshotBlockStyle("rgba(0, 0, 0, 0.03)"),
height:
estimateJVMResourceEditorHeight(metadataText),
}}
>
<Editor
height="100%"
language={metadataLanguage}
theme={
darkMode
? "transparent-dark"
: "transparent-light"
}
value={metadataText}
options={{
readOnly: true,
minimap: { enabled: false },
lineNumbers: "on",
wordWrap: "on",
scrollBeyondLastLine: false,
automaticLayout: true,
folding: true,
renderValidationDecorations: "off",
}}
/>
</div>
</div>
) : null}
</>
) : error ? null : (
<Empty description="暂无资源数据" />
)}
</Space>
)}
</Card>
{!readOnly ? (
<Card title="变更草稿" variant="borderless" style={cardStyle}>
<Space direction="vertical" size={16} style={{ width: "100%" }}>
{draftError ? (
<Alert type="error" showIcon message={draftError} />
) : null}
{applyMessage ? (
<Alert type="success" showIcon message={applyMessage} />
) : null}
<Descriptions
column={1}
size="small"
styles={DESCRIPTION_STYLES}
>
<Descriptions.Item label="资源路径">
{resourcePath || "-"}
</Descriptions.Item>
<Descriptions.Item label="目标资源">
{draftResourceId || resourcePath || "-"}
</Descriptions.Item>
<Descriptions.Item label="资源版本">
{snapshot?.version || "-"}
</Descriptions.Item>
<Descriptions.Item label="草稿来源">
{draftSource === "ai-plan" ? "AI 辅助草稿" : "手工编辑"}
</Descriptions.Item>
</Descriptions>
{supportedActions.length > 0 ? (
<Space
direction="vertical"
size={8}
style={{ width: "100%" }}
>
<Text strong></Text>
<Space size={8} wrap>
{supportedActions.map((item) => (
<Button
key={item.action}
size="small"
type={action === item.action ? "primary" : "default"}
danger={item.dangerous}
onClick={() => handleSelectAction(item.action, item)}
>
{resolveJVMActionDisplay(item).label}
</Button>
))}
</Space>
{selectedActionDisplay.description ? (
<Text type="secondary">
{selectedActionDisplay.description}
</Text>
) : null}
{selectedActionDefinition?.payloadFields?.length ? (
<Text type="secondary">
Payload
{selectedActionDefinition.payloadFields
.map(
(field) =>
`${field.name}${field.required ? "(必填)" : ""}`,
)
.join("、")}
</Text>
) : null}
</Space>
) : null}
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Text strong></Text>
<Input
value={action}
onChange={(event) =>
handleSelectAction(
event.target.value,
selectedActionDefinition,
)
}
placeholder={
providerMode === "jmx"
? "例如 set 或 invoke"
: "例如 put / clear / evict"
}
maxLength={64}
/>
{action ? (
<Text type="secondary">
{formatJVMActionDisplayText(selectedActionDisplay)}
</Text>
) : null}
</Space>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Text strong></Text>
<Input
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="填写本次 JVM 资源变更原因"
maxLength={200}
/>
</Space>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Text strong>Payload(JSON)</Text>
<Text type="secondary">
JSON 使 payload
{selectedActionDefinition?.payloadExample
? " 已按当前动作填充推荐模板。"
: ""}
</Text>
<TextArea
value={payloadText}
onChange={(event) => setPayloadText(event.target.value)}
autoSize={{ minRows: 8, maxRows: 18 }}
spellCheck={false}
/>
</Space>
<Space size={12} wrap>
<Button
type="primary"
loading={previewLoading}
onClick={() => void handlePreview()}
>
</Button>
<Button icon={<RobotOutlined />} onClick={handleAskAIForPlan}>
AI
</Button>
</Space>
</Space>
</Card>
) : null}
</div>
</JVMWorkspaceShell>
<JVMChangePreviewModal
open={previewOpen}
preview={previewResult}
applying={applyLoading}
onCancel={() => {
if (applyLoading) {
return;
}
setPreviewOpen(false);
}}
onConfirm={() => void handleApply()}
/>
</>
);
};
export default JVMResourceBrowser;

View File

@@ -13,6 +13,7 @@ import { convertMongoShellToJsonCommand } from '../utils/mongodb';
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
const SQL_KEYWORDS = [
'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT',
@@ -521,6 +522,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const activeConnection = sharedConnections.find(c => c.id === sharedCurrentConnectionId);
const activeDialect = resolveSqlDialect(
String(activeConnection?.config?.type || ''),
String(activeConnection?.config?.driver || ''),
);
const dialectKeywords = resolveSqlKeywords(activeDialect);
const dialectFunctions = resolveSqlFunctions(activeDialect);
const stripQuotes = (ident: string) => {
let raw = (ident || '').trim();
@@ -776,7 +784,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim());
const shouldBoostKeywords = !expectsTableName
&& wordPrefix.length > 0
&& SQL_KEYWORDS.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
&& dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
const sortGroups = shouldBoostKeywords
? { keyword: '00', func: '05', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' }
: expectsTableName
@@ -878,7 +886,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}));
// 关键字提示
const keywordSuggestions = SQL_KEYWORDS
const keywordSuggestions = dialectKeywords
.filter((k) => startsWithPrefix(k))
.map(k => ({
label: k,
@@ -889,7 +897,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}));
// 内置函数提示
const funcSuggestions = SQL_FUNCTIONS
const funcSuggestions = dialectFunctions
.filter((f) => startsWithPrefix(f.name))
.map(f => ({
label: f.name,

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Table, Input, Button, Space, Tag, Tree, Spin, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd';
import type { RadioChangeEvent } from 'antd';
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined, RightOutlined, DownOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
@@ -27,7 +28,7 @@ import {
} from './redisViewerTree';
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput } from '../utils/redisSearchPattern';
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput, type RedisSearchMode } from '../utils/redisSearchPattern';
import { decodeRedisUtf8Value, formatRedisStringValue, toHexDisplay } from '../utils/redisValueDisplay';
const { Search } = Input;
@@ -171,6 +172,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const [loading, setLoading] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [searchPattern, setSearchPattern] = useState('*');
const [searchMode, setSearchMode] = useState<RedisSearchMode>('fuzzy');
const [cursor, setCursor] = useState<string>('0');
const [hasMore, setHasMore] = useState(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
@@ -346,20 +348,20 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
}, [loadKeys, redisDB]);
const executeSearch = useCallback((value: string) => {
const normalized = normalizeRedisSearchInput(value);
const executeSearch = useCallback((value: string, mode: RedisSearchMode = searchMode) => {
const normalized = normalizeRedisSearchInput(value, mode);
setSearchInput(normalized.keyword);
setSearchPattern(normalized.pattern);
setCursor('0');
loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false));
}, [loadKeys]);
}, [loadKeys, searchMode]);
const handleSearch = (value: string) => {
executeSearch(value);
};
const handleSearchInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const normalized = normalizeRedisSearchDraftChange(event.target.value);
const normalized = normalizeRedisSearchDraftChange(event.target.value, searchMode);
setSearchInput(normalized.keyword);
if (!normalized.shouldSearchImmediately) {
return;
@@ -369,6 +371,12 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false));
};
const handleSearchModeChange = useCallback((event: RadioChangeEvent) => {
const nextMode = event.target.value as RedisSearchMode;
setSearchMode(nextMode);
executeSearch(searchInput, nextMode);
}, [executeSearch, searchInput]);
const handleLoadMore = () => {
if (!hasMore || loading) {
return;
@@ -1832,9 +1840,19 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<Tag style={mutedPillTagStyle}>{keys.length} Keys</Tag>
</div>
<Space.Compact style={{ width: '100%' }}>
<Radio.Group
value={searchMode}
onChange={handleSearchModeChange}
buttonStyle="solid"
style={{ flexShrink: 0 }}
>
<Radio.Button value="fuzzy"></Radio.Button>
<Radio.Button value="exact"></Radio.Button>
</Radio.Group>
<Search
{...noAutoCapInputProps}
placeholder="搜索 Key"
style={{ flex: 1 }}
placeholder={searchMode === 'exact' ? '输入完整 Key 精确搜索' : '搜索 Key模糊匹配'}
value={searchInput}
onChange={handleSearchInputChange}
onSearch={handleSearch}

View File

@@ -36,9 +36,9 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
} from '@ant-design/icons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection, ExternalSQLTreeEntry } from '../types';
import { SavedConnection, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types';
import { getDbIcon } from './DatabaseIcons';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile } from '../../wailsjs/go/app/App';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, JVMProbeCapabilities } from '../../wailsjs/go/app/App';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
@@ -48,8 +48,12 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual';
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
import JVMModeBadge from './jvm/JVMModeBadge';
const { Search } = Input;
@@ -60,7 +64,7 @@ interface TreeNode {
children?: TreeNode[];
icon?: React.ReactNode;
dataRef?: any;
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag';
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring';
}
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
@@ -355,10 +359,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const buildConnectionNode = (conn: SavedConnection): TreeNode => {
const existing = prevMap.get(conn.id);
const iconType = resolveConnectionIconType(conn);
const iconColor = resolveConnectionAccentColor(conn);
return {
title: conn.name,
key: conn.id,
icon: getDbIcon(conn.iconType || conn.config.type, conn.iconColor, 22),
icon: getDbIcon(iconType, iconColor, 22),
type: 'connection',
dataRef: conn,
isLeaf: false,
@@ -968,6 +974,67 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
if (conn.config.type === 'jvm') {
try {
const res = await JVMProbeCapabilities(buildRuntimeConfig(conn) as any);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
const capabilities: JVMCapability[] = Array.isArray(res.data) ? res.data as JVMCapability[] : [];
const modeNodes: TreeNode[] = capabilities.map((capability) => ({
title: capability.displayLabel || capability.mode,
key: `${conn.id}-jvm-mode-${capability.mode}`,
icon: <HddOutlined />,
type: 'jvm-mode',
dataRef: {
...conn,
providerMode: capability.mode,
canBrowse: capability.canBrowse,
canWrite: capability.canWrite,
reason: capability.reason,
displayLabel: capability.displayLabel,
},
isLeaf: capability.canBrowse !== true,
}));
const monitoringNodes: TreeNode[] = buildJVMMonitoringActionDescriptors(conn.id, capabilities).map((item) => ({
title: item.title,
key: item.key,
icon: <DashboardOutlined />,
type: 'jvm-monitoring',
dataRef: {
...conn,
providerMode: item.providerMode,
},
isLeaf: true,
}));
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
setTreeData(origin => updateTreeData(origin, node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]));
} else {
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
if (diagnosticNode.length > 0) {
setTreeData(origin => updateTreeData(origin, node.key, diagnosticNode));
message.warning({ content: `JVM Provider 探测失败:${res.message || '未知错误'};已保留诊断增强入口`, key: `conn-${conn.id}-jvm-caps` });
} else {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `conn-${conn.id}-jvm-caps` });
}
}
} catch (e: any) {
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
if (diagnosticNode.length > 0) {
setTreeData(origin => updateTreeData(origin, node.key, diagnosticNode));
message.warning({ content: `JVM Provider 探测异常:${e?.message || String(e)};已保留诊断增强入口`, key: `conn-${conn.id}-jvm-caps` });
} else {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-jvm-caps` });
}
} finally {
loadingNodesRef.current.delete(loadKey);
}
return;
}
// Handle Redis connections differently
if (conn.config.type === 'redis') {
try {
@@ -1042,6 +1109,53 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
};
const loadJVMResources = async (node: any) => {
const conn = node.dataRef as SavedConnection & { providerMode?: string; resourcePath?: string };
const providerMode = String(conn.providerMode || '').trim().toLowerCase();
const parentPath = String(conn.resourcePath || '').trim();
const loadKey = `jvm-resources-${conn.id}-${providerMode}-${parentPath}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
try {
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMListResources !== 'function') {
throw new Error('JVMListResources 后端方法不可用');
}
const res = await backendApp.JVMListResources(buildJVMRuntimeConfig(conn, providerMode), parentPath);
if (res.success) {
const resourceRows: JVMResourceSummary[] = Array.isArray(res.data) ? res.data as JVMResourceSummary[] : [];
const resourceNodes: TreeNode[] = resourceRows.map((item) => ({
title: item.name || item.path || item.id,
key: `${conn.id}-jvm-resource-${providerMode}-${item.path}`,
icon: item.hasChildren ? <FolderOpenOutlined /> : <HddOutlined />,
type: 'jvm-resource',
dataRef: {
...conn,
providerMode: item.providerMode || providerMode,
resourcePath: item.path,
resourceKind: item.kind,
canRead: item.canRead,
canWrite: item.canWrite,
hasChildren: item.hasChildren,
sensitive: item.sensitive,
},
isLeaf: item.hasChildren !== true,
}));
setTreeData(origin => updateTreeData(origin, node.key, resourceNodes));
} else {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `jvm-resource-${node.key}` });
}
} catch (e: any) {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: '加载 JVM 资源失败: ' + (e?.message || String(e)), key: `jvm-resource-${node.key}` });
} finally {
loadingNodesRef.current.delete(loadKey);
}
};
const loadTables = async (node: any) => {
const conn = node.dataRef; // has dbName
const dbName = conn.dbName;
@@ -1369,6 +1483,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
if (type === 'connection') {
await loadDatabases({ key, dataRef });
} else if (type === 'jvm-mode' || type === 'jvm-resource') {
await loadJVMResources({ key, dataRef });
} else if (type === 'database') {
await loadTables({ key, dataRef });
} else if (type === 'table') {
@@ -1461,6 +1577,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'table') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic' || type === 'jvm-monitoring') {
setActiveContext({ connectionId: dataRef.id, dbName: '' });
} else if (type === 'view' || type === 'db-trigger' || type === 'routine') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'saved-query') {
@@ -1507,6 +1625,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const { type, dataRef, key: nodeKey } = node;
if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' });
else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic' || type === 'jvm-monitoring') setActiveContext({ connectionId: dataRef.id, dbName: '' });
else if (type === 'table' || type === 'view' || type === 'db-trigger' || type === 'routine') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
@@ -1585,6 +1704,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
routineType
});
return;
} else if (node.type === 'jvm-mode') {
const { providerMode, id } = node.dataRef;
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
openJVMOverviewTab(conn, providerMode);
return;
} else if (node.type === 'jvm-resource') {
const { providerMode, resourcePath, resourceKind, id } = node.dataRef;
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
openJVMResourceTab(conn, providerMode, resourcePath, resourceKind);
return;
} else if (node.type === 'jvm-monitoring') {
const { providerMode, id } = node.dataRef;
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
openJVMMonitoringTab(conn, providerMode);
return;
} else if (node.type === 'jvm-diagnostic') {
const conn = (connections.find((item) => item.id === node.dataRef.id) || node.dataRef) as SavedConnection;
openJVMDiagnosticTab(conn);
return;
}
const key = node.key;
@@ -2380,6 +2518,81 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
};
const buildJVMRuntimeConfig = (conn: SavedConnection & { dbName?: string }, providerMode: string) => {
const sourceJVM = conn.config.jvm || {};
return buildRpcConnectionConfig(conn.config, {
database: '',
jvm: {
...sourceJVM,
preferredMode: providerMode as 'jmx' | 'endpoint' | 'agent',
allowedModes: [providerMode as 'jmx' | 'endpoint' | 'agent'],
},
});
};
const openJVMOverviewTab = (conn: SavedConnection, providerMode: string) => {
addTab({
id: `jvm-overview-${conn.id}-${providerMode}`,
title: buildJVMTabTitle(conn.name, 'overview', providerMode),
type: 'jvm-overview',
connectionId: conn.id,
providerMode: providerMode as 'jmx' | 'endpoint' | 'agent',
});
};
const openJVMMonitoringTab = (conn: SavedConnection, providerMode: string) => {
addTab({
id: `jvm-monitoring-${conn.id}-${providerMode}`,
title: buildJVMTabTitle(conn.name, 'monitoring', providerMode),
type: 'jvm-monitoring',
connectionId: conn.id,
providerMode: providerMode as 'jmx' | 'endpoint' | 'agent',
});
};
const buildJVMDiagnosticTreeNodes = (conn: SavedConnection): TreeNode[] => {
const descriptor = buildJVMDiagnosticActionDescriptor(conn.id, conn.config.jvm?.diagnostic);
if (!descriptor) {
return [];
}
return [{
title: descriptor.title,
key: descriptor.key,
icon: <DashboardOutlined />,
type: 'jvm-diagnostic',
dataRef: {
...conn,
diagnosticTransport: descriptor.transport,
},
isLeaf: true,
}];
};
const openJVMResourceTab = (conn: SavedConnection, providerMode: string, resourcePath: string, resourceKind?: string) => {
const trimmedResourcePath = String(resourcePath || '').trim();
addTab({
id: `jvm-resource-${conn.id}-${providerMode}-${encodeURIComponent(trimmedResourcePath)}`,
title: trimmedResourcePath
? `${buildJVMTabTitle(conn.name, 'resource', providerMode)} · ${trimmedResourcePath}`
: buildJVMTabTitle(conn.name, 'resource', providerMode),
type: 'jvm-resource',
connectionId: conn.id,
providerMode: providerMode as 'jmx' | 'endpoint' | 'agent',
resourcePath: trimmedResourcePath,
resourceKind,
});
};
const openJVMDiagnosticTab = (conn: SavedConnection) => {
const transport = conn.config.jvm?.diagnostic?.transport || 'agent-bridge';
addTab({
id: `jvm-diagnostic-${conn.id}`,
title: buildJVMTabTitle(conn.name, 'diagnostic', transport),
type: 'jvm-diagnostic',
connectionId: conn.id,
});
};
const getConnectionNodeRef = (connRef: any) => {
const latestConn = connections.find(c => c.id === connRef.id);
return { key: connRef.id, dataRef: latestConn || connRef };
@@ -3969,6 +4182,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
hoverTitle = String(node?.dataRef?.path || displayTitle);
}
if (node.type === 'jvm-mode') {
return (
<span
title={hoverTitle}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}
>
<JVMModeBadge
mode={String(node?.dataRef?.providerMode || displayTitle)}
label={displayTitle}
reason={String(node?.dataRef?.reason || '').trim() || undefined}
/>
</span>
);
}
if (node.type === 'external-sql-root') {
return (
<span

View File

@@ -16,26 +16,40 @@ import RedisMonitor from './RedisMonitor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
import JVMOverview from './JVMOverview';
import JVMResourceBrowser from './JVMResourceBrowser';
import JVMAuditViewer from './JVMAuditViewer';
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
import type { TabData } from '../types';
import { buildTabDisplayTitle } from '../utils/tabDisplay';
import { resolveConnectionAccentColor } from '../utils/connectionVisual';
type SortableTabLabelProps = {
displayTitle: string;
menuItems: MenuProps['items'];
accentColor?: string;
};
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
displayTitle,
menuItems,
accentColor,
}) => {
const labelStyle = accentColor
? ({ '--connection-accent': accentColor } as React.CSSProperties)
: undefined;
return (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span
className="tab-dnd-label"
className={`tab-dnd-label${accentColor ? ' has-connection-accent' : ''}`}
onContextMenu={(e) => e.preventDefault()}
title={displayTitle}
style={labelStyle}
>
{displayTitle}
{accentColor ? <span className="tab-connection-accent" aria-hidden="true" /> : null}
<span className="tab-title-text">{displayTitle}</span>
</span>
</Dropdown>
);
@@ -183,6 +197,7 @@ const TabManager: React.FC = () => {
const items = useMemo(() => tabs.map((tab, index) => {
const connection = connections.find((conn) => conn.id === tab.connectionId);
const displayTitle = buildTabDisplayTitle(tab, connection);
const accentColor = connection ? resolveConnectionAccentColor(connection) : undefined;
const tabIsActive = tab.id === activeTabId;
let content;
if (tab.type === 'query') {
@@ -203,6 +218,16 @@ const TabManager: React.FC = () => {
content = <DefinitionViewer tab={tab} />;
} else if (tab.type === 'table-overview') {
content = <TableOverview tab={tab} />;
} else if (tab.type === 'jvm-overview') {
content = <JVMOverview tab={tab} />;
} else if (tab.type === 'jvm-resource') {
content = <JVMResourceBrowser tab={tab} />;
} else if (tab.type === 'jvm-audit') {
content = <JVMAuditViewer tab={tab} />;
} else if (tab.type === 'jvm-diagnostic') {
content = <JVMDiagnosticConsole tab={tab} />;
} else if (tab.type === 'jvm-monitoring') {
content = <JVMMonitoringDashboard tab={tab} />;
}
const menuItems: MenuProps['items'] = [
@@ -238,6 +263,7 @@ const TabManager: React.FC = () => {
<SortableTabLabel
displayTitle={displayTitle}
menuItems={menuItems}
accentColor={accentColor}
/>
),
key: tab.id,
@@ -302,8 +328,26 @@ const TabManager: React.FC = () => {
-webkit-user-select: none;
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
}
.main-tabs .tab-dnd-label.has-connection-accent {
position: relative;
}
.main-tabs .tab-connection-accent {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--connection-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--connection-accent) 22%, transparent);
flex: 0 0 auto;
}
.main-tabs .tab-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.main-tabs .tab-dnd-node.is-dragging,
.main-tabs .tab-dnd-node.is-dragging .tab-dnd-label {
cursor: grabbing !important;

View File

@@ -9,9 +9,20 @@ import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, Trigg
import { useStore } from '../store';
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils';
import { buildAlterTablePreviewSql, hasAlterTableDraftChanges } from './tableDesignerSchemaSql';
import { buildAlterTablePreviewSql, buildCreateTablePreviewSql, hasAlterTableDraftChanges } from './tableDesignerSchemaSql';
import TableDesignerSqlPreview from './TableDesignerSqlPreview';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import {
isMysqlFamilyDialect as isMysqlFamilySqlDialect,
isOracleLikeDialect as isOracleLikeSqlDialect,
isPgLikeDialect as isPgLikeSqlDialect,
isSqlServerDialect as isSqlServerSqlDialect,
quoteSqlIdentifierPart,
quoteSqlIdentifierPath,
resolveColumnTypeOptions,
resolveSqlDialect,
} from '../utils/sqlDialect';
interface EditableColumn extends ColumnDefinition {
_key: string;
@@ -540,6 +551,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
// Initial Columns Definition
useEffect(() => {
const columnTypeOptions = resolveColumnTypeOptions(getDbType());
const initialCols = [
{
title: '名',
@@ -556,7 +568,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
key: 'type',
width: 150,
render: (text: string, record: EditableColumn) => readOnly ? text : (
<AutoComplete options={DB_TYPE_OPTIONS[getDbType()] || COMMON_TYPES} value={text} onChange={val => handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />
<AutoComplete options={columnTypeOptions} value={text} onChange={val => handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />
)
},
{
@@ -636,7 +648,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
}])
];
setTableColumns(initialCols);
}, [readOnly]); // Re-create if readOnly changes
}, [connections, openCommentEditor, readOnly, tab.connectionId]); // Re-create when datasource dialect or readonly state changes
const flushResizeGhost = useCallback(() => {
resizeRafRef.current = null;
@@ -847,16 +859,9 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const getDbType = (): string => {
const conn = connections.find(c => c.id === tab.connectionId);
const type = normalizeDbType(String(conn?.config?.type || ''));
if (!type) return '';
if (type === 'custom') {
return inferDialectFromCustomDriver(String(conn?.config?.driver || ''));
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
const rawType = String(conn?.config?.type || '').trim();
if (!rawType) return '';
return resolveSqlDialect(rawType, String(conn?.config?.driver || ''));
};
const generateTriggerTemplate = (): string => {
@@ -865,6 +870,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
switch (dbType) {
case 'mysql':
case 'mariadb':
case 'diros':
return `CREATE TRIGGER trigger_name
BEFORE INSERT ON \`${tblName}\`
FOR EACH ROW
@@ -897,6 +904,7 @@ BEGIN
-- 触发器逻辑
END;`;
case 'oracle':
case 'dameng':
case 'dm':
return `CREATE OR REPLACE TRIGGER trigger_name
BEFORE INSERT ON "${tblName}"
@@ -922,6 +930,8 @@ END;`;
switch (dbType) {
case 'mysql':
case 'mariadb':
case 'diros':
return `DROP TRIGGER IF EXISTS \`${triggerName}\``;
case 'postgres':
case 'kingbase':
@@ -931,6 +941,7 @@ END;`;
case 'sqlserver':
return `DROP TRIGGER IF EXISTS [${triggerName}]`;
case 'oracle':
case 'dameng':
case 'dm':
return `DROP TRIGGER "${triggerName}"`;
case 'sqlite':
@@ -1334,36 +1345,20 @@ ${selectedTrigger.statement}`;
};
};
const isPgLikeDialect = (dbType: string): boolean =>
dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase';
const isOracleLikeDialect = (dbType: string): boolean => dbType === 'oracle' || dbType === 'dm';
const isSqlServerDialect = (dbType: string): boolean => dbType === 'sqlserver';
const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql';
const isPgLikeDialect = (dbType: string): boolean => isPgLikeSqlDialect(dbType);
const isOracleLikeDialect = (dbType: string): boolean => isOracleLikeSqlDialect(dbType);
const isSqlServerDialect = (dbType: string): boolean => isSqlServerSqlDialect(dbType);
const isMysqlLikeDialect = (dbType: string): boolean => isMysqlFamilySqlDialect(dbType);
const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb';
const lacksAlterForeignKeySupport = (dbType: string): boolean => dbType === 'sqlite' || dbType === 'duckdb' || dbType === 'tdengine';
const lacksTableCommentSupport = (dbType: string): boolean => dbType === 'sqlite';
const quoteIdentifierPartByDialect = (part: string, dbType: string): string => {
const ident = stripIdentifierQuotes(part);
if (!ident) return '';
if (isMysqlLikeDialect(dbType) || dbType === 'tdengine') {
return `\`${escapeBacktickIdentifier(ident)}\``;
}
if (isSqlServerDialect(dbType)) {
return `[${escapeBracketIdentifier(ident)}]`;
}
return `"${escapeDoubleQuoteIdentifier(ident)}"`;
return quoteSqlIdentifierPart(dbType, part);
};
const quoteIdentifierPathByDialect = (path: string, dbType: string): string => {
const raw = String(path || '').trim();
if (!raw) return '';
const parts = raw
.split('.')
.map(part => stripIdentifierQuotes(part))
.filter(Boolean);
if (parts.length === 0) return '';
return parts.map(part => quoteIdentifierPartByDialect(part, dbType)).join('.');
return quoteSqlIdentifierPath(dbType, path);
};
const resolveTableInfo = () => {
@@ -1481,19 +1476,13 @@ ${selectedTrigger.statement}`;
};
const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => {
const tableName = `\`${escapeBacktickIdentifier(targetTableName)}\``;
const colDefs = targetColumns.map(curr => {
let extra = curr.extra || "";
if (curr.isAutoIncrement && !extra.toLowerCase().includes('auto_increment')) {
extra += " AUTO_INCREMENT";
}
return `\`${escapeBacktickIdentifier(curr.name)}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${escapeSqlString(String(curr.default))}'` : ''} ${extra} COMMENT '${escapeSqlString(curr.comment || '')}'`;
return buildCreateTablePreviewSql({
dbType: getDbType(),
tableName: targetTableName,
columns: targetColumns,
charset: targetCharset,
collation: targetCollation,
});
const pks = targetColumns.filter(c => c.key === 'PRI').map(c => `\`${escapeBacktickIdentifier(c.name)}\``);
if (pks.length > 0) {
colDefs.push(`PRIMARY KEY (${pks.join(', ')})`);
}
return `CREATE TABLE ${tableName} (\n ${colDefs.join(",\n ")}\n) ENGINE=InnoDB DEFAULT CHARSET=${targetCharset} COLLATE=${targetCollation};`;
};
const openCopySelectedColumnsModal = () => {
@@ -3014,25 +3003,7 @@ END;`;
okText="执行"
cancelText="取消"
>
<div style={{ maxHeight: '400px', overflow: 'hidden', borderRadius: 8, border: darkMode ? '1px solid #333' : '1px solid #eee' }}>
<Editor
height="360px"
defaultLanguage="sql"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={previewSql}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
padding: { top: 8, bottom: 8 },
}}
/>
</div>
<TableDesignerSqlPreview sql={previewSql} darkMode={darkMode} />
<p style={{ marginTop: 10, color: '#faad14' }}> SQL</p>
</Modal>

View File

@@ -0,0 +1,187 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import TableDesignerSqlPreview, { resolveSqlChangeHighlights } from './TableDesignerSqlPreview';
const mockMonaco = {
Range: class {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
constructor(
startLineNumber: number,
startColumn: number,
endLineNumber: number,
endColumn: number,
) {
this.startLineNumber = startLineNumber;
this.startColumn = startColumn;
this.endLineNumber = endLineNumber;
this.endColumn = endColumn;
}
},
editor: {
defineTheme: vi.fn(),
},
};
const mockEditor = {
deltaDecorations: vi.fn(() => ['decoration-1']),
getModel: vi.fn(() => ({
getLineCount: () => 5,
getLineMaxColumn: (lineNumber: number) => (lineNumber === 1 ? 22 : 80),
})),
};
vi.mock('@monaco-editor/react', () => ({
default: ({
beforeMount,
defaultLanguage,
language,
onMount,
options,
theme,
value,
}: {
beforeMount?: (monaco: any) => void;
defaultLanguage?: string;
language?: string;
onMount?: (editor: any, monaco: any) => void;
options?: Record<string, any>;
theme?: string;
value?: string;
}) => {
beforeMount?.(mockMonaco);
onMount?.(mockEditor, mockMonaco);
return (
<div
data-default-language={defaultLanguage}
data-language={language}
data-monaco-editor-mock="true"
data-options={JSON.stringify(options)}
data-theme={theme}
>
{value}
</div>
);
},
}));
describe('TableDesignerSqlPreview', () => {
beforeEach(() => {
mockEditor.deltaDecorations.mockClear();
mockMonaco.editor.defineTheme.mockClear();
});
it('renders SQL changes in a read-only Monaco SQL editor with explicit syntax highlight theme', () => {
const markup = renderToStaticMarkup(
<TableDesignerSqlPreview
sql={'ALTER TABLE "users"\nRENAME COLUMN "name" TO "display_name";'}
darkMode={false}
/>,
);
expect(markup).toContain('data-table-designer-sql-preview="true"');
expect(markup).toContain('data-monaco-editor-mock="true"');
expect(markup).toContain('data-default-language="sql"');
expect(markup).toContain('data-language="sql"');
expect(markup).toContain('data-theme="gonavi-sql-preview-light"');
expect(markup).toContain('&quot;readOnly&quot;:true');
expect(markup).toContain('&quot;lineNumbers&quot;:&quot;on&quot;');
expect(markup).not.toContain('&quot;glyphMargin&quot;:true');
expect(markup).toContain('ALTER TABLE');
expect(markup).toContain('RENAME COLUMN');
expect(mockMonaco.editor.defineTheme).toHaveBeenCalledWith(
'gonavi-sql-preview-light',
expect.objectContaining({
base: 'vs',
inherit: true,
rules: expect.arrayContaining([
expect.objectContaining({ token: 'keyword', foreground: expect.any(String) }),
expect.objectContaining({ token: 'string', foreground: expect.any(String) }),
expect.objectContaining({ token: 'comment', foreground: expect.any(String) }),
]),
}),
);
});
it('detects only SQL change operation lines instead of highlighting the whole SQL block', () => {
const highlights = resolveSqlChangeHighlights([
'ALTER TABLE "users"',
'ADD COLUMN "age" int NULL;',
'ALTER TABLE "users"',
'RENAME COLUMN "name" TO "display_name";',
'-- DuckDB 不支持通过 COMMENT ON COLUMN 持久化字段备注',
].join('\n'));
expect(highlights).toEqual([
expect.objectContaining({ kind: 'add', lineNumber: 2 }),
expect.objectContaining({ kind: 'rename', lineNumber: 4 }),
]);
});
it('adds Monaco decorations to changed SQL lines only', () => {
renderToStaticMarkup(
<TableDesignerSqlPreview
sql={[
'ALTER TABLE "users"',
'ADD COLUMN "age" int NULL;',
'ALTER TABLE "users"',
'DROP COLUMN "legacy_name";',
].join('\n')}
/>,
);
expect(mockEditor.deltaDecorations).toHaveBeenCalledWith(
[],
expect.arrayContaining([
expect.objectContaining({
range: expect.objectContaining({ startLineNumber: 2, endLineNumber: 2 }),
options: expect.objectContaining({
className: expect.stringContaining('gonavi-sql-preview-change-line-add'),
isWholeLine: true,
linesDecorationsClassName: expect.stringContaining('gonavi-sql-preview-change-marker-add'),
}),
}),
expect.objectContaining({
range: expect.objectContaining({ startLineNumber: 4, endLineNumber: 4 }),
options: expect.objectContaining({
className: expect.stringContaining('gonavi-sql-preview-change-line-drop'),
isWholeLine: true,
linesDecorationsClassName: expect.stringContaining('gonavi-sql-preview-change-marker-drop'),
}),
}),
]),
);
const firstDecorationCall = mockEditor.deltaDecorations.mock.calls[0] as unknown as [unknown, unknown[]];
expect(firstDecorationCall[1]).toHaveLength(2);
expect(firstDecorationCall[1]).toEqual(
expect.arrayContaining([
expect.objectContaining({
options: expect.not.objectContaining({
glyphMarginClassName: expect.any(String),
}),
}),
]),
);
});
it('uses the dark SQL preview theme when dark mode is enabled', () => {
const markup = renderToStaticMarkup(
<TableDesignerSqlPreview sql="CREATE TABLE users (id int);" darkMode />,
);
expect(markup).toContain('data-theme="gonavi-sql-preview-dark"');
expect(mockMonaco.editor.defineTheme).toHaveBeenCalledWith(
'gonavi-sql-preview-dark',
expect.objectContaining({
base: 'vs-dark',
inherit: true,
}),
);
});
});

View File

@@ -0,0 +1,250 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react';
interface TableDesignerSqlPreviewProps {
sql: string;
darkMode?: boolean;
height?: string | number;
}
export type SqlChangeHighlightKind =
| 'add'
| 'comment'
| 'constraint'
| 'create'
| 'drop'
| 'modify'
| 'rename';
export interface SqlChangeHighlight {
line: string;
lineNumber: number;
kind: SqlChangeHighlightKind;
label: string;
}
const SQL_PREVIEW_LIGHT_THEME = 'gonavi-sql-preview-light';
const SQL_PREVIEW_DARK_THEME = 'gonavi-sql-preview-dark';
const CHANGE_LINE_RULES: Array<{
kind: SqlChangeHighlightKind;
label: string;
pattern: RegExp;
}> = [
{ kind: 'rename', label: '重命名变更', pattern: /\b(RENAME\s+COLUMN|CHANGE\s+COLUMN|RENAME\s+TO|SP_RENAME)\b/i },
{ kind: 'add', label: '新增变更', pattern: /\b(ADD\s+COLUMN|ADD\s+PRIMARY\s+KEY)\b/i },
{ kind: 'drop', label: '删除变更', pattern: /\b(DROP\s+COLUMN|DROP\s+PRIMARY\s+KEY)\b/i },
{ kind: 'modify', label: '字段属性变更', pattern: /\b(MODIFY\s+COLUMN|ALTER\s+COLUMN|SET\s+DATA\s+TYPE|SET\s+DEFAULT|DROP\s+DEFAULT|SET\s+NOT\s+NULL|DROP\s+NOT\s+NULL)\b/i },
{ kind: 'constraint', label: '约束变更', pattern: /\b(ADD\s+CONSTRAINT|DROP\s+CONSTRAINT)\b/i },
{ kind: 'comment', label: '备注变更', pattern: /\b(COMMENT\s+ON\s+COLUMN|COMMENT\s+ON\s+TABLE)\b/i },
];
const CREATE_TABLE_PATTERN = /^\s*CREATE\s+TABLE\b/i;
const getCreateTableLineHighlight = (line: string, lineNumber: number): SqlChangeHighlight | null => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('--')) return null;
return {
line,
lineNumber,
kind: 'create',
label: '新建表结构',
};
};
const getAlterLineHighlight = (line: string, lineNumber: number): SqlChangeHighlight | null => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('--')) return null;
const matchedRule = CHANGE_LINE_RULES.find((rule) => rule.pattern.test(trimmed));
if (!matchedRule) return null;
return {
line,
lineNumber,
kind: matchedRule.kind,
label: matchedRule.label,
};
};
export const resolveSqlChangeHighlights = (sql: string): SqlChangeHighlight[] => {
const lines = sql.split(/\r?\n/);
const isCreateTableSql = lines.some((line) => CREATE_TABLE_PATTERN.test(line));
return lines
.map((line, index) => (
isCreateTableSql
? getCreateTableLineHighlight(line, index + 1)
: getAlterLineHighlight(line, index + 1)
))
.filter((highlight): highlight is SqlChangeHighlight => Boolean(highlight));
};
const registerSqlPreviewThemes: BeforeMount = (monaco) => {
monaco.editor.defineTheme(SQL_PREVIEW_LIGHT_THEME, {
base: 'vs',
inherit: true,
rules: [
{ token: 'keyword', foreground: '006C9C', fontStyle: 'bold' },
{ token: 'operator', foreground: '8250DF' },
{ token: 'number', foreground: 'B45309' },
{ token: 'string', foreground: '15803D' },
{ token: 'comment', foreground: '64748B', fontStyle: 'italic' },
{ token: 'predefined', foreground: '0F766E' },
],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#0F172A0A',
'editorGutter.background': '#00000000',
'editorLineNumber.foreground': '#94A3B8',
},
});
monaco.editor.defineTheme(SQL_PREVIEW_DARK_THEME, {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: '7DD3FC', fontStyle: 'bold' },
{ token: 'operator', foreground: 'C4B5FD' },
{ token: 'number', foreground: 'FDBA74' },
{ token: 'string', foreground: '86EFAC' },
{ token: 'comment', foreground: '94A3B8', fontStyle: 'italic' },
{ token: 'predefined', foreground: '5EEAD4' },
],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#FFFFFF12',
'editorGutter.background': '#00000000',
'editorLineNumber.foreground': '#64748B',
},
});
};
const getLineDecorationClassName = (kind: SqlChangeHighlightKind): string =>
`gonavi-sql-preview-change-line gonavi-sql-preview-change-line-${kind}`;
const getLineDecorationMarkerClassName = (kind: SqlChangeHighlightKind): string =>
`gonavi-sql-preview-change-marker gonavi-sql-preview-change-marker-${kind}`;
const TableDesignerSqlPreview: React.FC<TableDesignerSqlPreviewProps> = ({
sql,
darkMode = false,
height = '360px',
}) => {
const decorationIdsRef = useRef<string[]>([]);
const editorRef = useRef<any>(null);
const monacoRef = useRef<any>(null);
const changeHighlights = useMemo(() => resolveSqlChangeHighlights(sql), [sql]);
const applyChangeDecorations = useCallback(() => {
const editor = editorRef.current;
const monaco = monacoRef.current;
const model = editor?.getModel?.();
if (!editor || !monaco || !model) return;
const lineCount = model.getLineCount();
const decorations = changeHighlights
.filter((highlight) => highlight.lineNumber <= lineCount)
.map((highlight) => {
const endColumn = Math.max(1, model.getLineMaxColumn(highlight.lineNumber));
return {
range: new monaco.Range(highlight.lineNumber, 1, highlight.lineNumber, endColumn),
options: {
className: getLineDecorationClassName(highlight.kind),
hoverMessage: { value: highlight.label },
isWholeLine: true,
linesDecorationsClassName: getLineDecorationMarkerClassName(highlight.kind),
},
};
});
decorationIdsRef.current = editor.deltaDecorations(decorationIdsRef.current, decorations);
}, [changeHighlights]);
const handleEditorMount: OnMount = (editor, monaco) => {
editorRef.current = editor;
monacoRef.current = monaco;
applyChangeDecorations();
};
useEffect(() => {
applyChangeDecorations();
}, [applyChangeDecorations, sql]);
return (
<div
data-table-designer-sql-preview="true"
style={{
maxHeight: 400,
overflow: 'hidden',
borderRadius: 8,
border: darkMode ? '1px solid #333' : '1px solid #eee',
}}
>
<style>
{`
.gonavi-sql-preview-change-line {
border-left: 3px solid transparent;
}
.gonavi-sql-preview-change-line-add,
.gonavi-sql-preview-change-line-create {
background: rgba(22, 163, 74, 0.14);
border-left-color: #16a34a;
}
.gonavi-sql-preview-change-line-drop {
background: rgba(220, 38, 38, 0.14);
border-left-color: #dc2626;
}
.gonavi-sql-preview-change-line-modify,
.gonavi-sql-preview-change-line-rename,
.gonavi-sql-preview-change-line-constraint,
.gonavi-sql-preview-change-line-comment {
background: rgba(217, 119, 6, 0.16);
border-left-color: #d97706;
}
.gonavi-sql-preview-change-marker {
width: 4px !important;
margin-left: 2px;
border-radius: 999px;
}
.gonavi-sql-preview-change-marker-add,
.gonavi-sql-preview-change-marker-create {
background: #16a34a;
}
.gonavi-sql-preview-change-marker-drop {
background: #dc2626;
}
.gonavi-sql-preview-change-marker-modify,
.gonavi-sql-preview-change-marker-rename,
.gonavi-sql-preview-change-marker-constraint,
.gonavi-sql-preview-change-marker-comment {
background: #d97706;
}
`}
</style>
<Editor
beforeMount={registerSqlPreviewThemes}
defaultLanguage="sql"
height={height}
language="sql"
onMount={handleEditorMount}
options={{
automaticLayout: true,
fontFamily: '"JetBrains Mono", "Cascadia Code", Consolas, monospace',
fontSize: 13,
lineNumbers: 'on',
lineDecorationsWidth: 14,
minimap: { enabled: false },
padding: { top: 8, bottom: 8 },
readOnly: true,
scrollBeyondLastLine: false,
wordWrap: 'on',
}}
theme={darkMode ? SQL_PREVIEW_DARK_THEME : SQL_PREVIEW_LIGHT_THEME}
value={sql}
/>
</div>
);
};
export default TableDesignerSqlPreview;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd';
import React, { useState, useEffect, useMemo, useCallback, useDeferredValue } from 'react';
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'antd';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
@@ -9,6 +9,14 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
import {
TABLE_OVERVIEW_RENDER_BATCH_SIZE,
buildTableOverviewSearchIndex,
filterAndSortTableOverviewRows,
resolveTableOverviewVisibleRows,
type TableOverviewSortField,
type TableOverviewSortOrder,
} from '../utils/tableOverviewFilter';
interface TableOverviewProps {
tab: TabData;
@@ -25,8 +33,8 @@ interface TableStatRow {
updateTime: string;
}
type SortField = 'name' | 'rows' | 'dataSize';
type SortOrder = 'asc' | 'desc';
type SortField = TableOverviewSortField;
type SortOrder = TableOverviewSortOrder;
type ViewMode = 'card' | 'list';
const formatSize = (bytes: number): string => {
@@ -166,6 +174,9 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const [sortField, setSortField] = useState<SortField>('name');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [visibleTableLimit, setVisibleTableLimit] = useState(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
const deferredSearchText = useDeferredValue(searchText);
const isSearchPending = searchText !== deferredSearchText;
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
const metadataDialect = useMemo(
@@ -207,21 +218,21 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
void loadData();
}, [autoFetchVisible, loadData]);
const sortedFiltered = useMemo(() => {
let list = [...tables];
if (searchText.trim()) {
const kw = searchText.trim().toLowerCase();
list = list.filter(t => t.name.toLowerCase().includes(kw) || t.comment.toLowerCase().includes(kw));
}
list.sort((a, b) => {
let cmp = 0;
if (sortField === 'name') cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
else if (sortField === 'rows') cmp = a.rows - b.rows;
else if (sortField === 'dataSize') cmp = a.dataSize - b.dataSize;
return sortOrder === 'asc' ? cmp : -cmp;
});
return list;
}, [tables, searchText, sortField, sortOrder]);
const tableSearchIndex = useMemo(() => buildTableOverviewSearchIndex(tables), [tables]);
const sortedFiltered = useMemo(() => (
filterAndSortTableOverviewRows(tableSearchIndex, deferredSearchText, sortField, sortOrder)
), [deferredSearchText, sortField, sortOrder, tableSearchIndex]);
useEffect(() => {
setVisibleTableLimit(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
}, [deferredSearchText, sortField, sortOrder, viewMode, tables]);
const visibleOverview = useMemo(() => (
resolveTableOverviewVisibleRows(sortedFiltered, visibleTableLimit)
), [sortedFiltered, visibleTableLimit]);
const visibleTables = visibleOverview.visibleRows;
const openTable = useCallback((tableName: string) => {
if (!connection) return;
@@ -397,11 +408,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
{ key: 'dataSize', label: `按大小${sortField === 'dataSize' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('dataSize') },
];
const totalRows = tables.reduce((s, t) => s + t.rows, 0);
const totalSize = tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0);
const maxCombinedSize = sortedFiltered.reduce((max, table) => {
const totalRows = useMemo(() => tables.reduce((s, t) => s + t.rows, 0), [tables]);
const totalSize = useMemo(() => tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0), [tables]);
const maxCombinedSize = useMemo(() => sortedFiltered.reduce((max, table) => {
return Math.max(max, table.dataSize + table.indexSize);
}, 0);
}, 0), [sortedFiltered]);
const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver);
if (loading) {
@@ -468,6 +479,31 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
{/* Content Area */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
{sortedFiltered.length > 0 && (isSearchPending || visibleOverview.hiddenCount > 0 || deferredSearchText.trim()) && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
marginBottom: 10,
padding: '8px 10px',
borderRadius: 10,
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.025)',
color: textMuted,
fontSize: 12,
}}
>
<span>
{isSearchPending
? '正在更新筛选结果...'
: `匹配 ${sortedFiltered.length} 张表,当前渲染 ${visibleTables.length}`}
</span>
{visibleOverview.hiddenCount > 0 && (
<span> {visibleOverview.hiddenCount} </span>
)}
</div>
)}
{sortedFiltered.length === 0 ? (
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
) : viewMode === 'card' ? (
@@ -477,7 +513,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}>
{sortedFiltered.map(t => (
{visibleTables.map(t => (
<Dropdown
key={t.name}
trigger={['contextMenu']}
@@ -556,7 +592,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
) : (
/* ========== 行视图 ========== */
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{sortedFiltered.map(t => {
{visibleTables.map(t => {
const combinedSize = t.dataSize + t.indexSize;
const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0;
const fillWidth = maxCombinedSize > 0 ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%';
@@ -695,6 +731,16 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
})}
</div>
)}
{sortedFiltered.length > 0 && visibleOverview.hiddenCount > 0 && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0 4px' }}>
<Button
size="small"
onClick={() => setVisibleTableLimit(limit => limit + TABLE_OVERVIEW_RENDER_BATCH_SIZE)}
>
{visibleOverview.hiddenCount}
</Button>
</div>
)}
</div>
</div>
);

View File

@@ -1,14 +1,20 @@
import React, { useState, useEffect, useRef } from 'react';
import { Tooltip, message } from 'antd';
import { Button, Tooltip, message } from 'antd';
import { UserOutlined, RobotOutlined, EditOutlined, ReloadOutlined, DeleteOutlined, CheckOutlined, CopyOutlined, PlayCircleOutlined, ApiOutlined, LoadingOutlined, CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import mermaid from 'mermaid';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { AIChatMessage, AIToolCall } from '../../types';
import type { AIChatMessage, AIToolCall } from '../../types';
import { useStore } from '../../store';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import { normalizeAiMarkdown } from '../../utils/aiMarkdown';
import { extractJVMChangePlan, resolveJVMAIPlanTargetTabId } from '../../utils/jvmAiPlan';
import {
parseJVMDiagnosticPlan,
resolveJVMDiagnosticPlanTargetTabId,
} from '../../utils/jvmDiagnosticPlan';
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
const remarkPlugins = [remarkGfm];
@@ -568,6 +574,18 @@ export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg
}
return { displayContent: content, parsedThinking: '' };
}, [msg.content, msg.thinking]);
const jvmPlan = React.useMemo(() => {
if (isUser) {
return null;
}
return extractJVMChangePlan(displayContent);
}, [displayContent, isUser]);
const jvmDiagnosticPlan = React.useMemo(() => {
if (isUser) {
return null;
}
return parseJVMDiagnosticPlan(displayContent);
}, [displayContent, isUser]);
const isTypingThinking = !!(msg.loading && msg.phase === 'thinking');
if (msg.role === 'tool') return null;
@@ -695,6 +713,77 @@ export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg
activeDbName={activeDbName}
/>
)}
{!isUser && jvmPlan && (
<div style={{ marginTop: 12 }}>
<Button
size="small"
type="primary"
onClick={() => {
const targetContext = msg.jvmPlanContext;
if (!targetContext) {
message.warning('这条 JVM 计划缺少来源页签上下文,请在目标 JVM 资源页重新生成。');
return;
}
const store = useStore.getState();
const targetTabId = resolveJVMAIPlanTargetTabId(store.tabs, targetContext);
if (!targetTabId) {
message.warning('未找到与该 JVM 计划匹配的资源页签,请先打开原目标资源后再应用。');
return;
}
window.dispatchEvent(new CustomEvent('gonavi:jvm-apply-ai-plan', {
detail: {
plan: jvmPlan,
targetTabId,
connectionId: targetContext.connectionId,
providerMode: targetContext.providerMode,
resourcePath: targetContext.resourcePath,
},
}));
}}
>
JVM
</Button>
</div>
)}
{!isUser && jvmDiagnosticPlan && (
<div style={{ marginTop: 12 }}>
<Button
size="small"
type="primary"
onClick={() => {
const targetContext = msg.jvmDiagnosticPlanContext;
if (!targetContext) {
message.warning('这条诊断计划缺少来源页签上下文,请在目标诊断控制台重新生成。');
return;
}
const store = useStore.getState();
const targetTabId = resolveJVMDiagnosticPlanTargetTabId(
store.tabs,
store.connections,
targetContext,
);
if (!targetTabId) {
message.warning('未找到与该诊断计划匹配的诊断控制台页签,请先打开原目标控制台后再应用。');
return;
}
window.dispatchEvent(new CustomEvent('gonavi:jvm-apply-diagnostic-plan', {
detail: {
plan: jvmDiagnosticPlan,
targetTabId,
connectionId: targetContext.connectionId,
transport: targetContext.transport,
},
}));
}}
>
</Button>
</div>
)}
{/* 错误原文复制按钮 */}
{!isUser && msg.rawError && (
<div style={{ marginTop: 8 }}>

View File

@@ -141,6 +141,33 @@ describe('buildCopyInsertSQL', () => {
});
});
it('uses Oracle date constructors when all-column DELETE matching includes DATE values', () => {
const result = buildCopyDeleteSQL({
dbType: 'oracle',
tableName: 'LZJ.RIJIE_TABLE',
orderedCols: ['NAME', 'CREATED_AT', 'STATUS', 'MEMO'],
allTableColumns: ['NAME', 'CREATED_AT', 'STATUS', 'MEMO'],
record: {
NAME: '张三',
CREATED_AT: '2026-04-26T08:30:00+08:00',
STATUS: 'DONE',
MEMO: null,
},
columnTypesByLowerName: {
name: 'NVARCHAR2',
created_at: 'DATE',
status: 'VARCHAR2',
memo: 'VARCHAR2',
},
});
expect(result).toEqual({
ok: true,
whereStrategy: 'all-columns',
sql: `DELETE FROM "LZJ"."RIJIE_TABLE" WHERE ("NAME" = '张三' AND "CREATED_AT" = TO_DATE('2026-04-26 08:30:00', 'YYYY-MM-DD HH24:MI:SS') AND "STATUS" = 'DONE' AND "MEMO" IS NULL);`,
});
});
it('refuses to build UPDATE/DELETE SQL when the result set lacks keys and does not cover all table columns', () => {
const result = buildCopyDeleteSQL({
dbType: 'mysql',

View File

@@ -1,5 +1,6 @@
import type { IndexDefinition } from '../types';
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
import { isOracleLikeDialect } from '../utils/sqlDialect';
type BuildCopyInsertSQLParams = {
dbType: string;
@@ -164,10 +165,36 @@ const toNormalizedLiteralText = (value: any, columnType?: string): string => {
return String(value);
};
const formatCopySqlLiteral = (value: any, columnType?: string): string => {
const formatOracleTemporalLiteral = (value: any, columnType?: string): string | null => {
if (!isTemporalColumnType(columnType)) {
return null;
}
const normalized = toNormalizedLiteralText(value, columnType);
const escaped = escapeLiteral(normalized);
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
return `TO_DATE('${escaped}', 'YYYY-MM-DD')`;
}
if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(normalized)) {
const compactOffset = normalized.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2');
return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', 'YYYY-MM-DD HH24:MI:SSTZH:TZM')`;
}
const rawType = String(columnType || '').toLowerCase();
if (rawType.includes('timestamp')) {
return `TO_TIMESTAMP('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
}
return `TO_DATE('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
};
const formatCopySqlLiteral = (value: any, columnType?: string, dbType = ''): string => {
if (value === null || value === undefined) {
return 'NULL';
}
if (isOracleLikeDialect(dbType)) {
const oracleTemporalLiteral = formatOracleTemporalLiteral(value, columnType);
if (oracleTemporalLiteral) {
return oracleTemporalLiteral;
}
}
return `'${escapeLiteral(toNormalizedLiteralText(value, columnType))}'`;
};
@@ -208,7 +235,7 @@ const buildWhereClauseForColumns = ({
predicates.push(`${quotedColumn} IS NULL`);
continue;
}
predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`);
predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`);
}
if (predicates.length === 0) {
return null;
@@ -283,7 +310,7 @@ export const buildCopyInsertSQL = ({
const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col));
const values = orderedCols.map((col) => {
const { value } = getRecordValue(record, col);
return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col));
return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col), dbType);
});
return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`;
@@ -341,7 +368,7 @@ const buildCopyMutationSQL = (
const assignments = normalizedOrderedCols.map((columnName) => {
const { value } = getRecordValue(record, columnName);
return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName))}`;
return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`;
});
return {

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { buildCopiedRowsForPaste, buildPastedRowsFromCopiedRows } from './dataGridRowClipboard';
const rowKeyField = '__gonavi_row_key__';
describe('dataGridRowClipboard', () => {
it('copies selected rows in selection order without the internal row key', () => {
const copiedRows = buildCopiedRowsForPaste({
rows: [
{ [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
{ [rowKeyField]: 'row-2', id: 2, name: 'beta', hidden_note: 'B' },
],
selectedRowKeys: ['row-2', 'row-1'],
columnNames: ['id', 'name', 'hidden_note'],
rowKeyField,
});
expect(copiedRows).toEqual([
{ id: 2, name: 'beta', hidden_note: 'B' },
{ id: 1, name: 'alpha', hidden_note: 'A' },
]);
});
it('builds pasted rows as new rows with fresh internal keys', () => {
const pastedRows = buildPastedRowsFromCopiedRows({
rows: [
{ id: 2, name: 'beta' },
{ id: 1, name: 'alpha' },
],
columnNames: ['id', 'name'],
rowKeyField,
createRowKey: (index) => `paste-${index}`,
});
expect(pastedRows).toEqual([
{ [rowKeyField]: 'paste-0', id: 2, name: 'beta' },
{ [rowKeyField]: 'paste-1', id: 1, name: 'alpha' },
]);
});
});

View File

@@ -0,0 +1,66 @@
export interface BuildCopiedRowsForPasteInput {
rows: Array<Record<string, any>>;
selectedRowKeys: any[];
columnNames: string[];
rowKeyField: string;
rowKeyToString?: (key: any) => string;
}
export interface BuildPastedRowsFromCopiedRowsInput {
rows: Array<Record<string, any>>;
columnNames: string[];
rowKeyField: string;
createRowKey: (index: number) => string;
}
const defaultRowKeyToString = (key: any): string => String(key);
const getCopyableColumnNames = (columnNames: string[], rowKeyField: string): string[] =>
columnNames.filter((columnName) => columnName !== rowKeyField);
const pickCopyableRowValues = (
row: Record<string, any>,
columnNames: string[],
rowKeyField: string,
): Record<string, any> => {
const next: Record<string, any> = {};
getCopyableColumnNames(columnNames, rowKeyField).forEach((columnName) => {
next[columnName] = row?.[columnName];
});
return next;
};
export const buildCopiedRowsForPaste = ({
rows,
selectedRowKeys,
columnNames,
rowKeyField,
rowKeyToString = defaultRowKeyToString,
}: BuildCopiedRowsForPasteInput): Array<Record<string, any>> => {
if (!Array.isArray(rows) || !Array.isArray(selectedRowKeys) || selectedRowKeys.length === 0) {
return [];
}
const rowsByKey = new Map<string, Record<string, any>>();
rows.forEach((row) => {
const rowKey = row?.[rowKeyField];
if (rowKey === undefined || rowKey === null) return;
rowsByKey.set(rowKeyToString(rowKey), row);
});
return selectedRowKeys
.map((selectedKey) => rowsByKey.get(rowKeyToString(selectedKey)))
.filter((row): row is Record<string, any> => Boolean(row))
.map((row) => pickCopyableRowValues(row, columnNames, rowKeyField));
};
export const buildPastedRowsFromCopiedRows = ({
rows,
columnNames,
rowKeyField,
createRowKey,
}: BuildPastedRowsFromCopiedRowsInput): Array<Record<string, any>> =>
rows.map((row, index) => ({
[rowKeyField]: createRowKey(index),
...pickCopyableRowValues(row, columnNames, rowKeyField),
}));

View File

@@ -0,0 +1,172 @@
import React, { useMemo } from "react";
import { Alert, Descriptions, Modal, Space, Tag, Typography } from "antd";
import type { JVMChangePreview } from "../../types";
import { formatJVMRiskLevelText } from "../../utils/jvmResourcePresentation";
const { Text } = Typography;
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
type JVMChangePreviewModalProps = {
open: boolean;
preview: JVMChangePreview | null;
applying?: boolean;
onCancel: () => void;
onConfirm: () => void;
};
const riskColorMap: Record<string, string> = {
low: "green",
medium: "orange",
high: "red",
};
const formatValue = (value: unknown): string => {
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const previewBlockStyle: React.CSSProperties = {
margin: 0,
padding: 12,
borderRadius: 8,
background: "rgba(0, 0, 0, 0.04)",
overflow: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: 280,
};
const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
open,
preview,
applying = false,
onCancel,
onConfirm,
}) => {
const summary = useMemo(() => {
if (!preview) {
return "暂无预览结果";
}
return preview.summary || "预览已生成";
}, [preview]);
return (
<Modal
title="JVM 变更预览"
open={open}
onCancel={onCancel}
onOk={onConfirm}
okText="确认执行"
cancelText="关闭"
okButtonProps={{ disabled: !preview?.allowed, loading: applying }}
width={880}
destroyOnClose
>
{!preview ? (
<Alert type="info" showIcon message="暂无预览结果" />
) : (
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Descriptions column={1} size="small" styles={DESCRIPTION_STYLES}>
<Descriptions.Item label="变更摘要">
<Space size={8} wrap>
<Text>{summary}</Text>
<Tag color={riskColorMap[preview.riskLevel] || "default"}>
{formatJVMRiskLevelText(preview.riskLevel)}
</Tag>
{preview.requiresConfirmation ? (
<Tag color="gold"></Tag>
) : null}
{preview.allowed ? (
<Tag color="green"></Tag>
) : (
<Tag color="red"></Tag>
)}
</Space>
</Descriptions.Item>
{preview.blockingReason ? (
<Descriptions.Item label="阻断原因">
<Text type="danger" style={{ whiteSpace: "pre-wrap" }}>
{preview.blockingReason}
</Text>
</Descriptions.Item>
) : null}
</Descriptions>
{!preview.allowed && preview.blockingReason ? (
<Alert
type="error"
showIcon
message="当前变更不可执行"
description={
<span style={{ whiteSpace: "pre-wrap" }}>
{preview.blockingReason}
</span>
}
/>
) : (
<Alert type="info" showIcon message={summary} />
)}
<div>
<Text strong style={{ display: "block", marginBottom: 8 }}>
</Text>
<Descriptions
column={1}
size="small"
styles={DESCRIPTION_STYLES}
style={{ marginBottom: 12 }}
>
<Descriptions.Item label="资源 ID">
{preview.before?.resourceId || "-"}
</Descriptions.Item>
<Descriptions.Item label="版本">
{preview.before?.version || "-"}
</Descriptions.Item>
<Descriptions.Item label="格式">
{preview.before?.format || "-"}
</Descriptions.Item>
</Descriptions>
<pre style={previewBlockStyle}>
{formatValue(preview.before?.value)}
</pre>
</div>
<div>
<Text strong style={{ display: "block", marginBottom: 8 }}>
</Text>
<Descriptions
column={1}
size="small"
styles={DESCRIPTION_STYLES}
style={{ marginBottom: 12 }}
>
<Descriptions.Item label="资源 ID">
{preview.after?.resourceId || "-"}
</Descriptions.Item>
<Descriptions.Item label="版本">
{preview.after?.version || "-"}
</Descriptions.Item>
<Descriptions.Item label="格式">
{preview.after?.format || "-"}
</Descriptions.Item>
</Descriptions>
<pre style={previewBlockStyle}>
{formatValue(preview.after?.value)}
</pre>
</div>
</Space>
)}
</Modal>
);
};
export default JVMChangePreviewModal;

View File

@@ -0,0 +1,67 @@
import React from "react";
import { Button, Card, Space, Tag, Typography } from "antd";
import {
formatJVMDiagnosticRiskLabel,
groupJVMDiagnosticPresets,
resolveJVMDiagnosticRiskColor,
type JVMDiagnosticCommandPreset,
} from "../../utils/jvmDiagnosticPresentation";
const { Text } = Typography;
type JVMCommandPresetBarProps = {
onSelectPreset: (preset: JVMDiagnosticCommandPreset) => void;
};
const JVMCommandPresetBar: React.FC<JVMCommandPresetBarProps> = ({
onSelectPreset,
}) => (
<div style={{ display: "grid", gap: 12 }}>
{groupJVMDiagnosticPresets().map((group) => (
<Card
key={group.category}
size="small"
title={group.label}
style={{ borderRadius: 14 }}
styles={{
header: { minHeight: 38, paddingInline: 12 },
body: { display: "grid", gap: 8, padding: 12 },
}}
>
{group.items.map((preset) => (
<div
key={preset.key}
style={{
display: "grid",
gap: 6,
padding: 10,
borderRadius: 12,
background: "rgba(127,127,127,0.06)",
}}
>
<Space size={8} wrap>
<Button
size="small"
type="text"
onClick={() => onSelectPreset(preset)}
style={{ paddingInline: 8, fontWeight: 700 }}
>
{preset.label}
</Button>
<Tag color={resolveJVMDiagnosticRiskColor(preset.riskLevel)}>
{formatJVMDiagnosticRiskLabel(preset.riskLevel)}
</Tag>
</Space>
<Text type="secondary">{preset.description}</Text>
<Text code style={{ width: "fit-content" }}>
{preset.command}
</Text>
</div>
))}
</Card>
))}
</div>
);
export default JVMCommandPresetBar;

View File

@@ -0,0 +1,97 @@
import React from "react";
import { Empty, List, Tag, Typography } from "antd";
import type {
JVMDiagnosticAuditRecord,
JVMDiagnosticSessionHandle,
} from "../../types";
import {
formatJVMDiagnosticCommandTypeLabel,
formatJVMDiagnosticRiskLabel,
formatJVMDiagnosticSourceLabel,
formatJVMDiagnosticPhaseLabel,
formatJVMDiagnosticTransportLabel,
} from "../../utils/jvmDiagnosticPresentation";
const { Text } = Typography;
type JVMDiagnosticHistoryProps = {
session?: JVMDiagnosticSessionHandle | null;
records?: JVMDiagnosticAuditRecord[];
showSession?: boolean;
maxHeight?: number;
};
const JVMDiagnosticHistory: React.FC<JVMDiagnosticHistoryProps> = ({
session,
records = [],
showSession = true,
maxHeight = 360,
}) => (
<div style={{ display: "grid", gap: 12 }}>
{showSession ? (
<div style={{ display: "grid", gap: 4 }}>
<Text strong></Text>
{session ? (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Tag color="blue">{session.sessionId}</Tag>
<Tag>{formatJVMDiagnosticTransportLabel(session.transport)}</Tag>
</div>
) : (
<Empty
description="尚未建立诊断会话"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</div>
) : null}
<div style={{ display: "grid", gap: 8 }}>
<Text strong></Text>
{records.length ? (
<div style={{ maxHeight, overflow: "auto", paddingRight: 4 }}>
<List
size="small"
dataSource={records}
renderItem={(record) => (
<List.Item
key={`${record.sessionId || "record"}-${record.commandId || record.command}-${record.timestamp}`}
>
<div style={{ display: "grid", gap: 4, width: "100%" }}>
<Text
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "SFMono-Regular, Menlo, Monaco, Consolas, monospace",
}}
>
{record.command}
</Text>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{record.status ? (
<Tag color="green">{formatJVMDiagnosticPhaseLabel(record.status)}</Tag>
) : null}
{record.riskLevel ? (
<Tag color="gold">{formatJVMDiagnosticRiskLabel(record.riskLevel)}</Tag>
) : null}
{record.commandType ? (
<Tag color="blue">{formatJVMDiagnosticCommandTypeLabel(record.commandType)}</Tag>
) : null}
{record.source ? <Tag>{formatJVMDiagnosticSourceLabel(record.source)}</Tag> : null}
</div>
<Text type="secondary">
{record.reason || "未填写诊断原因"}
</Text>
</div>
</List.Item>
)}
/>
</div>
) : (
<Empty description="尚无诊断历史" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
</div>
);
export default JVMDiagnosticHistory;

View File

@@ -0,0 +1,65 @@
import React from "react";
import { Empty, List, Tag, Typography } from "antd";
import type { JVMDiagnosticEventChunk } from "../../types";
import {
formatJVMDiagnosticChunkText,
formatJVMDiagnosticEventLabel,
formatJVMDiagnosticPhaseLabel,
} from "../../utils/jvmDiagnosticPresentation";
const { Text } = Typography;
type JVMDiagnosticOutputProps = {
chunks: JVMDiagnosticEventChunk[];
maxHeight?: number;
};
const JVMDiagnosticOutput: React.FC<JVMDiagnosticOutputProps> = ({
chunks,
maxHeight = 420,
}) => {
if (!chunks.length) {
return (
<Empty
description="暂无实时输出。命令执行后,这里会按时间顺序追加后端返回内容。"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
);
}
return (
<div style={{ maxHeight, overflow: "auto", paddingRight: 4 }}>
<List
size="small"
dataSource={chunks}
renderItem={(chunk, index) => (
<List.Item
key={`${chunk.sessionId}-${chunk.commandId || "chunk"}-${index}`}
>
<div style={{ display: "grid", gap: 4, width: "100%" }}>
<Text
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "SFMono-Regular, Menlo, Monaco, Consolas, monospace",
}}
>
{formatJVMDiagnosticChunkText(chunk)}
</Text>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{chunk.phase ? (
<Tag color="geekblue">{formatJVMDiagnosticPhaseLabel(chunk.phase)}</Tag>
) : null}
{chunk.event ? <Tag>{formatJVMDiagnosticEventLabel(chunk.event)}</Tag> : null}
{chunk.commandId ? <Tag color="blue">{chunk.commandId}</Tag> : null}
</div>
</div>
</List.Item>
)}
/>
</div>
);
};
export default JVMDiagnosticOutput;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { Tooltip } from 'antd';
import { resolveJVMModeMeta } from '../../utils/jvmRuntimePresentation';
type JVMModeBadgeProps = {
mode: string;
label?: string;
reason?: string;
};
const JVMModeBadge: React.FC<JVMModeBadgeProps> = ({
mode,
label,
reason,
}) => {
const meta = resolveJVMModeMeta(mode);
const displayLabel = String(label || meta.label || 'Unknown').trim() || 'Unknown';
const content = (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
}}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
height: 20,
padding: '0 8px',
borderRadius: 999,
fontSize: 12,
fontWeight: 600,
color: meta.color,
background: meta.backgroundColor,
flexShrink: 0,
}}
>
{displayLabel}
</span>
{reason ? (
<span
style={{
fontSize: 12,
color: '#cf1322',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{reason}
</span>
) : null}
</span>
);
if (!reason) {
return content;
}
return <Tooltip title={reason}>{content}</Tooltip>;
};
export default JVMModeBadge;

View File

@@ -0,0 +1,119 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import JVMMonitoringCharts from "./JVMMonitoringCharts";
vi.mock("recharts", () => {
const passthrough =
(tag: string) =>
({ children, name }: { children?: React.ReactNode; name?: string }) =>
React.createElement(tag, null, name ? <span>{name}</span> : children);
const svgChild =
({ name }: { name?: string }) =>
name ? <text>{name}</text> : <g />;
return {
Area: svgChild,
AreaChart: passthrough("svg"),
CartesianGrid: svgChild,
Legend: svgChild,
Line: svgChild,
LineChart: passthrough("svg"),
ResponsiveContainer: passthrough("div"),
Tooltip: svgChild,
XAxis: svgChild,
YAxis: svgChild,
};
});
describe("JVMMonitoringCharts", () => {
it("renders chart titles, empty text, and legends in Chinese", () => {
const emptyMarkup = renderToStaticMarkup(
<JVMMonitoringCharts
darkMode={false}
session={{
connectionId: "conn-1",
providerMode: "jmx",
running: false,
availableMetrics: [],
missingMetrics: [],
providerWarnings: [],
}}
points={[]}
/>,
);
expect(emptyMarkup).toContain("堆内存");
expect(emptyMarkup).toContain("暂无堆内存采样数据");
expect(emptyMarkup).not.toContain("暂无 Heap 采样数据");
const dataMarkup = renderToStaticMarkup(
<JVMMonitoringCharts
darkMode={false}
session={{
connectionId: "conn-1",
providerMode: "jmx",
running: true,
availableMetrics: [
"heap.used",
"gc.count",
"thread.count",
"class.loading",
],
missingMetrics: [],
providerWarnings: [],
}}
points={[
{
timestamp: 1713945600000,
heapUsedBytes: 64 * 1024 * 1024,
heapCommittedBytes: 128 * 1024 * 1024,
gcCollectionCount: 20,
gcCollectionTimeMs: 50,
threadCount: 33,
daemonThreadCount: 12,
peakThreadCount: 44,
loadedClassCount: 13282,
unloadedClassCount: 3,
},
]}
/>,
);
expect(dataMarkup).toContain("堆内存已使用");
expect(dataMarkup).toContain("堆内存已提交");
expect(dataMarkup).toContain("垃圾回收次数");
expect(dataMarkup).toContain("垃圾回收耗时(ms)");
expect(dataMarkup).toContain("线程数");
expect(dataMarkup).toContain("守护线程数");
expect(dataMarkup).toContain("线程峰值");
expect(dataMarkup).toContain("已加载类");
expect(dataMarkup).toContain("已卸载类");
expect(dataMarkup).not.toContain("Heap Used");
expect(dataMarkup).not.toContain("GC Count");
expect(dataMarkup).not.toContain("Threads");
expect(dataMarkup).not.toContain("ClassLoading");
});
it("uses relaxed card spacing so charts do not feel crowded", () => {
const markup = renderToStaticMarkup(
<JVMMonitoringCharts
darkMode={false}
session={{
connectionId: "conn-1",
providerMode: "jmx",
running: false,
availableMetrics: [],
missingMetrics: [],
providerWarnings: [],
}}
points={[]}
/>,
);
expect(markup).toContain("row-gap:24px");
expect(markup).toContain("height:380px");
expect(markup).toContain("padding:20px 22px 14px");
});
});

View File

@@ -0,0 +1,185 @@
import React from "react";
import { Card, Col, Empty, Row } from "antd";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip as RechartsTooltip,
XAxis,
YAxis,
} from "recharts";
import type { JVMMonitoringPoint, JVMMonitoringSessionState } from "../../types";
import {
buildMonitoringChartPoints,
formatCompactNumber,
formatMonitoringAxisBytes,
monitoringMetricAvailable,
} from "../../utils/jvmMonitoringPresentation";
type JVMMonitoringChartsProps = {
points: JVMMonitoringPoint[];
session: JVMMonitoringSessionState;
darkMode: boolean;
};
const buildCardStyle = (darkMode: boolean): React.CSSProperties => ({
borderRadius: 18,
height: 380,
background: darkMode ? "#1f1f1f" : "#ffffff",
boxShadow: "0 8px 28px rgba(15, 23, 42, 0.06)",
});
const chartMargin = { top: 18, right: 28, bottom: 26, left: 8 };
const axisTickStyle = (color: string) => ({ fill: color, fontSize: 11 });
const legendProps = {
iconSize: 8,
verticalAlign: "bottom" as const,
wrapperStyle: {
paddingTop: 14,
lineHeight: "22px",
},
};
const JVMMonitoringCharts: React.FC<JVMMonitoringChartsProps> = ({
points,
session,
darkMode,
}) => {
const data = buildMonitoringChartPoints(points);
const textColor = darkMode ? "rgba(255,255,255,0.72)" : "rgba(0,0,0,0.65)";
const gridColor = darkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
const tooltipStyle = {
backgroundColor: darkMode ? "#141414" : "#ffffff",
border: `1px solid ${gridColor}`,
borderRadius: 8,
};
const renderEmpty = (description: string) => (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={description}
style={{ marginTop: 96 }}
/>
);
const renderCard = (title: string, content: React.ReactNode) => (
<Card
variant="borderless"
title={title}
style={buildCardStyle(darkMode)}
styles={{ body: { height: 304, padding: "20px 22px 14px" } }}
>
{content}
</Card>
);
const hasData = data.length > 0;
return (
<Row gutter={[24, 24]}>
<Col xs={24} xl={12}>
{renderCard(
"堆内存",
!hasData
? renderEmpty("暂无堆内存采样数据")
: !monitoringMetricAvailable(session, "heap.used")
? renderEmpty("当前监控来源未提供堆内存指标")
: (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={chartMargin}>
<defs>
<linearGradient id="jvmHeapGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#fa8c16" stopOpacity={0.28} />
<stop offset="95%" stopColor="#fa8c16" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
<XAxis dataKey="timeLabel" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} minTickGap={32} />
<YAxis tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} tickFormatter={formatMonitoringAxisBytes} width={74} />
<RechartsTooltip contentStyle={tooltipStyle} />
<Legend {...legendProps} />
<Area type="monotone" dataKey="heapUsedBytes" name="堆内存已使用" stroke="#fa8c16" fill="url(#jvmHeapGradient)" isAnimationActive={false} />
<Line type="monotone" dataKey="heapCommittedBytes" name="堆内存已提交" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
),
)}
</Col>
<Col xs={24} xl={12}>
{renderCard(
"垃圾回收",
!hasData
? renderEmpty("暂无垃圾回收采样数据")
: !monitoringMetricAvailable(session, "gc.count")
? renderEmpty("当前监控来源未提供垃圾回收指标")
: (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={chartMargin}>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
<XAxis dataKey="timeLabel" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} minTickGap={32} />
<YAxis yAxisId="left" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} width={42} />
<YAxis yAxisId="right" orientation="right" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} width={42} />
<RechartsTooltip contentStyle={tooltipStyle} />
<Legend {...legendProps} />
<Line yAxisId="left" type="monotone" dataKey="gcCollectionCount" name="垃圾回收次数" stroke="#52c41a" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line yAxisId="right" type="monotone" dataKey="gcCollectionTimeMs" name="垃圾回收耗时(ms)" stroke="#722ed1" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
),
)}
</Col>
<Col xs={24} xl={12}>
{renderCard(
"线程",
!hasData
? renderEmpty("暂无线程采样数据")
: !monitoringMetricAvailable(session, "thread.count")
? renderEmpty("当前监控来源未提供线程指标")
: (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={chartMargin}>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
<XAxis dataKey="timeLabel" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} minTickGap={32} />
<YAxis tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} width={42} />
<RechartsTooltip contentStyle={tooltipStyle} />
<Legend {...legendProps} />
<Line type="monotone" dataKey="threadCount" name="线程数" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="daemonThreadCount" name="守护线程数" stroke="#13c2c2" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="peakThreadCount" name="线程峰值" stroke="#faad14" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
),
)}
</Col>
<Col xs={24} xl={12}>
{renderCard(
"类加载",
!hasData
? renderEmpty("暂无类加载采样数据")
: !monitoringMetricAvailable(session, "class.loading")
? renderEmpty("当前监控来源未提供类加载指标")
: (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={chartMargin}>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
<XAxis dataKey="timeLabel" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} minTickGap={32} />
<YAxis tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} tickFormatter={formatCompactNumber} width={58} />
<RechartsTooltip contentStyle={tooltipStyle} />
<Legend {...legendProps} />
<Line type="monotone" dataKey="loadedClassCount" name="已加载类" stroke="#eb2f96" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="unloadedClassCount" name="已卸载类" stroke="#8c8c8c" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
),
)}
</Col>
</Row>
);
};
export default JVMMonitoringCharts;

View File

@@ -0,0 +1,69 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { JVMMonitoringSessionState } from "../../types";
import JVMMonitoringDetailPanel from "./JVMMonitoringDetailPanel";
describe("JVMMonitoringDetailPanel", () => {
it("explains why process physical memory can be unavailable for JMX", () => {
const session: JVMMonitoringSessionState = {
connectionId: "conn-1",
providerMode: "jmx",
running: true,
missingMetrics: ["memory.rss"],
availableMetrics: ["memory.virtual"],
providerWarnings: [],
};
const markup = renderToStaticMarkup(
<JVMMonitoringDetailPanel
session={session}
latestPoint={{
timestamp: 1713945600000,
committedVirtualMemoryBytes: 385 * 1024 * 1024,
}}
darkMode={false}
/>,
);
expect(markup).toContain("进程物理内存");
expect(markup).toContain("JMX 连接未暴露进程驻留物理内存属性");
expect(markup).toContain("HTTP 端点或增强代理");
expect(markup).not.toContain("CommittedVirtualMemorySize");
expect(markup).not.toContain("Endpoint/Agent");
});
it("renders thread state names with Chinese semantic labels", () => {
const session: JVMMonitoringSessionState = {
connectionId: "conn-1",
providerMode: "jmx",
running: true,
missingMetrics: [],
availableMetrics: ["thread.states"],
providerWarnings: [],
};
const markup = renderToStaticMarkup(
<JVMMonitoringDetailPanel
session={session}
latestPoint={{
timestamp: 1713945600000,
threadStateCounts: {
WAITING: 12,
RUNNABLE: 11,
TIMED_WAITING: 10,
},
}}
darkMode={false}
/>,
);
expect(markup).toContain("等待中 12");
expect(markup).toContain("可运行 11");
expect(markup).toContain("限时等待 10");
expect(markup).not.toContain("WAITING 12");
expect(markup).not.toContain("RUNNABLE 11");
expect(markup).not.toContain("TIMED_WAITING 10");
});
});

View File

@@ -0,0 +1,154 @@
import React from "react";
import { Alert, Card, Descriptions, Empty, List, Space, Tag, Typography } from "antd";
import type { JVMMonitoringPoint, JVMMonitoringSessionState } from "../../types";
import {
buildMonitoringAvailabilityText,
extractThreadStateRows,
formatBytes,
formatCompactNumber,
formatPercent,
formatRecentGCLabel,
} from "../../utils/jvmMonitoringPresentation";
const { Paragraph, Text } = Typography;
type JVMMonitoringDetailPanelProps = {
session: JVMMonitoringSessionState;
latestPoint?: JVMMonitoringPoint;
darkMode: boolean;
};
const buildCardStyle = (darkMode: boolean): React.CSSProperties => ({
borderRadius: 12,
background: darkMode ? "#1f1f1f" : "#ffffff",
boxShadow: "0 1px 2px rgba(5, 5, 5, 0.06)",
});
const buildProcessMemoryMissingHint = (
session: JVMMonitoringSessionState,
): string | null => {
if (!(session.missingMetrics || []).includes("memory.rss")) {
return null;
}
if (session.providerMode === "jmx") {
return "JMX 连接未暴露进程驻留物理内存属性,当前只能读取进程虚拟内存指标;如需进程物理内存,请切换到 HTTP 端点或增强代理采集。";
}
return "当前监控来源未返回进程驻留物理内存指标;请确认 HTTP 端点或增强代理已采集并上报进程物理内存。";
};
const JVMMonitoringDetailPanel: React.FC<JVMMonitoringDetailPanelProps> = ({
session,
latestPoint,
darkMode,
}) => {
const threadRows = extractThreadStateRows(latestPoint);
const recentGcEvents = session.recentGcEvents || [];
const missingMetrics = session.missingMetrics || [];
const processMemoryMissingHint = buildProcessMemoryMissingHint(session);
return (
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Card variant="borderless" title="排障指标" style={buildCardStyle(darkMode)}>
<Descriptions column={1} size="small">
<Descriptions.Item label="进程 CPU">
{formatPercent(latestPoint?.processCpuLoad)}
</Descriptions.Item>
<Descriptions.Item label="系统 CPU">
{formatPercent(latestPoint?.systemCpuLoad)}
</Descriptions.Item>
<Descriptions.Item label="进程物理内存">
{formatBytes(latestPoint?.processRssBytes)}
</Descriptions.Item>
<Descriptions.Item label="进程虚拟内存">
{formatBytes(latestPoint?.committedVirtualMemoryBytes)}
</Descriptions.Item>
</Descriptions>
{processMemoryMissingHint ? (
<Alert
type="info"
showIcon
message="进程物理内存缺失原因"
description={processMemoryMissingHint}
style={{ marginTop: 12 }}
/>
) : null}
</Card>
<Card variant="borderless" title="线程状态分布" style={buildCardStyle(darkMode)}>
{threadRows.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无线程状态采样" />
) : (
<Space wrap size={[8, 8]}>
{threadRows.map((item) => (
<Tag key={item.state} color="blue">
{item.label} {formatCompactNumber(item.count)}
</Tag>
))}
</Space>
)}
</Card>
<Card variant="borderless" title="最近垃圾回收明细" style={buildCardStyle(darkMode)}>
{recentGcEvents.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
missingMetrics.includes("gc.events")
? "当前监控来源未提供事件级垃圾回收数据"
: "最近窗口暂无垃圾回收事件"
}
/>
) : (
<List
dataSource={recentGcEvents}
renderItem={(event) => (
<List.Item>
<List.Item.Meta
title={formatRecentGCLabel(event)}
description={
<Space size={12} wrap>
{typeof event.beforeUsedBytes === "number" ? (
<Text type="secondary">
{formatBytes(event.beforeUsedBytes)}
</Text>
) : null}
{typeof event.afterUsedBytes === "number" ? (
<Text type="secondary">
{formatBytes(event.afterUsedBytes)}
</Text>
) : null}
{event.action ? <Tag>{event.action}</Tag> : null}
</Space>
}
/>
</List.Item>
)}
/>
)}
</Card>
<Card variant="borderless" title="能力与降级" style={buildCardStyle(darkMode)}>
<Paragraph type="secondary" style={{ whiteSpace: "pre-wrap", marginBottom: 12 }}>
{buildMonitoringAvailabilityText(session)}
</Paragraph>
<Space size={[8, 8]} wrap>
{(session.missingMetrics || []).map((metric) => (
<Tag key={metric} color="warning">
{metric}
</Tag>
))}
{(session.providerWarnings || []).map((warning, index) => (
<Tag key={`${warning}-${index}`} color="default">
{warning}
</Tag>
))}
</Space>
</Card>
</Space>
);
};
export default JVMMonitoringDetailPanel;

View File

@@ -0,0 +1,47 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import JVMMonitoringStatusCards from "./JVMMonitoringStatusCards";
describe("JVMMonitoringStatusCards", () => {
it("renders monitoring summary labels in Chinese", () => {
const markup = renderToStaticMarkup(
<JVMMonitoringStatusCards
darkMode={false}
session={{
connectionId: "conn-1",
providerMode: "jmx",
running: true,
}}
latestPoint={{
timestamp: 1713945600000,
heapUsedBytes: 64 * 1024 * 1024,
heapCommittedBytes: 128 * 1024 * 1024,
gcCollectionCount: 20,
gcCollectionTimeMs: 50,
threadCount: 33,
peakThreadCount: 44,
threadStateCounts: {
RUNNABLE: 11,
},
loadedClassCount: 13282,
}}
/>,
);
expect(markup).toContain("堆内存");
expect(markup).toContain("已提交");
expect(markup).toContain("垃圾回收压力");
expect(markup).toContain("累计 50ms");
expect(markup).toContain("线程");
expect(markup).toContain("峰值 44");
expect(markup).toContain("可运行 11");
expect(markup).toContain("类加载");
expect(markup).not.toContain("Committed");
expect(markup).not.toContain("Total");
expect(markup).not.toContain("Peak");
expect(markup).not.toContain("RUNNABLE");
expect(markup).not.toContain("ClassLoading");
});
});

View File

@@ -0,0 +1,92 @@
import React from "react";
import { Card, Col, Row, Space, Statistic, Tag, Typography } from "antd";
import type { JVMMonitoringPoint, JVMMonitoringSessionState } from "../../types";
import {
formatBytes,
formatCompactNumber,
formatDurationMs,
resolveThreadStateLabel,
} from "../../utils/jvmMonitoringPresentation";
const { Text } = Typography;
type JVMMonitoringStatusCardsProps = {
latestPoint?: JVMMonitoringPoint;
session?: JVMMonitoringSessionState;
darkMode: boolean;
};
const cardStyle = (darkMode: boolean): React.CSSProperties => ({
borderRadius: 12,
background: darkMode ? "#1f1f1f" : "#ffffff",
boxShadow: "0 1px 2px rgba(5, 5, 5, 0.06)",
});
const JVMMonitoringStatusCards: React.FC<JVMMonitoringStatusCardsProps> = ({
latestPoint,
session,
darkMode,
}) => {
const runnableCount = latestPoint?.threadStateCounts?.RUNNABLE || 0;
const heapMeta =
latestPoint?.heapCommittedBytes && latestPoint.heapCommittedBytes > 0
? `已提交 ${formatBytes(latestPoint.heapCommittedBytes)}`
: "等待采样";
const gcMeta =
typeof latestPoint?.gcDeltaTimeMs === "number" && latestPoint.gcDeltaTimeMs >= 0
? `Δ ${formatDurationMs(latestPoint.gcDeltaTimeMs)}`
: typeof latestPoint?.gcCollectionTimeMs === "number"
? `累计 ${formatDurationMs(latestPoint.gcCollectionTimeMs)}`
: "等待采样";
const threadMeta =
latestPoint?.peakThreadCount && latestPoint.peakThreadCount > 0
? `峰值 ${formatCompactNumber(latestPoint.peakThreadCount)}`
: "等待采样";
const classMeta =
typeof latestPoint?.classLoadDelta === "number"
? `Δ ${formatCompactNumber(latestPoint.classLoadDelta)}`
: "等待采样";
const runnableLabel = resolveThreadStateLabel("RUNNABLE");
return (
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} xl={6}>
<Card variant="borderless" style={cardStyle(darkMode)} title="堆内存">
<Statistic value={formatBytes(latestPoint?.heapUsedBytes)} />
<Text type="secondary">{heapMeta}</Text>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card variant="borderless" style={cardStyle(darkMode)} title="垃圾回收压力">
<Statistic
value={formatCompactNumber(
latestPoint?.gcDeltaCount ?? latestPoint?.gcCollectionCount,
)}
/>
<Text type="secondary">{gcMeta}</Text>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card variant="borderless" style={cardStyle(darkMode)} title="线程">
<Statistic value={formatCompactNumber(latestPoint?.threadCount)} />
<Space size={8} wrap>
<Text type="secondary">{threadMeta}</Text>
{runnableCount > 0 ? <Tag color="blue">{runnableLabel} {runnableCount}</Tag> : null}
</Space>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card variant="borderless" style={cardStyle(darkMode)} title="类加载">
<Statistic value={formatCompactNumber(latestPoint?.loadedClassCount)} />
<Space size={8} wrap>
<Text type="secondary">{classMeta}</Text>
{session?.running ? <Tag color="green"></Tag> : <Tag></Tag>}
</Space>
</Card>
</Col>
</Row>
);
};
export default JVMMonitoringStatusCards;

View File

@@ -0,0 +1,128 @@
import React from "react";
import { Card, Typography } from "antd";
const { Paragraph, Text } = Typography;
type JVMWorkspaceShellProps = React.HTMLAttributes<HTMLDivElement> & {
darkMode?: boolean;
};
type JVMWorkspaceHeroProps = {
darkMode?: boolean;
eyebrow: string;
title: string;
description?: React.ReactNode;
badges?: React.ReactNode;
actions?: React.ReactNode;
};
export const getJVMWorkspaceCardStyle = (
darkMode?: boolean,
): React.CSSProperties => ({
borderRadius: 18,
boxShadow: darkMode
? "0 16px 38px rgba(0, 0, 0, 0.26)"
: "0 18px 44px rgba(24, 54, 96, 0.08)",
});
const getShellBackground = (darkMode?: boolean): string =>
darkMode
? "linear-gradient(135deg, #101820 0%, #141414 48%, #1f1f1f 100%)"
: "linear-gradient(135deg, #eef4ff 0%, #f7f9fc 45%, #ffffff 100%)";
const getHeroBackground = (darkMode?: boolean): string =>
darkMode
? "linear-gradient(135deg, rgba(22,119,255,0.22), rgba(82,196,26,0.08))"
: "linear-gradient(135deg, rgba(22,119,255,0.14), rgba(19,194,194,0.08))";
export const JVMWorkspaceShell: React.FC<JVMWorkspaceShellProps> = ({
children,
darkMode,
style,
...rest
}) => (
<div
{...rest}
data-jvm-workspace-shell="true"
style={{
height: "100%",
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
padding: 24,
display: "grid",
gap: 18,
alignContent: "start",
background: getShellBackground(darkMode),
...style,
}}
>
{children}
</div>
);
export const JVMWorkspaceHero: React.FC<JVMWorkspaceHeroProps> = ({
darkMode,
eyebrow,
title,
description,
badges,
actions,
}) => (
<Card
data-jvm-workspace-hero="true"
variant="borderless"
style={{
...getJVMWorkspaceCardStyle(darkMode),
background: getHeroBackground(darkMode),
border: darkMode
? "1px solid rgba(255,255,255,0.08)"
: "1px solid rgba(22,119,255,0.12)",
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 320px), 1fr))",
gap: 18,
alignItems: "center",
}}
>
<div style={{ minWidth: 0 }}>
<Text type="secondary">{eyebrow}</Text>
<Typography.Title level={3} style={{ margin: "4px 0 8px" }}>
{title}
</Typography.Title>
{description ? (
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
{description}
</Paragraph>
) : null}
{badges ? (
<div
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
marginTop: 14,
}}
>
{badges}
</div>
) : null}
</div>
{actions ? (
<div
style={{
display: "flex",
gap: 10,
flexWrap: "wrap",
justifyContent: "flex-end",
}}
>
{actions}
</div>
) : null}
</div>
</Card>
);

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
buildCreateTablePreviewSql,
buildAlterTablePreviewSql,
hasAlterTableDraftChanges,
type BuildAlterTablePreviewInput,
@@ -76,4 +77,140 @@ describe('tableDesignerSchemaSql', () => {
expect(sql).toContain('FIRST');
expect(sql).not.toContain('MODIFY COLUMN `display_name`');
});
it('builds oracle alter preview with oracle rename and modify syntax', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'oracle',
tableName: 'HR.EMPLOYEES',
originalColumns: [
baseColumn({ _key: 'name', name: 'NAME', type: 'VARCHAR2(64)', nullable: 'YES', comment: '旧名称' }),
],
columns: [
baseColumn({
_key: 'name',
name: 'DISPLAY_NAME',
type: 'VARCHAR2(128)',
nullable: 'NO',
default: 'guest',
comment: '显示名',
}),
],
}));
expect(sql).toContain('ALTER TABLE "HR"."EMPLOYEES"\nRENAME COLUMN "NAME" TO "DISPLAY_NAME";');
expect(sql).toContain(`ALTER TABLE "HR"."EMPLOYEES"\nMODIFY ("DISPLAY_NAME" VARCHAR2(128) DEFAULT 'guest' NOT NULL);`);
expect(sql).toContain(`COMMENT ON COLUMN "HR"."EMPLOYEES"."DISPLAY_NAME" IS '显示名';`);
expect(sql).not.toContain('`');
expect(sql).not.toContain('CHANGE COLUMN');
expect(sql).not.toContain('AUTO_INCREMENT');
});
it('builds sqlserver alter preview with sp_rename and alter column syntax', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'sqlserver',
tableName: 'dbo.Users',
originalColumns: [
baseColumn({ _key: 'name', name: 'name', type: 'nvarchar(64)', nullable: 'YES' }),
],
columns: [
baseColumn({ _key: 'name', name: 'display_name', type: 'nvarchar(128)', nullable: 'NO' }),
],
}));
expect(sql).toContain(`EXEC sp_rename 'dbo.Users.name', 'display_name', 'COLUMN';`);
expect(sql).toContain('ALTER TABLE [dbo].[Users]\nALTER COLUMN [display_name] nvarchar(128) NOT NULL;');
expect(sql).not.toContain('CHANGE COLUMN');
expect(sql).not.toContain('MODIFY COLUMN');
expect(sql).not.toContain('`');
});
it('keeps sqlite alter preview limited to sqlite-supported operations', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'sqlite',
tableName: 'users',
originalColumns: [
baseColumn({ _key: 'name', name: 'name', type: 'TEXT', nullable: 'YES' }),
],
columns: [
baseColumn({ _key: 'name', name: 'display_name', type: 'INTEGER', nullable: 'NO' }),
],
}));
expect(sql).toContain('ALTER TABLE "users"\nRENAME COLUMN "name" TO "display_name";');
expect(sql).toContain('-- SQLite 不支持直接修改字段属性');
expect(sql).not.toContain('CHANGE COLUMN');
expect(sql).not.toContain('MODIFY COLUMN');
expect(sql).not.toContain('AFTER');
});
it('builds duckdb alter preview without mysql-only syntax', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'duckdb',
tableName: 'main.users',
originalColumns: [
baseColumn({ _key: 'score', name: 'score', type: 'INTEGER', nullable: 'YES', default: '0' }),
],
columns: [
baseColumn({ _key: 'score', name: 'score', type: 'BIGINT', nullable: 'NO', default: '1' }),
],
}));
expect(sql).toContain('ALTER TABLE "main"."users"\nALTER COLUMN "score" SET DATA TYPE BIGINT;');
expect(sql).toContain('ALTER TABLE "main"."users"\nALTER COLUMN "score" SET DEFAULT 1;');
expect(sql).toContain('ALTER TABLE "main"."users"\nALTER COLUMN "score" SET NOT NULL;');
expect(sql).not.toContain('CHANGE COLUMN');
expect(sql).not.toContain('MODIFY COLUMN');
});
it('uses native limited alter syntax for clickhouse and tdengine instead of mysql syntax', () => {
const clickhouseSql = buildAlterTablePreviewSql(buildInput({
dbType: 'clickhouse',
tableName: 'events',
originalColumns: [baseColumn({ _key: 'name', name: 'name', type: 'String', nullable: 'YES' })],
columns: [baseColumn({ _key: 'name', name: 'display_name', type: 'String', nullable: 'YES' })],
}));
const tdengineSql = buildAlterTablePreviewSql(buildInput({
dbType: 'tdengine',
tableName: 'meters',
originalColumns: [baseColumn({ _key: 'value', name: 'value', type: 'FLOAT', nullable: 'YES' })],
columns: [baseColumn({ _key: 'value', name: 'value', type: 'DOUBLE', nullable: 'YES' })],
}));
expect(clickhouseSql).toContain('ALTER TABLE `events`\nRENAME COLUMN `name` TO `display_name`;');
expect(tdengineSql).toContain('ALTER TABLE `meters`\nMODIFY COLUMN `value` DOUBLE;');
expect(clickhouseSql).not.toContain('CHANGE COLUMN');
expect(tdengineSql).not.toContain('CHANGE COLUMN');
expect(clickhouseSql).not.toContain('AFTER');
expect(tdengineSql).not.toContain('AFTER');
});
it('treats mariadb doris and sphinx as mysql-family only where mysql syntax is intended', () => {
for (const dbType of ['mariadb', 'diros', 'sphinx']) {
const sql = buildAlterTablePreviewSql(buildInput({ dbType }));
expect(sql).toContain('ALTER TABLE `users`');
expect(sql).toContain('ADD COLUMN `age` int NULL');
}
});
it('builds oracle create table preview without mysql table options', () => {
const sql = buildCreateTablePreviewSql({
dbType: 'oracle',
tableName: 'HR.EMPLOYEES',
charset: 'utf8mb4',
collation: 'utf8mb4_unicode_ci',
columns: [
baseColumn({ _key: 'id', name: 'ID', type: 'NUMBER(10)', nullable: 'NO', key: 'PRI', isAutoIncrement: true }),
baseColumn({ _key: 'name', name: 'NAME', type: 'VARCHAR2(255)', nullable: 'YES', comment: '姓名' }),
],
});
expect(sql).toContain('CREATE TABLE "HR"."EMPLOYEES"');
expect(sql).toContain('"ID" NUMBER(10) GENERATED BY DEFAULT AS IDENTITY NOT NULL');
expect(sql).toContain('PRIMARY KEY ("ID")');
expect(sql).toContain(`COMMENT ON COLUMN "HR"."EMPLOYEES"."NAME" IS '姓名';`);
expect(sql).not.toContain('ENGINE=InnoDB');
expect(sql).not.toContain('DEFAULT CHARSET');
expect(sql).not.toContain('AUTO_INCREMENT');
expect(sql).not.toContain('`');
});
});

View File

@@ -1,3 +1,16 @@
import {
isBacktickIdentifierDialect,
isMysqlFamilyDialect,
isOracleLikeDialect,
isPgLikeDialect,
isSqlServerDialect,
quoteSqlIdentifierPart,
quoteSqlIdentifierPath,
resolveSqlDialect,
unquoteSqlIdentifierPart,
unquoteSqlIdentifierPath,
} from '../utils/sqlDialect';
export interface EditableColumnSnapshot {
_key: string;
name: string;
@@ -17,21 +30,17 @@ export interface BuildAlterTablePreviewInput {
columns: EditableColumnSnapshot[];
}
const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''");
const escapeBacktickIdentifier = (value: string) => String(value || '').replace(/`/g, '``');
const escapeDoubleQuoteIdentifier = (value: string) => String(value || '').replace(/"/g, '""');
export interface BuildCreateTablePreviewInput {
dbType: string;
tableName: string;
columns: EditableColumnSnapshot[];
charset?: string;
collation?: string;
}
const stripIdentifierQuotes = (part: string): string => {
const text = String(part || '').trim();
if (!text) return '';
if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1).trim();
}
if (text.startsWith('[') && text.endsWith(']')) {
return text.slice(1, -1).replace(/]]/g, ']').trim();
}
return text;
};
const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''");
const stripIdentifierQuotes = unquoteSqlIdentifierPart;
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = String(qualifiedName || '').trim();
@@ -44,117 +53,158 @@ const splitQualifiedName = (qualifiedName: string): { schemaName: string; object
};
};
const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql';
const isPgLikeDialect = (dbType: string): boolean =>
dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase';
const quoteIdentifierPart = (part: string, dbType: string): string => quoteSqlIdentifierPart(dbType, part);
const needsPgLikeQuote = (ident: string): boolean => !/^[a-z_][a-z0-9_]*$/.test(ident);
const quoteIdentifierPath = (path: string, dbType: string): string => quoteSqlIdentifierPath(dbType, path);
const quoteIdentifierPart = (part: string, dbType: string): string => {
const ident = stripIdentifierQuotes(part);
if (!ident) return '';
if (isMysqlLikeDialect(dbType)) {
return `\`${escapeBacktickIdentifier(ident)}\``;
}
if (isPgLikeDialect(dbType)) {
if (!needsPgLikeQuote(ident)) {
return ident;
}
return `"${escapeDoubleQuoteIdentifier(ident)}"`;
}
return ident;
const normalizeDefaultText = (value: unknown): string => String(value ?? '').trim();
const isKnownDefaultExpression = (trimmed: string): boolean => {
if (!trimmed) return false;
if (/^N?'.*'$/i.test(trimmed)) return true;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return true;
if (/^(true|false|null)$/i.test(trimmed)) return true;
if (/^(current_timestamp|current_date|current_time|localtimestamp|sysdate|systimestamp)$/i.test(trimmed)) return true;
if (/^(now|uuid|newid|sysdatetime)\s*\(\s*\)$/i.test(trimmed)) return true;
if (/^nextval\s*\(/i.test(trimmed) || /::/.test(trimmed)) return true;
return false;
};
const quoteIdentifierPath = (path: string, dbType: string): string =>
String(path || '')
.trim()
.split('.')
.map((part) => stripIdentifierQuotes(part))
.filter(Boolean)
.map((part) => quoteIdentifierPart(part, dbType))
.join('.');
const formatPgLikeDefault = (value: string): string => {
const trimmed = String(value || '').trim();
const formatDefaultExpression = (value: unknown, dbType: string): string => {
const trimmed = normalizeDefaultText(value);
if (!trimmed) return '';
if (/^'.*'$/.test(trimmed)) return trimmed;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
if (/^(true|false|null)$/i.test(trimmed)) return trimmed.toUpperCase() === 'NULL' ? 'NULL' : trimmed.toUpperCase();
if (/^(current_timestamp|current_date|current_time)$/i.test(trimmed)) return trimmed.toUpperCase();
if (/^nextval\s*\(/i.test(trimmed) || /::/.test(trimmed)) return trimmed;
return `'${escapeSqlString(trimmed)}'`;
if (isKnownDefaultExpression(trimmed)) {
if (/^(true|false|null)$/i.test(trimmed)) return trimmed.toUpperCase();
if (/^(current_timestamp|current_date|current_time|localtimestamp|sysdate|systimestamp)$/i.test(trimmed)) {
return trimmed.toUpperCase();
}
return trimmed;
}
const prefix = isSqlServerDialect(dbType) ? 'N' : '';
return `${prefix}'${escapeSqlString(trimmed)}'`;
};
const buildMySqlColumnDefinition = (column: EditableColumnSnapshot): string => {
let extra = String(column.extra || '');
const buildDefaultSql = (value: unknown, dbType: string): string => {
const defaultValue = normalizeDefaultText(value);
if (!defaultValue) return '';
return `DEFAULT ${formatDefaultExpression(defaultValue, dbType)}`;
};
const definitionChanged = (curr: EditableColumnSnapshot, orig: EditableColumnSnapshot): boolean => (
curr.type !== orig.type ||
curr.nullable !== orig.nullable ||
normalizeDefaultText(curr.default) !== normalizeDefaultText(orig.default) ||
(curr.comment || '') !== (orig.comment || '') ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
);
const physicalDefinitionChanged = (curr: EditableColumnSnapshot, orig: EditableColumnSnapshot): boolean => (
curr.type !== orig.type ||
curr.nullable !== orig.nullable ||
normalizeDefaultText(curr.default) !== normalizeDefaultText(orig.default) ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
);
const buildMySqlColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => {
let extra = String(column.extra || '').trim();
if (column.isAutoIncrement) {
if (!extra.toLowerCase().includes('auto_increment')) {
extra += ' AUTO_INCREMENT';
extra = `${extra} AUTO_INCREMENT`.trim();
}
} else {
extra = extra.replace(/auto_increment/gi, '').trim();
}
const defaultSql = column.default ? `DEFAULT '${escapeSqlString(String(column.default))}'` : '';
return `${quoteIdentifierPart(column.name, 'mysql')} ${column.type} ${column.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${defaultSql} ${extra} COMMENT '${escapeSqlString(column.comment || '')}'`.replace(/\s+/g, ' ').trim();
const defaultSql = buildDefaultSql(column.default, dbType);
return [
quoteIdentifierPart(column.name, dbType),
String(column.type || '').trim(),
column.nullable === 'NO' ? 'NOT NULL' : 'NULL',
defaultSql,
extra,
`COMMENT '${escapeSqlString(column.comment || '')}'`,
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
};
const buildPgLikeColumnDefinition = (column: EditableColumnSnapshot): string => {
const parts = [quoteIdentifierPart(column.name, 'postgres'), String(column.type || '').trim()];
const defaultValue = String(column.default || '').trim();
if (defaultValue) {
parts.push(`DEFAULT ${formatPgLikeDefault(defaultValue)}`);
const buildStandardColumnDefinition = (
column: EditableColumnSnapshot,
dbType: string,
options: { includeNull?: boolean; includeIdentity?: boolean } = {},
): string => {
const parts = [quoteIdentifierPart(column.name, dbType), String(column.type || '').trim()];
if (options.includeIdentity && column.isAutoIncrement) {
if (isSqlServerDialect(dbType)) {
parts.push('IDENTITY(1,1)');
} else if (isOracleLikeDialect(dbType)) {
parts.push('GENERATED BY DEFAULT AS IDENTITY');
}
}
const defaultSql = buildDefaultSql(column.default, dbType);
if (defaultSql) parts.push(defaultSql);
if (column.nullable === 'NO') {
parts.push('NOT NULL');
} else if (options.includeNull) {
parts.push('NULL');
}
return parts.filter(Boolean).join(' ').trim();
};
const buildPgLikeColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => {
const parts = [quoteIdentifierPart(column.name, dbType), String(column.type || '').trim()];
const defaultSql = buildDefaultSql(column.default, dbType);
if (defaultSql) parts.push(defaultSql);
if (column.nullable === 'NO') parts.push('NOT NULL');
return parts.join(' ').trim();
};
const buildPgLikeCommentSql = (tableRef: string, columnName: string, comment: string): string => {
const columnRef = `${tableRef}.${quoteIdentifierPart(columnName, 'postgres')}`;
const buildColumnCommentSql = (tableRef: string, columnName: string, comment: string, dbType: string): string => {
const columnRef = `${tableRef}.${quoteIdentifierPart(columnName, dbType)}`;
const trimmed = String(comment || '').trim();
if (!trimmed) {
if (!trimmed && isPgLikeDialect(dbType)) {
return `COMMENT ON COLUMN ${columnRef} IS NULL;`;
}
return `COMMENT ON COLUMN ${columnRef} IS '${escapeSqlString(trimmed)}';`;
};
const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const tableName = quoteIdentifierPath(input.tableName, 'mysql');
const buildSqlServerColumnCommentSql = (
tableName: string,
columnName: string,
comment: string,
): string => {
const { schemaName, objectName } = splitQualifiedName(tableName);
const schema = escapeSqlString(schemaName || 'dbo');
const table = escapeSqlString(objectName || tableName);
const column = escapeSqlString(columnName);
const value = escapeSqlString(comment || '');
return `IF EXISTS (SELECT 1 FROM sys.extended_properties ep JOIN sys.tables t ON ep.major_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id JOIN sys.columns c ON ep.major_id = c.object_id AND ep.minor_id = c.column_id WHERE ep.name = N'MS_Description' AND s.name = N'${schema}' AND t.name = N'${table}' AND c.name = N'${column}') BEGIN EXEC sp_updateextendedproperty @name = N'MS_Description', @value = N'${value}', @level0type = N'SCHEMA', @level0name = N'${schema}', @level1type = N'TABLE', @level1name = N'${table}', @level2type = N'COLUMN', @level2name = N'${column}' END ELSE BEGIN EXEC sp_addextendedproperty @name = N'MS_Description', @value = N'${value}', @level0type = N'SCHEMA', @level0name = N'${schema}', @level1type = N'TABLE', @level1name = N'${table}', @level2type = N'COLUMN', @level2name = N'${column}' END;`;
};
const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
const tableName = quoteIdentifierPath(input.tableName, dbType);
const alters: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
alters.push(`DROP COLUMN ${quoteIdentifierPart(orig.name, 'mysql')}`);
alters.push(`DROP COLUMN ${quoteIdentifierPart(orig.name, dbType)}`);
}
});
input.columns.forEach((curr, index) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
const prevCol = index > 0 ? input.columns[index - 1] : null;
const positionSql = prevCol ? `AFTER ${quoteIdentifierPart(prevCol.name, 'mysql')}` : 'FIRST';
const colDef = buildMySqlColumnDefinition(curr);
const positionSql = prevCol ? `AFTER ${quoteIdentifierPart(prevCol.name, dbType)}` : 'FIRST';
const colDef = buildMySqlColumnDefinition(curr, dbType);
if (!orig) {
alters.push(`ADD COLUMN ${colDef} ${positionSql}`.trim());
return;
}
const definitionChanged =
curr.type !== orig.type ||
curr.nullable !== orig.nullable ||
curr.default !== orig.default ||
(curr.comment || '') !== (orig.comment || '') ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement);
if (curr.name !== orig.name) {
alters.push(
`CHANGE COLUMN ${quoteIdentifierPart(orig.name, 'mysql')} ${colDef} ${positionSql}`.trim(),
);
alters.push(`CHANGE COLUMN ${quoteIdentifierPart(orig.name, dbType)} ${colDef} ${positionSql}`.trim());
return;
}
if (definitionChanged) {
if (definitionChanged(curr, orig)) {
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`.trim());
}
});
@@ -163,74 +213,65 @@ const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string =
const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key);
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
if (keysChanged) {
if (origPKKeys.length > 0) {
alters.push('DROP PRIMARY KEY');
}
if (origPKKeys.length > 0) alters.push('DROP PRIMARY KEY');
if (newPKKeys.length > 0) {
const pkNames = input.columns
.filter((col) => col.key === 'PRI')
.map((col) => quoteIdentifierPart(col.name, 'mysql'))
.map((col) => quoteIdentifierPart(col.name, dbType))
.join(', ');
alters.push(`ADD PRIMARY KEY (${pkNames})`);
}
}
if (alters.length === 0) {
return '';
}
return `ALTER TABLE ${tableName}\n${alters.join(',\n')};`;
return alters.length === 0 ? '' : `ALTER TABLE ${tableName}\n${alters.join(',\n')};`;
};
const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
const tableParts = splitQualifiedName(input.tableName);
const baseTableName = tableParts.objectName || stripIdentifierQuotes(input.tableName);
const tableRef = quoteIdentifierPath(input.tableName, 'postgres');
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, 'postgres')};`);
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildPgLikeColumnDefinition(curr)};`);
if (String(curr.comment || '').trim()) {
statements.push(buildPgLikeCommentSql(tableRef, curr.name, curr.comment || ''));
}
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildPgLikeColumnDefinition(curr, dbType)};`);
if (String(curr.comment || '').trim()) statements.push(buildColumnCommentSql(tableRef, curr.name, curr.comment || '', dbType));
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, 'postgres')} TO ${quoteIdentifierPart(curr.name, 'postgres')};`);
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (curr.type !== orig.type) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} TYPE ${curr.type};`);
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} TYPE ${curr.type};`);
}
const currDefault = String(curr.default || '').trim();
const origDefault = String(orig.default || '').trim();
const currDefault = normalizeDefaultText(curr.default);
const origDefault = normalizeDefaultText(orig.default);
if (currDefault !== origDefault) {
if (currDefault) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} SET DEFAULT ${formatPgLikeDefault(currDefault)};`);
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} SET DEFAULT ${formatDefaultExpression(currDefault, dbType)};`);
} else {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} DROP DEFAULT;`);
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} DROP DEFAULT;`);
}
}
if (curr.nullable !== orig.nullable) {
statements.push(
`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, 'postgres')} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`,
);
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`);
}
if ((curr.comment || '') !== (orig.comment || '')) {
statements.push(buildPgLikeCommentSql(tableRef, currentName, curr.comment || ''));
statements.push(buildColumnCommentSql(tableRef, currentName, curr.comment || '', dbType));
}
});
@@ -239,12 +280,12 @@ const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
if (keysChanged) {
if (origPKKeys.length > 0) {
statements.push(`ALTER TABLE ${tableRef}\nDROP CONSTRAINT IF EXISTS ${quoteIdentifierPart(`${baseTableName}_pkey`, 'postgres')};`);
statements.push(`ALTER TABLE ${tableRef}\nDROP CONSTRAINT IF EXISTS ${quoteIdentifierPart(`${baseTableName}_pkey`, dbType)};`);
}
if (newPKKeys.length > 0) {
const pkNames = input.columns
.filter((col) => col.key === 'PRI')
.map((col) => quoteIdentifierPart(col.name, 'postgres'))
.map((col) => quoteIdentifierPart(col.name, dbType))
.join(', ');
statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`);
}
@@ -253,13 +294,322 @@ const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput): string
return statements.join('\n');
};
export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = String(input.dbType || '').trim().toLowerCase();
if (isPgLikeDialect(dbType)) {
return buildPgLikeAlterPreviewSql({ ...input, dbType });
const buildOracleLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD (${buildStandardColumnDefinition(curr, dbType, { includeIdentity: true })});`);
if (String(curr.comment || '').trim()) statements.push(buildColumnCommentSql(tableRef, curr.name, curr.comment || '', dbType));
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (physicalDefinitionChanged(curr, orig)) {
statements.push(`ALTER TABLE ${tableRef}\nMODIFY (${buildStandardColumnDefinition({ ...curr, name: currentName }, dbType, { includeIdentity: true })});`);
}
if ((curr.comment || '') !== (orig.comment || '')) {
statements.push(buildColumnCommentSql(tableRef, currentName, curr.comment || '', dbType));
}
});
const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key);
const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key);
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
if (keysChanged) {
if (origPKKeys.length > 0) statements.push(`ALTER TABLE ${tableRef}\nDROP PRIMARY KEY;`);
if (newPKKeys.length > 0) {
const pkNames = input.columns.filter((col) => col.key === 'PRI').map((col) => quoteIdentifierPart(col.name, dbType)).join(', ');
statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`);
}
}
return buildMySqlAlterPreviewSql({ ...input, dbType });
return statements.join('\n');
};
const buildSqlServerDefaultDropBatch = (tableName: string, columnName: string): string => {
const { schemaName, objectName } = splitQualifiedName(tableName);
const schema = escapeSqlString(schemaName || 'dbo');
const table = escapeSqlString(objectName || tableName);
const column = escapeSqlString(columnName);
const tableRef = quoteIdentifierPath(`${schemaName || 'dbo'}.${objectName || tableName}`, 'sqlserver');
return `DECLARE @gonavi_df nvarchar(128); SELECT @gonavi_df = dc.name FROM sys.default_constraints dc JOIN sys.columns c ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id JOIN sys.tables t ON c.object_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id WHERE s.name = N'${schema}' AND t.name = N'${table}' AND c.name = N'${column}'; IF @gonavi_df IS NOT NULL EXEC(N'ALTER TABLE ${tableRef} DROP CONSTRAINT ' + QUOTENAME(@gonavi_df));`;
};
const buildSqlServerAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = 'sqlserver';
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD ${buildStandardColumnDefinition(curr, dbType, { includeNull: true, includeIdentity: true })};`);
if (String(curr.comment || '').trim()) statements.push(buildSqlServerColumnCommentSql(input.tableName, curr.name, curr.comment || ''));
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
const plainTablePath = unquoteSqlIdentifierPath(input.tableName);
statements.push(`EXEC sp_rename '${escapeSqlString(`${plainTablePath}.${orig.name}`)}', '${escapeSqlString(curr.name)}', 'COLUMN';`);
currentName = curr.name;
}
if (curr.type !== orig.type || curr.nullable !== orig.nullable || Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${buildStandardColumnDefinition({ ...curr, name: currentName, default: '' }, dbType, { includeNull: true, includeIdentity: false })};`);
}
const currDefault = normalizeDefaultText(curr.default);
const origDefault = normalizeDefaultText(orig.default);
if (currDefault !== origDefault) {
statements.push(buildSqlServerDefaultDropBatch(input.tableName, currentName));
if (currDefault) {
statements.push(`ALTER TABLE ${tableRef}\nADD DEFAULT ${formatDefaultExpression(currDefault, dbType)} FOR ${quoteIdentifierPart(currentName, dbType)};`);
}
}
if ((curr.comment || '') !== (orig.comment || '')) {
statements.push(buildSqlServerColumnCommentSql(input.tableName, currentName, curr.comment || ''));
}
});
const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key);
const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key);
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
if (keysChanged) {
const { objectName } = splitQualifiedName(input.tableName);
const constraintName = quoteIdentifierPart(`PK_${objectName || 'table'}`, dbType);
if (origPKKeys.length > 0) {
statements.push(`-- SQL Server 删除旧主键需要原约束名;请先在索引页确认后删除。`);
}
if (newPKKeys.length > 0) {
const pkNames = input.columns.filter((col) => col.key === 'PRI').map((col) => quoteIdentifierPart(col.name, dbType)).join(', ');
statements.push(`ALTER TABLE ${tableRef}\nADD CONSTRAINT ${constraintName} PRIMARY KEY (${pkNames});`);
}
}
return statements.join('\n');
};
const buildSqliteAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = 'sqlite';
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildStandardColumnDefinition(curr, dbType)};`);
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (physicalDefinitionChanged(curr, orig) || (curr.comment || '') !== (orig.comment || '')) {
statements.push(`-- SQLite 不支持直接修改字段属性,请通过创建新表、迁移数据、替换旧表的方式处理字段 ${currentName}`);
}
});
return statements.join('\n');
};
const buildDuckDbAlterPreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = 'duckdb';
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${buildStandardColumnDefinition(curr, dbType)};`);
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (curr.type !== orig.type) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} SET DATA TYPE ${curr.type};`);
}
const currDefault = normalizeDefaultText(curr.default);
const origDefault = normalizeDefaultText(orig.default);
if (currDefault !== origDefault) {
if (currDefault) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} SET DEFAULT ${formatDefaultExpression(currDefault, dbType)};`);
} else {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} DROP DEFAULT;`);
}
}
if (curr.nullable !== orig.nullable) {
statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`);
}
if ((curr.comment || '') !== (orig.comment || '')) {
statements.push(`-- DuckDB 不支持通过 COMMENT ON COLUMN 持久化字段备注,字段 ${currentName} 的备注仅保留在设计器预览中。`);
}
});
return statements.join('\n');
};
const buildLimitedBacktickAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string, label: string): string => {
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableRef}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${quoteIdentifierPart(curr.name, dbType)} ${curr.type};`);
if (curr.nullable === 'NO' || normalizeDefaultText(curr.default) || String(curr.comment || '').trim()) {
statements.push(`-- ${label} 的字段约束/默认值/备注语法与 MySQL 不同,已避免生成 MySQL 专属子句,请按目标库能力补充。`);
}
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableRef}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} TO ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (curr.type !== orig.type) {
statements.push(`ALTER TABLE ${tableRef}\nMODIFY COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.type};`);
}
if (
curr.nullable !== orig.nullable ||
normalizeDefaultText(curr.default) !== normalizeDefaultText(orig.default) ||
(curr.comment || '') !== (orig.comment || '') ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
) {
statements.push(`-- ${label} 的字段约束/默认值/备注语法与 MySQL 不同,已避免生成 MySQL 专属子句,请按目标库能力补充。`);
}
});
return statements.join('\n');
};
export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): string => {
const dbType = resolveSqlDialect(input.dbType);
if (isPgLikeDialect(dbType)) return buildPgLikeAlterPreviewSql({ ...input, dbType }, dbType);
if (isOracleLikeDialect(dbType)) return buildOracleLikeAlterPreviewSql({ ...input, dbType }, dbType);
if (isSqlServerDialect(dbType)) return buildSqlServerAlterPreviewSql({ ...input, dbType });
if (dbType === 'sqlite') return buildSqliteAlterPreviewSql({ ...input, dbType });
if (dbType === 'duckdb') return buildDuckDbAlterPreviewSql({ ...input, dbType });
if (dbType === 'clickhouse') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'ClickHouse');
if (dbType === 'tdengine') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'TDengine');
if (isMysqlFamilyDialect(dbType)) return buildMySqlAlterPreviewSql({ ...input, dbType }, dbType);
return buildPgLikeAlterPreviewSql({ ...input, dbType }, dbType);
};
export const hasAlterTableDraftChanges = (input: BuildAlterTablePreviewInput): boolean =>
buildAlterTablePreviewSql(input).trim().length > 0;
const buildCreateTableColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => {
if (isMysqlFamilyDialect(dbType)) {
return buildMySqlColumnDefinition(column, dbType);
}
if (isOracleLikeDialect(dbType)) {
return buildStandardColumnDefinition(column, dbType, { includeIdentity: true });
}
if (isSqlServerDialect(dbType)) {
return buildStandardColumnDefinition(column, dbType, { includeNull: true, includeIdentity: true });
}
if (dbType === 'clickhouse' || dbType === 'tdengine') {
return [quoteIdentifierPart(column.name, dbType), String(column.type || '').trim()].join(' ');
}
return buildStandardColumnDefinition(column, dbType);
};
const buildCreateColumnComments = (tableRef: string, input: BuildCreateTablePreviewInput, dbType: string): string[] => (
input.columns
.filter((column) => String(column.comment || '').trim())
.map((column) => {
if (isSqlServerDialect(dbType)) {
return buildSqlServerColumnCommentSql(input.tableName, column.name, column.comment || '');
}
if (isPgLikeDialect(dbType) || isOracleLikeDialect(dbType)) {
return buildColumnCommentSql(tableRef, column.name, column.comment || '', dbType);
}
return '';
})
.filter(Boolean)
);
export const buildCreateTablePreviewSql = (input: BuildCreateTablePreviewInput): string => {
const dbType = resolveSqlDialect(input.dbType);
const tableRef = quoteIdentifierPath(input.tableName, dbType);
const colDefs = input.columns.map((column) => buildCreateTableColumnDefinition(column, dbType));
const pkColumns = input.columns.filter((column) => column.key === 'PRI');
if (pkColumns.length > 0) {
const pkNames = pkColumns.map((column) => quoteIdentifierPart(column.name, dbType)).join(', ');
colDefs.push(`PRIMARY KEY (${pkNames})`);
}
const createSql = `CREATE TABLE ${tableRef} (\n ${colDefs.join(',\n ')}\n)`;
const comments = buildCreateColumnComments(tableRef, input, dbType);
if (dbType === 'mysql' || dbType === 'mariadb') {
const charset = String(input.charset || '').trim();
const collation = String(input.collation || '').trim();
const charsetSql = charset ? ` DEFAULT CHARSET=${charset}` : '';
const collationSql = collation ? ` COLLATE=${collation}` : '';
return `${createSql} ENGINE=InnoDB${charsetSql}${collationSql};`;
}
if (dbType === 'clickhouse') {
return `${createSql}\nENGINE = MergeTree\nORDER BY tuple();`;
}
const suffixComments = comments.length > 0 ? `\n${comments.join('\n')}` : '';
if (dbType === 'tdengine' && !input.columns.some((column) => /^timestamp$/i.test(String(column.type || '').trim()))) {
return `${createSql};\n-- TDengine 普通表通常需要 TIMESTAMP 时间列,执行前请确认表模型。${suffixComments}`;
}
if (isBacktickIdentifierDialect(dbType) && dbType !== 'mysql' && dbType !== 'mariadb') {
return `${createSql};${suffixComments}`;
}
return `${createSql};${suffixComments}`;
};

View File

@@ -119,6 +119,74 @@ describe('store appearance persistence', () => {
expect(useStore.getState().connections[0]?.config.password).toBe('secret');
});
it('preserves JVM Arthas diagnostic config when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'jvm-1',
name: 'Orders JVM',
config: {
id: 'jvm-1',
type: 'jvm',
host: '127.0.0.1',
port: 9010,
user: '',
jvm: {
allowedModes: ['jmx'],
preferredMode: 'jmx',
diagnostic: {
enabled: true,
transport: 'arthas-tunnel',
baseUrl: 'http://127.0.0.1:7777',
targetId: 'gonavi-local-test',
apiKey: 'diag-token',
allowObserveCommands: true,
allowTraceCommands: true,
allowMutatingCommands: false,
timeoutSeconds: 20,
},
},
},
},
]);
expect(useStore.getState().connections[0]?.config.jvm?.diagnostic).toEqual({
enabled: true,
transport: 'arthas-tunnel',
baseUrl: 'http://127.0.0.1:7777',
targetId: 'gonavi-local-test',
apiKey: 'diag-token',
allowObserveCommands: true,
allowTraceCommands: true,
allowMutatingCommands: false,
timeoutSeconds: 20,
});
});
it('preserves connection icon metadata when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'visual-1',
name: 'Visual Orders',
iconType: 'postgres',
iconColor: '#2f855a',
config: {
id: 'visual-1',
type: 'mysql',
host: 'db.local',
port: 3306,
user: 'root',
},
},
]);
expect(useStore.getState().connections[0]?.iconType).toBe('postgres');
expect(useStore.getState().connections[0]?.iconColor).toBe('#2f855a');
});
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ export interface SSHConfig {
}
export interface ProxyConfig {
type: 'socks5' | 'http';
type: "socks5" | "http";
host: string;
port: number;
user?: string;
@@ -21,6 +21,256 @@ export interface HTTPTunnelConfig {
password?: string;
}
export interface JVMJMXConfig {
enabled?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
domainAllowlist?: string[];
}
export interface JVMEndpointConfig {
enabled?: boolean;
baseUrl?: string;
apiKey?: string;
timeoutSeconds?: number;
}
export interface JVMAgentConfig {
enabled?: boolean;
baseUrl?: string;
apiKey?: string;
timeoutSeconds?: number;
}
export type JVMDiagnosticTransport = "agent-bridge" | "arthas-tunnel";
export interface JVMDiagnosticConfig {
enabled?: boolean;
transport?: JVMDiagnosticTransport;
baseUrl?: string;
targetId?: string;
apiKey?: string;
allowObserveCommands?: boolean;
allowTraceCommands?: boolean;
allowMutatingCommands?: boolean;
timeoutSeconds?: number;
}
export interface JVMDiagnosticCapability {
transport: JVMDiagnosticTransport;
canOpenSession: boolean;
canStream: boolean;
canCancel: boolean;
allowObserveCommands: boolean;
allowTraceCommands: boolean;
allowMutatingCommands: boolean;
reason?: string;
}
export interface JVMDiagnosticSessionRequest {
title?: string;
reason?: string;
}
export interface JVMDiagnosticSessionHandle {
sessionId: string;
transport: string;
startedAt: number;
}
export interface JVMDiagnosticCommandRequest {
sessionId: string;
commandId: string;
command: string;
source?: string;
reason?: string;
}
export interface JVMDiagnosticEventChunk {
sessionId: string;
commandId?: string;
event?: string;
phase?: string;
content?: string;
timestamp?: number;
metadata?: Record<string, any>;
}
export interface JVMDiagnosticAuditRecord {
timestamp: number;
connectionId: string;
sessionId?: string;
commandId?: string;
transport: string;
command: string;
commandType?: string;
source?: string;
reason?: string;
riskLevel?: string;
status: string;
}
export interface JVMDiagnosticPlan {
intent: string;
transport: JVMDiagnosticTransport;
command: string;
riskLevel: "low" | "medium" | "high";
reason: string;
expectedSignals?: string[];
}
export interface JVMDiagnosticCommandDraft {
sessionId?: string;
command: string;
source?: "manual" | "ai-plan";
reason?: string;
}
export interface JVMConfig {
environment?: "dev" | "uat" | "prod";
readOnly?: boolean;
allowedModes?: Array<"jmx" | "endpoint" | "agent">;
preferredMode?: "jmx" | "endpoint" | "agent";
jmx?: JVMJMXConfig;
endpoint?: JVMEndpointConfig;
agent?: JVMAgentConfig;
diagnostic?: JVMDiagnosticConfig;
}
export interface JVMCapability {
mode: "jmx" | "endpoint" | "agent";
canBrowse: boolean;
canWrite: boolean;
canPreview: boolean;
reason?: string;
displayLabel: string;
}
export interface JVMMonitoringPoint {
timestamp: number;
heapUsedBytes?: number;
heapCommittedBytes?: number;
heapMaxBytes?: number;
nonHeapUsedBytes?: number;
nonHeapCommittedBytes?: number;
gcCollectionCount?: number;
gcCollectionTimeMs?: number;
gcDeltaCount?: number;
gcDeltaTimeMs?: number;
threadCount?: number;
daemonThreadCount?: number;
peakThreadCount?: number;
threadStateCounts?: Record<string, number>;
loadedClassCount?: number;
unloadedClassCount?: number;
classLoadDelta?: number;
processCpuLoad?: number;
systemCpuLoad?: number;
processRssBytes?: number;
committedVirtualMemoryBytes?: number;
}
export interface JVMMonitoringRecentGCEvent {
timestamp: number;
name?: string;
cause?: string;
action?: string;
durationMs?: number;
beforeUsedBytes?: number;
afterUsedBytes?: number;
}
export interface JVMMonitoringSessionState {
connectionId: string;
providerMode: "jmx" | "endpoint" | "agent";
running: boolean;
points?: JVMMonitoringPoint[];
recentGcEvents?: JVMMonitoringRecentGCEvent[];
availableMetrics?: string[];
missingMetrics?: string[];
providerWarnings?: 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 JVMActionPayloadField {
name: string;
type?: string;
required?: boolean;
description?: string;
}
export interface JVMActionDefinition {
action: string;
label?: string;
description?: string;
dangerous?: boolean;
payloadFields?: JVMActionPayloadField[];
payloadExample?: Record<string, any>;
}
export interface JVMValueSnapshot {
resourceId: string;
kind: string;
format: string;
version?: string;
value: any;
description?: string;
sensitive?: boolean;
supportedActions?: JVMActionDefinition[];
metadata?: Record<string, any>;
}
export interface JVMChangePreview {
allowed: boolean;
requiresConfirmation?: boolean;
summary: string;
riskLevel: "low" | "medium" | "high";
blockingReason?: string;
before: JVMValueSnapshot;
after: JVMValueSnapshot;
}
export interface JVMChangeRequest {
providerMode: "jmx" | "endpoint" | "agent";
resourceId: string;
action: string;
reason: string;
source?: "manual" | "ai-plan";
expectedVersion?: string;
payload?: Record<string, any>;
}
export interface JVMApplyResult {
status: string;
message?: string;
updatedValue: JVMValueSnapshot;
}
export interface JVMAuditRecord {
timestamp: number;
connectionId: string;
providerMode: string;
resourceId: string;
action: string;
reason: string;
source?: string;
result: string;
}
export interface ConnectionConfig {
id?: string;
type: string;
@@ -31,7 +281,7 @@ export interface ConnectionConfig {
savePassword?: boolean;
database?: string;
useSSL?: boolean;
sslMode?: 'preferred' | 'required' | 'skip-verify' | 'disable';
sslMode?: "preferred" | "required" | "skip-verify" | "disable";
sslCertPath?: string;
sslKeyPath?: string;
useSSH?: boolean;
@@ -46,7 +296,7 @@ export interface ConnectionConfig {
redisDB?: number; // Redis database index (0-15)
uri?: string; // Connection URI for copy/paste
hosts?: string[]; // Multi-host addresses: host:port
topology?: 'single' | 'replica' | 'cluster';
topology?: "single" | "replica" | "cluster";
mysqlReplicaUser?: string;
mysqlReplicaPassword?: string;
replicaSet?: string;
@@ -56,6 +306,7 @@ export interface ConnectionConfig {
mongoAuthMechanism?: string;
mongoReplicaUser?: string;
mongoReplicaPassword?: string;
jvm?: JVMConfig;
}
export interface MongoMemberInfo {
@@ -82,8 +333,8 @@ export interface SavedConnection {
hasOpaqueDSN?: boolean;
includeDatabases?: string[];
includeRedisDatabases?: number[]; // Redis databases to show (0-15)
iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type
iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色
iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type
iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色
}
export interface GlobalProxyConfig extends ProxyConfig {
@@ -134,13 +385,31 @@ export interface TriggerDefinition {
export interface TabData {
id: string;
title: string;
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'redis-monitor' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
type:
| "query"
| "table"
| "design"
| "redis-keys"
| "redis-command"
| "redis-monitor"
| "trigger"
| "view-def"
| "routine-def"
| "table-overview"
| "jvm-overview"
| "jvm-resource"
| "jvm-audit"
| "jvm-diagnostic"
| "jvm-monitoring";
connectionId: string;
dbName?: string;
tableName?: string;
query?: string;
initialTab?: string;
readOnly?: boolean;
providerMode?: "jmx" | "endpoint" | "agent";
resourcePath?: string;
resourceKind?: string;
redisDB?: number; // Redis database index for redis tabs
triggerName?: string; // Trigger name for trigger tabs
viewName?: string; // View name for view definition tabs
@@ -149,6 +418,19 @@ export interface TabData {
savedQueryId?: string; // Saved query identity for quick-save behavior
}
export interface JVMAIPlanContext {
tabId: string;
connectionId: string;
providerMode: "jmx" | "endpoint" | "agent";
resourcePath: string;
}
export interface JVMDiagnosticPlanContext {
tabId: string;
connectionId: string;
transport: JVMDiagnosticTransport;
}
export interface DatabaseNode {
title: string;
key: string;
@@ -195,7 +477,7 @@ export interface RedisScanResult {
}
export interface RedisValue {
type: 'string' | 'hash' | 'list' | 'set' | 'zset' | 'stream';
type: "string" | "hash" | "list" | "set" | "zset" | "stream";
ttl: number;
value: any;
length: number;
@@ -218,9 +500,9 @@ export interface StreamEntry {
// --- AI Types ---
export type AIProviderType = 'openai' | 'anthropic' | 'gemini' | 'custom';
export type AISafetyLevel = 'readonly' | 'readwrite' | 'full';
export type AIContextLevel = 'schema_only' | 'with_samples' | 'with_results';
export type AIProviderType = "openai" | "anthropic" | "gemini" | "custom";
export type AISafetyLevel = "readonly" | "readwrite" | "full";
export type AIContextLevel = "schema_only" | "with_samples" | "with_results";
export interface AIContextItem {
dbName: string;
@@ -253,11 +535,16 @@ export interface AIToolCall {
};
}
export type ChatPhase = 'idle' | 'connecting' | 'thinking' | 'generating' | 'tool_calling';
export type ChatPhase =
| "idle"
| "connecting"
| "thinking"
| "generating"
| "tool_calling";
export interface AIChatMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
role: "user" | "assistant" | "system" | "tool";
phase?: ChatPhase;
content: string;
thinking?: string;
@@ -269,40 +556,51 @@ export interface AIChatMessage {
tool_name?: string; // used for UI display
rawError?: string; // 存储未清洗的原始错误信息,用于用户复制排查
success?: boolean; // 标记探针执行是否成功
jvmPlanContext?: JVMAIPlanContext;
jvmDiagnosticPlanContext?: JVMDiagnosticPlanContext;
}
export interface AISafetyResult {
allowed: boolean;
operationType: 'query' | 'dml' | 'ddl' | 'other';
operationType: "query" | "dml" | "ddl" | "other";
requiresConfirm: boolean;
warningMessage?: string;
}
export type SecurityUpdateOverallStatus =
| 'not_detected'
| 'pending'
| 'postponed'
| 'in_progress'
| 'needs_attention'
| 'completed'
| 'rolled_back';
| "not_detected"
| "pending"
| "postponed"
| "in_progress"
| "needs_attention"
| "completed"
| "rolled_back";
export type SecurityUpdateIssueScope = 'connection' | 'global_proxy' | 'ai_provider' | 'system';
export type SecurityUpdateIssueSeverity = 'high' | 'medium' | 'low';
export type SecurityUpdateItemStatus = 'pending' | 'updated' | 'needs_attention' | 'skipped' | 'failed';
export type SecurityUpdateIssueScope =
| "connection"
| "global_proxy"
| "ai_provider"
| "system";
export type SecurityUpdateIssueSeverity = "high" | "medium" | "low";
export type SecurityUpdateItemStatus =
| "pending"
| "updated"
| "needs_attention"
| "skipped"
| "failed";
export type SecurityUpdateIssueReasonCode =
| 'migration_required'
| 'secret_missing'
| 'field_invalid'
| 'write_conflict'
| 'validation_failed'
| 'environment_blocked';
| "migration_required"
| "secret_missing"
| "field_invalid"
| "write_conflict"
| "validation_failed"
| "environment_blocked";
export type SecurityUpdateIssueAction =
| 'open_connection'
| 'open_proxy_settings'
| 'open_ai_settings'
| 'retry_update'
| 'view_details';
| "open_connection"
| "open_proxy_settings"
| "open_ai_settings"
| "retry_update"
| "view_details";
export interface SecurityUpdateSummary {
total: number;
@@ -328,7 +626,7 @@ export interface SecurityUpdateStatus {
schemaVersion?: number;
migrationId?: string;
overallStatus: SecurityUpdateOverallStatus;
sourceType?: 'current_app_saved_config';
sourceType?: "current_app_saved_config";
reminderVisible?: boolean;
canStart?: boolean;
canPostpone?: boolean;
@@ -343,5 +641,3 @@ export interface SecurityUpdateStatus {
issues: SecurityUpdateIssue[];
lastError?: string;
}

View File

@@ -1,9 +1,12 @@
import { describe, expect, it } from 'vitest';
import {
getConnectionConfigLayoutKindLabel,
getStoredSecretPlaceholder,
normalizeConnectionSecretErrorMessage,
resolveConnectionConfigLayout,
resolveConnectionTestFailureFeedback,
summarizeConnectionTestFailureMessage,
} from './connectionModalPresentation';
describe('connectionModalPresentation', () => {
@@ -33,14 +36,14 @@ describe('connectionModalPresentation', () => {
expect(normalizeConnectionSecretErrorMessage('连接测试超时')).toBe('连接测试超时');
});
it('shows a toast-worthy failure message for saved-secret lookup errors during connection tests', () => {
it('keeps saved-secret lookup errors inside the modal instead of raising a global toast', () => {
expect(resolveConnectionTestFailureFeedback({
kind: 'runtime',
reason: 'saved connection not found: conn-1',
fallback: '连接失败',
})).toEqual({
message: '测试失败: 未找到当前连接对应的已保存密文,请重新填写密码并保存后再试',
shouldToast: true,
shouldToast: false,
});
});
@@ -54,4 +57,89 @@ describe('connectionModalPresentation', () => {
shouldToast: false,
});
});
});
it('uses only the first line for connection failure toast summaries', () => {
expect(summarizeConnectionTestFailureMessage(`测试失败: 当前端口不是 JMX 远程管理端口\n建议请改填 JMX 端口\n技术细节raw error`)).toBe(
'测试失败: 当前端口不是 JMX 远程管理端口',
);
});
it('assigns card-based configuration sections to every supported data source type', () => {
const allTypes = [
'mysql',
'mariadb',
'doris',
'diros',
'sphinx',
'clickhouse',
'postgres',
'sqlserver',
'sqlite',
'duckdb',
'oracle',
'dameng',
'kingbase',
'highgo',
'vastbase',
'mongodb',
'redis',
'tdengine',
'custom',
'jvm',
];
allTypes.forEach((type) => {
const layout = resolveConnectionConfigLayout(type);
expect(layout.sections.length).toBeGreaterThan(0);
expect(layout.sections).toContain('identity');
expect(new Set(layout.sections).size).toBe(layout.sections.length);
});
});
it('keeps datasource-specific connection options in the layout contract', () => {
expect(resolveConnectionConfigLayout('mysql').sections).toEqual([
'identity',
'uri',
'target',
'connectionMode',
'replica',
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('mongodb').sections).toEqual([
'identity',
'uri',
'target',
'connectionMode',
'mongoDiscovery',
'replica',
'mongoPolicy',
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('redis').sections).toEqual([
'identity',
'uri',
'target',
'connectionMode',
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('sqlite').sections).toEqual([
'identity',
'uri',
'fileTarget',
]);
expect(resolveConnectionConfigLayout('custom').sections).toEqual([
'identity',
'customDriver',
'customDsn',
]);
});
it('uses localized labels for layout kinds shown in the modal', () => {
expect(getConnectionConfigLayoutKindLabel('mysql-compatible')).toBe('MySQL 兼容');
expect(getConnectionConfigLayoutKindLabel('file')).toBe('文件型数据库');
});
});

View File

@@ -15,6 +15,249 @@ type ConnectionTestFailureFeedback = {
shouldToast: boolean;
};
export type ConnectionConfigSectionKey =
| 'identity'
| 'uri'
| 'target'
| 'fileTarget'
| 'connectionMode'
| 'mongoDiscovery'
| 'replica'
| 'service'
| 'mongoPolicy'
| 'credentials'
| 'databaseScope'
| 'customDriver'
| 'customDsn'
| 'jvmRuntime';
export type ConnectionConfigLayoutKind =
| 'mysql-compatible'
| 'mongodb'
| 'redis'
| 'postgres-compatible'
| 'oracle'
| 'file'
| 'custom'
| 'jvm'
| 'generic-sql';
export type ConnectionConfigLayout = {
kind: ConnectionConfigLayoutKind;
sections: ConnectionConfigSectionKey[];
};
type ConnectionConfigSectionCopy = {
title: string;
description: string;
};
const mysqlCompatibleTypes = new Set([
'mysql',
'mariadb',
'doris',
'diros',
'sphinx',
]);
const postgresCompatibleTypes = new Set([
'postgres',
'kingbase',
'highgo',
'vastbase',
]);
const fileDatabaseTypes = new Set(['sqlite', 'duckdb']);
const CONNECTION_CONFIG_SECTION_COPY: Record<
ConnectionConfigSectionKey,
ConnectionConfigSectionCopy
> = {
identity: {
title: '基础身份',
description: '连接名称和连接树中展示的基础信息。',
},
uri: {
title: '连接 URI',
description: '适合复制粘贴完整连接串,也可以和下方参数互相生成、解析。',
},
target: {
title: '目标地址',
description: '数据库服务的主机、端口或网关入口,是连通性测试的主目标。',
},
fileTarget: {
title: '数据库文件',
description: 'SQLite / DuckDB 使用本地数据库文件路径,不需要端口和网络隧道。',
},
connectionMode: {
title: '连接模式',
description: '选择单机、主从、副本集或集群等拓扑模式。',
},
mongoDiscovery: {
title: 'MongoDB 寻址',
description: '选择标准 host:port 或 mongodb+srv DNS 发现方式。',
},
replica: {
title: '多节点配置',
description: '补充从库、种子节点、副本集成员或独立认证信息。',
},
service: {
title: '数据库服务',
description: '默认数据库、Oracle Service Name 等服务级定位参数。',
},
mongoPolicy: {
title: 'MongoDB 策略',
description: '认证库、读偏好等 MongoDB 专属策略。',
},
credentials: {
title: '认证凭据',
description: '用户名、密码和密文保留策略;留空会按已保存密文规则处理。',
},
databaseScope: {
title: '数据库范围',
description: '连接成功后可限制连接树展示的数据库或 Redis DB。',
},
customDriver: {
title: '自定义驱动',
description: '指定驱动名称,用于匹配已安装或可动态导入的数据库驱动。',
},
customDsn: {
title: '连接字符串',
description: '直接填写驱动要求的 DSN适合非内置数据源或特殊参数。',
},
jvmRuntime: {
title: 'JVM 运行时',
description: 'JVM 目标、接入模式、JMX、Endpoint、Agent 与诊断增强。',
},
};
export const getConnectionConfigSectionCopy = (
key: ConnectionConfigSectionKey,
): ConnectionConfigSectionCopy => CONNECTION_CONFIG_SECTION_COPY[key];
export const getConnectionConfigLayoutKindLabel = (
kind: ConnectionConfigLayoutKind,
): string => {
switch (kind) {
case 'mysql-compatible':
return 'MySQL 兼容';
case 'mongodb':
return '文档数据库';
case 'redis':
return '键值数据库';
case 'postgres-compatible':
return 'PostgreSQL 兼容';
case 'oracle':
return 'Oracle 服务';
case 'file':
return '文件型数据库';
case 'custom':
return '自定义连接';
case 'jvm':
return 'JVM 运行时';
case 'generic-sql':
default:
return '标准 SQL';
}
};
export const resolveConnectionConfigLayout = (
rawType: string,
): ConnectionConfigLayout => {
const type = String(rawType || '').trim().toLowerCase();
if (type === 'jvm') {
return {
kind: 'jvm',
sections: ['identity', 'jvmRuntime'],
};
}
if (type === 'custom') {
return {
kind: 'custom',
sections: ['identity', 'customDriver', 'customDsn'],
};
}
if (fileDatabaseTypes.has(type)) {
return {
kind: 'file',
sections: ['identity', 'uri', 'fileTarget'],
};
}
if (mysqlCompatibleTypes.has(type)) {
return {
kind: 'mysql-compatible',
sections: [
'identity',
'uri',
'target',
'connectionMode',
'replica',
'credentials',
'databaseScope',
],
};
}
if (type === 'mongodb') {
return {
kind: 'mongodb',
sections: [
'identity',
'uri',
'target',
'connectionMode',
'mongoDiscovery',
'replica',
'mongoPolicy',
'credentials',
'databaseScope',
],
};
}
if (type === 'redis') {
return {
kind: 'redis',
sections: [
'identity',
'uri',
'target',
'connectionMode',
'credentials',
'databaseScope',
],
};
}
if (postgresCompatibleTypes.has(type)) {
return {
kind: 'postgres-compatible',
sections: [
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
],
};
}
if (type === 'oracle') {
return {
kind: 'oracle',
sections: [
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
],
};
}
return {
kind: 'generic-sql',
sections: ['identity', 'uri', 'target', 'credentials', 'databaseScope'],
};
};
const normalizeText = (value: unknown, fallback = ''): string => {
const text = String(value ?? '').trim();
if (!text || text === 'undefined' || text === 'null') {
@@ -50,6 +293,18 @@ export const normalizeConnectionSecretErrorMessage = (
return text;
};
export const summarizeConnectionTestFailureMessage = (
value: unknown,
fallback = '',
): string => {
const text = normalizeConnectionSecretErrorMessage(value, fallback);
const [firstLine] = text
.split(/\r?\n/)
.map((item) => item.trim())
.filter((item) => item !== '');
return firstLine || text;
};
export const resolveConnectionTestFailureFeedback = ({
kind,
reason,
@@ -68,7 +323,7 @@ export const resolveConnectionTestFailureFeedback = ({
return {
message: `测试失败: ${normalizeConnectionSecretErrorMessage(reason, fallback)}`,
shouldToast: true,
shouldToast: false,
};
};

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import type { SavedConnection } from '../types';
import {
resolveConnectionAccentColor,
resolveConnectionIconType,
} from './connectionVisual';
const baseConnection: SavedConnection = {
id: 'conn-1',
name: 'Orders',
config: {
id: 'conn-1',
type: 'mysql',
host: 'db.local',
port: 3306,
user: 'root',
},
};
describe('connectionVisual', () => {
it('uses custom icon metadata as the connection visual identity', () => {
const connection: SavedConnection = {
...baseConnection,
iconType: 'postgres',
iconColor: '#2f855a',
};
expect(resolveConnectionIconType(connection)).toBe('postgres');
expect(resolveConnectionAccentColor(connection)).toBe('#2f855a');
});
it('falls back to the data source default color when custom color is blank', () => {
expect(resolveConnectionIconType(baseConnection)).toBe('mysql');
expect(resolveConnectionAccentColor(baseConnection)).toBe('#00758F');
});
it('ignores invalid custom colors instead of rendering unsafe CSS values', () => {
const connection: SavedConnection = {
...baseConnection,
iconColor: 'url(javascript:alert(1))',
};
expect(resolveConnectionAccentColor(connection)).toBe('#00758F');
});
});

View File

@@ -0,0 +1,40 @@
import type { SavedConnection } from '../types';
import { getDbDefaultColor } from '../components/DatabaseIcons';
const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
const toTrimmedString = (value: unknown): string => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
return '';
};
export const normalizeConnectionIconColor = (value: unknown): string => {
const color = toTrimmedString(value);
return HEX_COLOR_PATTERN.test(color) ? color : '';
};
export const resolveConnectionIconType = (
connection?: Pick<SavedConnection, 'iconType' | 'config'> | null,
): string => {
const iconType = toTrimmedString(connection?.iconType).toLowerCase();
if (iconType) {
return iconType;
}
const configType = toTrimmedString(connection?.config?.type).toLowerCase();
return configType || 'custom';
};
export const resolveConnectionAccentColor = (
connection?: Pick<SavedConnection, 'iconColor' | 'iconType' | 'config'> | null,
): string => {
const iconColor = normalizeConnectionIconColor(connection?.iconColor);
if (iconColor) {
return iconColor;
}
return getDbDefaultColor(resolveConnectionIconType(connection));
};

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import {
applyWhereConditionSuggestion,
buildEffectiveFilterConditions,
buildQuickWhereFilterCondition,
normalizeQuickWhereCondition,
resolveWhereConditionSuggestions,
resolveWhereConditionSelectedValue,
validateQuickWhereCondition,
} from './dataGridWhereFilter';
describe('dataGridWhereFilter', () => {
it('normalizes pasted WHERE clauses to condition bodies', () => {
expect(normalizeQuickWhereCondition(' WHERE status = 1; ')).toBe('status = 1');
expect(normalizeQuickWhereCondition('\nwhere name like \'A%\'\n')).toBe("name like 'A%'");
});
it('rejects multi statement or commented quick where conditions', () => {
expect(validateQuickWhereCondition('status = 1')).toEqual({ ok: true });
expect(validateQuickWhereCondition('status = 1; drop table users')).toEqual({
ok: false,
message: 'WHERE 条件不能包含分号或 SQL 注释',
});
expect(validateQuickWhereCondition('status = 1 -- bypass')).toEqual({
ok: false,
message: 'WHERE 条件不能包含分号或 SQL 注释',
});
});
it('merges structured filters with a quick custom where condition', () => {
const effective = buildEffectiveFilterConditions(
[{ id: 1, column: 'status', op: '=', value: 'A', logic: 'AND' }],
'amount > 100',
);
expect(effective).toEqual([
{ id: 1, column: 'status', op: '=', value: 'A', logic: 'AND' },
{
id: -1,
enabled: true,
logic: 'AND',
column: '',
op: 'CUSTOM',
value: 'amount > 100',
value2: '',
},
]);
expect(buildQuickWhereFilterCondition('')).toBeNull();
});
it('suggests columns, operators and keywords for quick where editing', () => {
const columnSuggestions = resolveWhereConditionSuggestions({
input: 'sta',
columnNames: ['status', 'created_at'],
dbType: 'mysql',
});
expect(columnSuggestions[0]).toMatchObject({
label: 'status',
kind: 'column',
value: '`status`',
});
const operatorSuggestions = resolveWhereConditionSuggestions({
input: 'status ',
columnNames: ['status'],
dbType: 'mysql',
});
expect(operatorSuggestions.map((item) => item.label)).toContain('LIKE');
const quotedOperatorSuggestions = resolveWhereConditionSuggestions({
input: '`username` ',
columnNames: ['username'],
dbType: 'mysql',
});
expect(quotedOperatorSuggestions.find((item) => item.label === '=')?.value).toBe('`username` = ');
const keywordSuggestions = resolveWhereConditionSuggestions({
input: 'status = 1 a',
columnNames: ['status'],
dbType: 'mysql',
});
expect(keywordSuggestions.map((item) => item.label)).toContain('AND');
});
it('applies a suggestion to the current trailing token', () => {
expect(applyWhereConditionSuggestion('status = 1 a', 'AND ')).toBe('status = 1 AND ');
expect(applyWhereConditionSuggestion('', '`user`')).toBe('`user`');
});
it('keeps a completed quoted column intact when applying an operator suggestion', () => {
expect(applyWhereConditionSuggestion('`字段名`', '= ')).toBe('`字段名` = ');
expect(applyWhereConditionSuggestion('`字段名` ', '= ')).toBe('`字段名` = ');
expect(applyWhereConditionSuggestion('"字段名"', 'LIKE ')).toBe('"字段名" LIKE ');
});
it('uses the selected autocomplete value once without appending it again', () => {
expect(
resolveWhereConditionSelectedValue({
selectedValue: '`username`',
currentInput: '`username`',
insertText: '`username`',
}),
).toBe('`username`');
expect(
resolveWhereConditionSelectedValue({
selectedValue: '`username` = ',
currentInput: '`username` = ',
insertText: '= ',
}),
).toBe('`username` = ');
});
});

View File

@@ -0,0 +1,242 @@
import { quoteIdentPart, type FilterCondition } from './sql';
export type WhereConditionSuggestionKind = 'column' | 'operator' | 'keyword';
export type WhereConditionSuggestion = {
label: string;
value: string;
insertText: string;
detail: string;
kind: WhereConditionSuggestionKind;
};
const QUICK_WHERE_CONDITION_ID = -1;
const WHERE_KEYWORDS = [
'AND',
'OR',
'NOT',
'IS',
'NULL',
'TRUE',
'FALSE',
'IN',
'LIKE',
'BETWEEN',
'EXISTS',
];
const WHERE_OPERATORS = [
'=',
'!=',
'<>',
'>',
'>=',
'<',
'<=',
'LIKE',
'NOT LIKE',
'IN',
'BETWEEN',
'IS NULL',
'IS NOT NULL',
];
const toTrimmedString = (value: unknown): string => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
return '';
};
const normalizeSuggestionPrefix = (value: string): string => {
const text = String(value || '');
if (!text || /\s$/.test(text)) return '';
const identifierMatch = text.match(/([A-Za-z_][A-Za-z0-9_$]*)$/);
if (identifierMatch) return identifierMatch[1];
const isBoundary = (char: string | undefined) => !char || /[\s([,{=<>!]/.test(char);
const boundaryIndex = Math.max(
text.lastIndexOf(' '),
text.lastIndexOf('\t'),
text.lastIndexOf('\n'),
text.lastIndexOf('('),
text.lastIndexOf('['),
text.lastIndexOf(','),
text.lastIndexOf('{'),
text.lastIndexOf('='),
text.lastIndexOf('<'),
text.lastIndexOf('>'),
text.lastIndexOf('!'),
);
for (const quote of ['`', '"']) {
const start = text.lastIndexOf(quote);
if (start < 0 || !isBoundary(text[start - 1])) continue;
const tokenStart = boundaryIndex + 1;
const tokenHead = text.slice(tokenStart, start);
if (tokenHead.includes(quote)) continue;
return text.slice(start);
}
return '';
};
const shouldSuggestOperators = (input: string): boolean => {
return /\s$/.test(input) && /(?:[A-Za-z_][A-Za-z0-9_$]*|"[^"]+"|`[^`]+`)\s$/.test(input);
};
const toOperatorInsertText = (operator: string): string => {
if (operator === 'IN') return 'IN ()';
if (operator === 'BETWEEN') return 'BETWEEN AND ';
return `${operator} `;
};
export const normalizeQuickWhereCondition = (value: unknown): string => {
let text = toTrimmedString(value);
text = text.replace(/^where\b/i, '').trim();
text = text.replace(/;+\s*$/, '').trim();
return text;
};
export const validateQuickWhereCondition = (
value: unknown,
): { ok: true } | { ok: false; message: string } => {
const text = normalizeQuickWhereCondition(value);
if (!text) {
return { ok: true };
}
if (/[;]/.test(text) || /--|\/\*/.test(text)) {
return {
ok: false,
message: 'WHERE 条件不能包含分号或 SQL 注释',
};
}
return { ok: true };
};
export const buildQuickWhereFilterCondition = (
value: unknown,
): FilterCondition | null => {
const text = normalizeQuickWhereCondition(value);
if (!text) return null;
return {
id: QUICK_WHERE_CONDITION_ID,
enabled: true,
logic: 'AND',
column: '',
op: 'CUSTOM',
value: text,
value2: '',
};
};
export const buildEffectiveFilterConditions = (
conditions: FilterCondition[] | undefined,
quickWhereCondition: unknown,
): FilterCondition[] => {
const baseConditions = Array.isArray(conditions) ? conditions : [];
const quickCondition = buildQuickWhereFilterCondition(quickWhereCondition);
if (!quickCondition) {
return baseConditions;
}
return [...baseConditions, quickCondition];
};
export const applyWhereConditionSuggestion = (
input: string,
insertText: string,
): string => {
const text = String(input || '');
const prefix = normalizeSuggestionPrefix(text);
if (!prefix) {
if (text && !/\s$/.test(text) && !/[([,{=<>!]$/.test(text)) {
return `${text} ${insertText}`;
}
return `${text}${insertText}`;
}
return `${text.slice(0, text.length - prefix.length)}${insertText}`;
};
export const resolveWhereConditionSelectedValue = ({
selectedValue,
currentInput,
insertText,
}: {
selectedValue: unknown;
currentInput: unknown;
insertText?: unknown;
}): string => {
const selectedText = String(selectedValue ?? '');
if (selectedText) {
return selectedText;
}
const insertTextValue = String(insertText ?? '');
if (!insertTextValue) {
return String(currentInput ?? '');
}
return applyWhereConditionSuggestion(String(currentInput ?? ''), insertTextValue);
};
export const resolveWhereConditionSuggestions = ({
input,
columnNames,
dbType,
}: {
input: string;
columnNames: string[];
dbType: string;
}): WhereConditionSuggestion[] => {
const text = String(input || '');
const prefix = normalizeSuggestionPrefix(text).replace(/^["`]/, '').toLowerCase();
const options: WhereConditionSuggestion[] = [];
if (shouldSuggestOperators(text)) {
WHERE_OPERATORS.forEach((operator) => {
const insertText = toOperatorInsertText(operator);
options.push({
label: operator,
insertText,
value: applyWhereConditionSuggestion(text, insertText),
detail: '操作符',
kind: 'operator',
});
});
return options;
}
(columnNames || [])
.map((column) => toTrimmedString(column))
.filter(Boolean)
.filter((column) => !prefix || column.toLowerCase().startsWith(prefix))
.slice(0, 30)
.forEach((column) => {
const insertText = quoteIdentPart(dbType, column);
options.push({
label: column,
insertText,
value: applyWhereConditionSuggestion(text, insertText),
detail: '字段',
kind: 'column',
});
});
WHERE_KEYWORDS
.filter((keyword) => !prefix || keyword.toLowerCase().startsWith(prefix))
.forEach((keyword) => {
const insertText = `${keyword} `;
options.push({
label: keyword,
insertText,
value: applyWhereConditionSuggestion(text, insertText),
detail: '关键字',
kind: 'keyword',
});
});
return options;
};

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest';
import { buildJVMChangeDraftFromAIPlan, extractJVMChangePlan, resolveJVMAIPlanResourceId, resolveJVMAIPlanTargetTabId } from './jvmAiPlan';
describe('extractJVMChangePlan', () => {
it('parses fenced json plan with namespace and key selector', () => {
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');
expect(plan?.selector.key).toBe('user:1');
expect(plan ? resolveJVMAIPlanResourceId(plan) : '').toBe('orders/user:1');
});
it('parses fenced json plan with explicit resource path', () => {
const message = [
'```json',
'{"targetType":"managedBean","selector":{"resourcePath":"/cache/orders/user:1"},"action":"clear","reason":"触发受控清理"}',
'```',
].join('\n');
const plan = extractJVMChangePlan(message);
expect(plan?.targetType).toBe('managedBean');
expect(plan?.selector.resourcePath).toBe('/cache/orders/user:1');
expect(plan?.action).toBe('clear');
});
it('returns null for malformed plan', () => {
expect(extractJVMChangePlan('```json\n{"action":1}\n```')).toBeNull();
});
it('returns null when selector is missing', () => {
expect(
extractJVMChangePlan('```json\n{"targetType":"cacheEntry","action":"evict","reason":"修复缓存脏值"}\n```'),
).toBeNull();
});
});
describe('buildJVMChangeDraftFromAIPlan', () => {
it('maps updateValue plan to current JVM change contract', () => {
const plan = extractJVMChangePlan(
'```json\n{"targetType":"cacheEntry","selector":{"namespace":"orders","key":"user:1"},"action":"updateValue","payload":{"format":"json","value":{"status":"ACTIVE"}},"reason":"修复缓存脏值"}\n```',
);
expect(plan).not.toBeNull();
expect(buildJVMChangeDraftFromAIPlan(plan!)).toEqual({
resourceId: 'orders/user:1',
action: 'put',
reason: '修复缓存脏值',
source: 'ai-plan',
payload: {
status: 'ACTIVE',
},
});
});
it('maps clear plan without leaking wrapper payload fields', () => {
const plan = extractJVMChangePlan(
'```json\n{"targetType":"managedBean","selector":{"resourcePath":"/cache/orders"},"action":"clear","reason":"受控清理"}\n```',
);
expect(plan).not.toBeNull();
expect(buildJVMChangeDraftFromAIPlan(plan!)).toEqual({
resourceId: '/cache/orders',
action: 'clear',
reason: '受控清理',
source: 'ai-plan',
payload: {},
});
});
it('rejects non-object update payload values for current preview contract', () => {
const plan = extractJVMChangePlan(
'```json\n{"targetType":"cacheEntry","selector":{"resourcePath":"/cache/orders"},"action":"updateValue","payload":{"format":"text","value":"ACTIVE"},"reason":"修复缓存脏值"}\n```',
);
expect(plan).not.toBeNull();
expect(() => buildJVMChangeDraftFromAIPlan(plan!)).toThrow('当前 JVM 预览要求 payload 仍然是 JSON 对象');
});
it('keeps generic action for managed bean payload updates', () => {
const plan = extractJVMChangePlan(
'```json\n{"targetType":"attribute","selector":{"resourcePath":"jmx://java.lang/type=Memory/attribute/Verbose"},"action":"set","payload":{"format":"json","value":{"value":true}},"reason":"开启诊断日志"}\n```',
);
expect(plan).not.toBeNull();
expect(buildJVMChangeDraftFromAIPlan(plan!)).toEqual({
resourceId: 'jmx://java.lang/type=Memory/attribute/Verbose',
action: 'set',
reason: '开启诊断日志',
source: 'ai-plan',
payload: {
value: true,
},
});
});
});
describe('resolveJVMAIPlanTargetTabId', () => {
it('prefers the original tab when message context still matches', () => {
expect(
resolveJVMAIPlanTargetTabId(
[
{
id: 'tab-orders',
title: 'orders',
type: 'jvm-resource',
connectionId: 'conn-orders',
providerMode: 'endpoint',
resourcePath: '/cache/orders/user:1',
},
],
{
tabId: 'tab-orders',
connectionId: 'conn-orders',
providerMode: 'endpoint',
resourcePath: '/cache/orders/user:1',
},
),
).toBe('tab-orders');
});
it('falls back to a reopened tab with the same JVM context', () => {
expect(
resolveJVMAIPlanTargetTabId(
[
{
id: 'tab-orders-reopened',
title: 'orders',
type: 'jvm-resource',
connectionId: 'conn-orders',
providerMode: 'endpoint',
resourcePath: '/cache/orders/user:1',
},
],
{
tabId: 'tab-orders-old',
connectionId: 'conn-orders',
providerMode: 'endpoint',
resourcePath: '/cache/orders/user:1',
},
),
).toBe('tab-orders-reopened');
});
it('rejects tabs that only match the current session but not the original JVM context', () => {
expect(
resolveJVMAIPlanTargetTabId(
[
{
id: 'tab-other-resource',
title: 'orders-other',
type: 'jvm-resource',
connectionId: 'conn-orders',
providerMode: 'endpoint',
resourcePath: '/cache/orders/user:2',
},
],
{
tabId: 'tab-orders',
connectionId: 'conn-orders',
providerMode: 'endpoint',
resourcePath: '/cache/orders/user:1',
},
),
).toBe('');
});
});

View File

@@ -0,0 +1,321 @@
import type { JVMActionDefinition, JVMChangeRequest, JVMAIPlanContext, JVMValueSnapshot, TabData } from '../types';
export type JVMAIChangePlan = {
targetType: 'cacheEntry' | 'managedBean' | 'attribute' | 'operation';
selector: {
namespace?: string;
key?: string;
resourcePath?: string;
};
action: string;
payload?: {
format: 'json' | 'text';
value: unknown;
};
reason: string;
};
export type JVMAIChangeDraft = Pick<JVMChangeRequest, 'resourceId' | 'action' | 'reason' | 'source' | 'payload'>;
type JVMAIPlanPromptContext = {
connectionName: string;
host?: string;
providerMode: 'jmx' | 'endpoint' | 'agent';
resourcePath: string;
readOnly: boolean;
environment?: string;
snapshot?: JVMValueSnapshot | null;
};
const planFencePattern = /```json\s*([\s\S]*?)```/gi;
const allowedTargetTypes = new Set<JVMAIChangePlan['targetType']>(['cacheEntry', 'managedBean', 'attribute', 'operation']);
const allowedPayloadFormats = new Set<NonNullable<JVMAIChangePlan['payload']>['format']>(['json', 'text']);
const asTrimmedString = (value: unknown): string => String(value ?? '').trim();
const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const normalizeSelector = (value: unknown): JVMAIChangePlan['selector'] | null => {
if (!isRecord(value)) {
return null;
}
const selector: JVMAIChangePlan['selector'] = {};
const namespace = asTrimmedString(value.namespace);
const key = asTrimmedString(value.key);
const resourcePath = asTrimmedString(value.resourcePath);
if (namespace) {
selector.namespace = namespace;
}
if (key) {
selector.key = key;
}
if (resourcePath) {
selector.resourcePath = resourcePath;
}
return selector.namespace || selector.key || selector.resourcePath ? selector : null;
};
const normalizePayload = (value: unknown): JVMAIChangePlan['payload'] | undefined => {
if (value == null) {
return undefined;
}
if (!isRecord(value)) {
return undefined;
}
const format = asTrimmedString(value.format) as NonNullable<JVMAIChangePlan['payload']>['format'];
if (!allowedPayloadFormats.has(format)) {
return undefined;
}
return {
format,
value: value.value,
};
};
const normalizePlan = (value: unknown): JVMAIChangePlan | null => {
if (!isRecord(value)) {
return null;
}
const targetType = asTrimmedString(value.targetType) as JVMAIChangePlan['targetType'];
const action = asTrimmedString(value.action) as JVMAIChangePlan['action'];
const reason = asTrimmedString(value.reason);
const selector = normalizeSelector(value.selector);
const payload = normalizePayload(value.payload);
if (!allowedTargetTypes.has(targetType) || !action || !reason || !selector) {
return null;
}
return {
targetType,
selector,
action,
payload,
reason,
};
};
const formatSnapshotValue = (snapshot?: JVMValueSnapshot | null): string => {
if (!snapshot) {
return '当前资源快照尚未加载成功。';
}
if (typeof snapshot.value === 'string') {
return snapshot.value;
}
try {
return JSON.stringify(snapshot.value ?? null, null, 2);
} catch {
return String(snapshot.value);
}
};
export const extractJVMChangePlan = (content: string): JVMAIChangePlan | null => {
const source = String(content || '');
planFencePattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = planFencePattern.exec(source)) !== null) {
try {
const parsed = JSON.parse(match[1]);
const normalized = normalizePlan(parsed);
if (normalized) {
return normalized;
}
} catch {
// Ignore malformed JSON blocks and continue scanning.
}
}
return null;
};
export const resolveJVMAIPlanResourceId = (plan: JVMAIChangePlan): string => {
const resourcePath = asTrimmedString(plan.selector.resourcePath);
if (resourcePath) {
return resourcePath;
}
const namespace = asTrimmedString(plan.selector.namespace);
const key = asTrimmedString(plan.selector.key);
return [namespace, key].filter(Boolean).join('/');
};
export const matchesJVMAIPlanTargetTab = (
tab: Pick<TabData, 'type' | 'connectionId' | 'providerMode' | 'resourcePath'>,
context?: JVMAIPlanContext,
): boolean => {
if (!context || tab.type !== 'jvm-resource') {
return false;
}
const providerMode = (tab.providerMode || 'jmx') as JVMAIPlanContext['providerMode'];
return (
tab.connectionId === context.connectionId &&
providerMode === context.providerMode &&
asTrimmedString(tab.resourcePath) === asTrimmedString(context.resourcePath)
);
};
export const resolveJVMAIPlanTargetTabId = (tabs: TabData[], context?: JVMAIPlanContext): string => {
if (!context) {
return '';
}
const exactMatch = tabs.find((tab) => tab.id === context.tabId && matchesJVMAIPlanTargetTab(tab, context));
if (exactMatch) {
return exactMatch.id;
}
const fallbackMatch = tabs.find((tab) => matchesJVMAIPlanTargetTab(tab, context));
return fallbackMatch?.id || '';
};
export const buildJVMChangeDraftFromAIPlan = (plan: JVMAIChangePlan): JVMAIChangeDraft => {
const resourceId = resolveJVMAIPlanResourceId(plan);
if (!resourceId) {
throw new Error('AI 计划缺少可用的资源定位信息');
}
const reason = asTrimmedString(plan.reason);
if (!reason) {
throw new Error('AI 计划缺少变更原因');
}
const action = asTrimmedString(plan.action);
if (!action) {
throw new Error('AI 计划缺少可执行 action');
}
if (plan.action === 'updateValue') {
const value = plan.payload?.value;
if (plan.payload?.format !== 'json' || !isRecord(value)) {
throw new Error('当前 JVM 预览要求 payload 仍然是 JSON 对象');
}
return {
resourceId,
action: 'put',
reason,
source: 'ai-plan',
payload: value as Record<string, any>,
};
}
const payloadValue = plan.payload?.value;
if (plan.payload && plan.payload.format === 'json') {
if (!isRecord(payloadValue)) {
throw new Error('当前 JVM 预览要求 payload 仍然是 JSON 对象');
}
return {
resourceId,
action,
reason,
source: 'ai-plan',
payload: payloadValue as Record<string, any>,
};
}
if (plan.payload && plan.payload.format === 'text') {
return {
resourceId,
action,
reason,
source: 'ai-plan',
payload: {
value: payloadValue == null ? '' : String(payloadValue),
},
};
}
return {
resourceId,
action,
reason,
source: 'ai-plan',
payload: {},
};
};
const formatSupportedActions = (actions?: JVMActionDefinition[]): string => {
if (!actions || actions.length === 0) {
return '当前资源未声明支持动作。若要生成计划,请仅在你能从快照内容中明确推断时给出 action并保持 payload 为 JSON 对象。';
}
return actions
.map((item) => {
const payloadFields = Array.isArray(item.payloadFields) && item.payloadFields.length > 0
? `payload 字段:${item.payloadFields.map((field) => `${field.name}${field.required ? '(required)' : ''}`).join('、')}`
: '';
return `- ${item.action}${item.label ? ` (${item.label})` : ''}${item.description ? `${item.description}` : ''}${payloadFields}`;
})
.join('\n');
};
export const buildJVMAIPlanPrompt = ({
connectionName,
host,
providerMode,
resourcePath,
readOnly,
environment,
snapshot,
}: JVMAIPlanPromptContext): string => {
const normalizedPath = asTrimmedString(resourcePath) || '(未提供资源路径)';
const snapshotFormat = asTrimmedString(snapshot?.format) || 'json';
const environmentLabel = asTrimmedString(environment) || 'unknown';
const supportedActionsText = formatSupportedActions(snapshot?.supportedActions);
return [
'请分析下面这个 JVM 资源,并生成一个可用于 GoNavi “预览变更” 的结构化修改计划。',
'',
`连接名称:${connectionName}`,
`目标主机:${asTrimmedString(host) || '-'}`,
`Provider 模式:${providerMode}`,
`运行环境:${environmentLabel}`,
`连接策略:${readOnly ? '只读连接,当前只能生成计划和风险分析,不能假设已执行' : '可写连接,但仍必须先预览再人工确认'}`,
`当前资源路径:${normalizedPath}`,
'',
'当前资源快照:',
`\`\`\`${snapshotFormat}`,
formatSnapshotValue(snapshot),
'```',
'',
'当前资源支持动作:',
supportedActionsText,
'',
'输出要求:',
'1. 可以先给一小段分析,但必须包含且只包含一个 ```json 代码块。',
'2. 代码块里的 JSON 字段必须严格是targetType、selector、action、payload、reason。',
`3. selector.resourcePath 优先使用当前资源路径 ${normalizedPath},不要凭空编造其他路径。`,
'4. action 优先从“当前资源支持动作”里选择;如果当前资源未声明支持动作,才允许基于快照内容推断。',
'5. payload 只能使用 JSON 对象包装,不要输出脚本、命令或原始二进制。若需要纯文本值,也请包装成 {"format":"text","value":"..."}。',
'6. 不要声称已经执行修改,也不要输出脚本或命令。',
'',
'JSON 示例:',
'```json',
JSON.stringify(
{
targetType: 'cacheEntry',
selector: {
resourcePath: normalizedPath,
},
action: 'put',
payload: {
format: 'json',
value: {
status: 'ACTIVE',
},
},
reason: '修复缓存脏值',
},
null,
2,
),
'```',
].join('\n');
};

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from "vitest";
import {
buildDefaultJVMConnectionValues,
buildJVMConnectionConfig,
hasUnsupportedJVMDiagnosticTransport,
hasUnsupportedJVMEditableModes,
normalizeEditableJVMModes,
resolveEditableJVMModeSelection,
} 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");
expect(values.jvmDiagnosticEnabled).toBe(false);
expect(values.jvmDiagnosticTransport).toBe("agent-bridge");
expect(values.jvmDiagnosticAllowObserveCommands).toBe(true);
expect(values.jvmDiagnosticAllowTraceCommands).toBe(false);
expect(values.jvmDiagnosticAllowMutatingCommands).toBe(false);
expect(values.jvmDiagnosticTimeoutSeconds).toBe(15);
});
it("builds nested jvm config payload", () => {
const config = buildJVMConnectionConfig({
name: "Orders JVM",
type: "jvm",
host: "orders.internal",
port: 9010,
jvmReadOnly: true,
jvmAllowedModes: ["jmx", "endpoint", "agent"],
jvmPreferredMode: "agent",
jvmEnvironment: "prod",
jvmEndpointEnabled: true,
jvmEndpointBaseUrl: "https://orders.internal/manage/jvm",
jvmEndpointApiKey: "token-1",
jvmAgentEnabled: true,
jvmAgentBaseUrl: "http://127.0.0.1:19090/gonavi/agent/jvm",
jvmAgentApiKey: "agent-token",
timeout: 45,
jvmDiagnosticEnabled: true,
jvmDiagnosticTransport: "arthas-tunnel",
jvmDiagnosticBaseUrl: "https://orders.internal/diag",
jvmDiagnosticTargetId: "orders-01",
jvmDiagnosticApiKey: "diag-token",
jvmDiagnosticAllowObserveCommands: true,
jvmDiagnosticAllowTraceCommands: true,
jvmDiagnosticAllowMutatingCommands: false,
jvmDiagnosticTimeoutSeconds: 18,
});
expect(config.jvm?.preferredMode).toBe("agent");
expect(config.jvm?.endpoint?.baseUrl).toBe(
"https://orders.internal/manage/jvm",
);
expect(config.jvm?.agent?.baseUrl).toBe(
"http://127.0.0.1:19090/gonavi/agent/jvm",
);
expect(config.jvm?.diagnostic).toEqual({
enabled: true,
transport: "arthas-tunnel",
baseUrl: "https://orders.internal/diag",
targetId: "orders-01",
apiKey: "diag-token",
allowObserveCommands: true,
allowTraceCommands: true,
allowMutatingCommands: false,
timeoutSeconds: 18,
});
});
it("normalizes allowed modes and falls back preferred mode to first allowed mode", () => {
const config = buildJVMConnectionConfig({
host: "cache.internal",
port: 9010,
jvmAllowedModes: [" Endpoint ", "invalid", "JMX", "endpoint"],
jvmPreferredMode: "AGENT",
});
expect(config.jvm?.allowedModes).toEqual(["endpoint", "jmx"]);
expect(config.jvm?.preferredMode).toBe("endpoint");
expect(config.jvm?.jmx?.enabled).toBe(true);
});
it("normalizes environment and port defaults when input is invalid", () => {
const config = buildJVMConnectionConfig({
host: "orders.internal",
port: 0,
jvmJmxPort: "",
jvmEnvironment: " PROD ",
jvmReadOnly: false,
jvmAllowedModes: ["JMX"],
jvmPreferredMode: "jmx",
});
expect(config.port).toBe(9010);
expect(config.jvm?.jmx?.port).toBe(9010);
expect(config.jvm?.environment).toBe("prod");
expect(config.jvm?.readOnly).toBe(false);
});
it("keeps endpoint timeout aligned to the visible connection timeout", () => {
const config = buildJVMConnectionConfig({
host: "orders.internal",
port: 9010,
timeout: 45,
jvmEndpointTimeoutSeconds: 30,
jvmAllowedModes: ["endpoint"],
jvmPreferredMode: "endpoint",
jvmEndpointEnabled: true,
jvmEndpointBaseUrl: "https://orders.internal/manage/jvm",
jvmDiagnosticEnabled: true,
jvmDiagnosticTransport: "arthas-tunnel",
jvmDiagnosticBaseUrl: "https://orders.internal/diag",
jvmDiagnosticTargetId: "orders-01",
jvmDiagnosticApiKey: "diag-token",
jvmDiagnosticAllowObserveCommands: true,
jvmDiagnosticAllowTraceCommands: true,
jvmDiagnosticAllowMutatingCommands: false,
jvmDiagnosticTimeoutSeconds: 18,
});
expect(config.timeout).toBe(45);
expect(config.jvm?.endpoint?.timeoutSeconds).toBe(45);
expect(config.jvm?.diagnostic?.timeoutSeconds).toBe(18);
});
it("detects unsupported diagnostic transport without silently accepting it", () => {
expect(hasUnsupportedJVMDiagnosticTransport("legacy-bridge")).toBe(true);
expect(hasUnsupportedJVMDiagnosticTransport("agent-bridge")).toBe(false);
expect(hasUnsupportedJVMDiagnosticTransport("")).toBe(false);
});
it("normalizes editable JVM modes to the supported form subset", () => {
expect(
normalizeEditableJVMModes([" endpoint ", "agent", "JMX", "endpoint"]),
).toEqual(["endpoint", "agent", "jmx"]);
});
it("detects unsupported editable JVM modes without downgrading them silently", () => {
expect(
hasUnsupportedJVMEditableModes({
allowedModes: ["agent", "jmx"],
preferredMode: "agent",
}),
).toBe(false);
expect(
hasUnsupportedJVMEditableModes({
allowedModes: ["endpoint", "jmx"],
preferredMode: "otel",
}),
).toBe(true);
expect(
hasUnsupportedJVMEditableModes({
allowedModes: ["endpoint", "jmx"],
preferredMode: "endpoint",
}),
).toBe(false);
});
it("preserves preferred mode when rebuilding editable mode selection from stored config", () => {
expect(
resolveEditableJVMModeSelection({
allowedModes: [],
preferredMode: "agent",
}),
).toEqual({
allowedModes: ["agent"],
preferredMode: "agent",
});
expect(
resolveEditableJVMModeSelection({
allowedModes: ["endpoint", "jmx"],
preferredMode: "agent",
}),
).toEqual({
allowedModes: ["endpoint", "jmx"],
preferredMode: "agent",
});
});
});

View File

@@ -0,0 +1,265 @@
import type { ConnectionConfig } from "../types";
const DEFAULT_JMX_PORT = 9010;
const DEFAULT_TIMEOUT_SECONDS = 30;
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
const DEFAULT_ENVIRONMENT = "dev";
const JVM_MODES = ["jmx", "endpoint", "agent"] as const;
export const JVM_EDITABLE_MODES = ["jmx", "endpoint", "agent"] as const;
const JVM_DIAGNOSTIC_TRANSPORTS = ["agent-bridge", "arthas-tunnel"] as const;
type JVMMode = (typeof JVM_MODES)[number];
type JVMEditableMode = (typeof JVM_EDITABLE_MODES)[number];
type JVMDiagnosticTransport = (typeof JVM_DIAGNOSTIC_TRANSPORTS)[number];
type JVMEnvironment = "dev" | "uat" | "prod";
type JVMConnectionFormValues = Record<string, unknown>;
const isJVMMode = (value: string): value is JVMMode =>
JVM_MODES.includes(value as JVMMode);
const isJVMEditableMode = (value: string): value is JVMEditableMode =>
JVM_EDITABLE_MODES.includes(value as JVMEditableMode);
const isJVMDiagnosticTransport = (
value: string,
): value is JVMDiagnosticTransport =>
JVM_DIAGNOSTIC_TRANSPORTS.includes(value as JVMDiagnosticTransport);
const toStringValue = (value: unknown): string => {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value).trim();
}
return "";
};
const toInteger = (value: unknown, fallback: number): number => {
if (value === undefined || value === null || value === "") {
return fallback;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
const intValue = Math.trunc(parsed);
return intValue > 0 ? intValue : fallback;
};
const normalizeModes = (value: unknown): JVMMode[] => {
if (!Array.isArray(value)) {
return ["jmx"];
}
const result: JVMMode[] = [];
const seen = new Set<JVMMode>();
for (const item of value) {
const mode = toStringValue(item).toLowerCase();
if (!isJVMMode(mode) || seen.has(mode)) {
continue;
}
seen.add(mode);
result.push(mode);
}
return result.length > 0 ? result : ["jmx"];
};
export const normalizeEditableJVMModes = (
value: unknown,
): JVMEditableMode[] => {
if (!Array.isArray(value)) {
return ["jmx"];
}
const result: JVMEditableMode[] = [];
const seen = new Set<JVMEditableMode>();
for (const item of value) {
const mode = toStringValue(item).toLowerCase();
if (!isJVMEditableMode(mode) || seen.has(mode)) {
continue;
}
seen.add(mode);
result.push(mode);
}
return result.length > 0 ? result : ["jmx"];
};
export const hasUnsupportedJVMEditableModes = ({
allowedModes,
preferredMode,
}: {
allowedModes: unknown;
preferredMode: unknown;
}): boolean => {
const allowed = Array.isArray(allowedModes)
? allowedModes
.map((item) => toStringValue(item).toLowerCase())
.filter((item) => item !== "")
: [];
const preferred = toStringValue(preferredMode).toLowerCase();
return (
allowed.some((mode) => !isJVMEditableMode(mode)) ||
(preferred !== "" && !isJVMEditableMode(preferred))
);
};
export const hasUnsupportedJVMDiagnosticTransport = (
value: unknown,
): boolean => {
const transport = toStringValue(value).toLowerCase();
return transport !== "" && !isJVMDiagnosticTransport(transport);
};
export const resolveEditableJVMModeSelection = ({
allowedModes,
preferredMode,
}: {
allowedModes: unknown;
preferredMode: unknown;
}): { allowedModes: string[]; preferredMode: string } => {
const normalizedAllowedModes = Array.isArray(allowedModes)
? allowedModes
.map((item) => toStringValue(item).toLowerCase())
.filter((item) => item !== "")
: [];
const normalizedPreferredMode = toStringValue(preferredMode).toLowerCase();
const resolvedAllowedModes =
normalizedAllowedModes.length > 0
? Array.from(new Set(normalizedAllowedModes))
: normalizedPreferredMode
? [normalizedPreferredMode]
: ["jmx"];
return {
allowedModes: resolvedAllowedModes,
preferredMode: normalizedPreferredMode || resolvedAllowedModes[0],
};
};
const normalizePreferredMode = (
value: unknown,
allowedModes: JVMMode[],
): JVMMode => {
const preferred = toStringValue(value).toLowerCase();
if (isJVMMode(preferred) && allowedModes.includes(preferred)) {
return preferred;
}
return allowedModes[0];
};
const normalizeEnvironment = (value: unknown): JVMEnvironment => {
const env = toStringValue(value).toLowerCase();
if (env === "uat" || env === "prod") {
return env;
}
return DEFAULT_ENVIRONMENT;
};
const normalizeReadOnly = (value: unknown): boolean => {
if (typeof value === "boolean") {
return value;
}
return true;
};
const normalizeDiagnosticTransport = (
value: unknown,
): JVMDiagnosticTransport => {
const transport = toStringValue(value).toLowerCase();
if (isJVMDiagnosticTransport(transport)) {
return transport;
}
return "agent-bridge";
};
export const buildDefaultJVMConnectionValues = () => ({
type: "jvm",
host: "localhost",
port: DEFAULT_JMX_PORT,
jvmReadOnly: true,
jvmAllowedModes: ["jmx"],
jvmPreferredMode: "jmx",
jvmEnvironment: DEFAULT_ENVIRONMENT,
jvmEndpointEnabled: false,
jvmEndpointBaseUrl: "",
jvmEndpointApiKey: "",
jvmAgentEnabled: false,
jvmAgentBaseUrl: "",
jvmAgentApiKey: "",
jvmDiagnosticEnabled: false,
jvmDiagnosticTransport: "agent-bridge",
jvmDiagnosticBaseUrl: "",
jvmDiagnosticTargetId: "",
jvmDiagnosticApiKey: "",
jvmDiagnosticAllowObserveCommands: true,
jvmDiagnosticAllowTraceCommands: false,
jvmDiagnosticAllowMutatingCommands: false,
jvmDiagnosticTimeoutSeconds: DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS,
});
export const buildJVMConnectionConfig = (
values: JVMConnectionFormValues,
): ConnectionConfig => {
const allowedModes = normalizeModes(values.jvmAllowedModes);
const preferredMode = normalizePreferredMode(
values.jvmPreferredMode,
allowedModes,
);
const port = toInteger(values.port, DEFAULT_JMX_PORT);
const timeout =
values.timeout === undefined ||
values.timeout === null ||
values.timeout === ""
? toInteger(values.jvmEndpointTimeoutSeconds, DEFAULT_TIMEOUT_SECONDS)
: toInteger(values.timeout, DEFAULT_TIMEOUT_SECONDS);
const diagnosticTimeout = toInteger(
values.jvmDiagnosticTimeoutSeconds,
DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS,
);
return {
type: "jvm",
host: toStringValue(values.host),
port,
user: "",
password: "",
timeout,
jvm: {
environment: normalizeEnvironment(values.jvmEnvironment),
readOnly: normalizeReadOnly(values.jvmReadOnly),
allowedModes,
preferredMode,
jmx: {
enabled: allowedModes.includes("jmx"),
host: toStringValue(values.jvmJmxHost) || toStringValue(values.host),
port: toInteger(values.jvmJmxPort, port),
username: toStringValue(values.jvmJmxUsername),
password: toStringValue(values.jvmJmxPassword),
},
endpoint: {
enabled: values.jvmEndpointEnabled === true,
baseUrl: toStringValue(values.jvmEndpointBaseUrl),
apiKey: toStringValue(values.jvmEndpointApiKey),
timeoutSeconds: timeout,
},
agent: {
enabled: values.jvmAgentEnabled === true,
baseUrl: toStringValue(values.jvmAgentBaseUrl),
apiKey: toStringValue(values.jvmAgentApiKey),
timeoutSeconds: timeout,
},
diagnostic: {
enabled: values.jvmDiagnosticEnabled === true,
transport: normalizeDiagnosticTransport(values.jvmDiagnosticTransport),
baseUrl: toStringValue(values.jvmDiagnosticBaseUrl),
targetId: toStringValue(values.jvmDiagnosticTargetId),
apiKey: toStringValue(values.jvmDiagnosticApiKey),
allowObserveCommands: values.jvmDiagnosticAllowObserveCommands !== false,
allowTraceCommands: values.jvmDiagnosticAllowTraceCommands === true,
allowMutatingCommands:
values.jvmDiagnosticAllowMutatingCommands === true,
timeoutSeconds: diagnosticTimeout,
},
},
};
};

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
resolveJVMDiagnosticCompletionItems,
resolveJVMDiagnosticCompletionMode,
} from "./jvmDiagnosticCompletion";
describe("jvmDiagnosticCompletion", () => {
it("suggests command keywords when typing the first token", () => {
const items = resolveJVMDiagnosticCompletionItems("t");
expect(items.some((item) => item.label === "thread")).toBe(true);
expect(items.some((item) => item.label === "trace")).toBe(true);
});
it("suggests the jvm command from the command input hint", () => {
const items = resolveJVMDiagnosticCompletionItems("jv");
expect(items.some((item) => item.label === "jvm")).toBe(true);
});
it("switches to argument mode after the command head", () => {
expect(resolveJVMDiagnosticCompletionMode("thread -")).toEqual({
head: "thread",
mode: "argument",
search: "-",
});
});
it("returns command-specific snippets for trace style commands", () => {
const items = resolveJVMDiagnosticCompletionItems("watch ");
expect(items.some((item) => item.label === "watch 模板")).toBe(true);
expect(items.some((item) => item.label === "展开层级 -x 2")).toBe(true);
expect(items.every((item) => item.scope === "argument")).toBe(true);
});
it("supports multiline commands by using the current line before cursor", () => {
const items = resolveJVMDiagnosticCompletionItems(
"thread -n 5\nclas",
);
expect(items.some((item) => item.label === "classloader")).toBe(true);
expect(items.some((item) => item.label === "watch")).toBe(false);
});
it("falls back to command suggestions for unknown heads", () => {
const items = resolveJVMDiagnosticCompletionItems("unknown ");
expect(items.some((item) => item.label === "dashboard")).toBe(true);
expect(items.some((item) => item.label === "thread")).toBe(true);
});
});

View File

@@ -0,0 +1,499 @@
import { JVM_DIAGNOSTIC_COMMAND_PRESETS } from "./jvmDiagnosticPresentation";
export type JVMDiagnosticCompletionMode = "command" | "argument";
export interface JVMDiagnosticCompletionState {
mode: JVMDiagnosticCompletionMode;
head: string;
search: string;
}
export interface JVMDiagnosticCompletionItem {
label: string;
insertText: string;
detail: string;
documentation?: string;
scope: JVMDiagnosticCompletionMode;
isSnippet?: boolean;
}
type DiagnosticCommandDefinition = {
head: string;
detail: string;
documentation: string;
};
const BASE_COMMAND_DEFINITIONS: DiagnosticCommandDefinition[] = [
{
head: "dashboard",
detail: "观察类命令",
documentation: "查看 JVM 运行总览。",
},
{
head: "jvm",
detail: "观察类命令",
documentation: "查看 JVM 内存、线程、类加载、GC 和运行参数信息。",
},
{
head: "thread",
detail: "观察类命令",
documentation: "查看热点线程、线程栈和阻塞线程。",
},
{
head: "sc",
detail: "观察类命令",
documentation: "搜索匹配类信息。",
},
{
head: "sm",
detail: "观察类命令",
documentation: "查看类的方法签名。",
},
{
head: "jad",
detail: "观察类命令",
documentation: "反编译指定类。",
},
{
head: "sysprop",
detail: "观察类命令",
documentation: "查看系统属性。",
},
{
head: "sysenv",
detail: "观察类命令",
documentation: "查看环境变量。",
},
{
head: "classloader",
detail: "观察类命令",
documentation: "查看类加载器信息。",
},
{
head: "trace",
detail: "跟踪类命令",
documentation: "跟踪方法调用耗时路径。",
},
{
head: "watch",
detail: "跟踪类命令",
documentation: "观察入参、返回值或异常。",
},
{
head: "stack",
detail: "跟踪类命令",
documentation: "输出方法调用栈。",
},
{
head: "monitor",
detail: "跟踪类命令",
documentation: "周期性统计方法调用。",
},
{
head: "tt",
detail: "跟踪类命令",
documentation: "方法时光隧道,记录和回放调用。",
},
{
head: "ognl",
detail: "高风险命令",
documentation: "执行 OGNL 表达式,默认需要额外授权。",
},
{
head: "vmtool",
detail: "高风险命令",
documentation: "直接操作 JVM 对象或执行 VMTool 动作。",
},
{
head: "redefine",
detail: "高风险命令",
documentation: "重新定义类字节码。",
},
{
head: "retransform",
detail: "高风险命令",
documentation: "重新触发类转换。",
},
{
head: "stop",
detail: "控制命令",
documentation: "停止当前后台任务。",
},
];
const buildBaseCommandItems = (): JVMDiagnosticCompletionItem[] => {
const itemsByHead = new Map<string, JVMDiagnosticCompletionItem>();
BASE_COMMAND_DEFINITIONS.forEach((item) => {
itemsByHead.set(item.head, {
label: item.head,
insertText: item.head,
detail: item.detail,
documentation: item.documentation,
scope: "command",
});
});
JVM_DIAGNOSTIC_COMMAND_PRESETS.forEach((item) => {
const head = item.command.split(/\s+/, 1)[0]?.trim().toLowerCase() || item.label;
if (itemsByHead.has(head)) {
return;
}
itemsByHead.set(head, {
label: head,
insertText: head,
detail: `${item.category} 命令`,
documentation: item.description,
scope: "command",
});
});
return Array.from(itemsByHead.values());
};
const BASE_COMMAND_ITEMS = buildBaseCommandItems();
const ARGUMENT_ITEMS_BY_HEAD: Record<string, JVMDiagnosticCompletionItem[]> = {
dashboard: [
{
label: "dashboard",
insertText: "",
detail: "直接执行",
documentation: "查看当前 JVM 运行总览。",
scope: "argument",
},
],
jvm: [
{
label: "jvm",
insertText: "",
detail: "直接执行",
documentation: "查看 JVM 内存、线程、类加载、GC 和运行参数信息。",
scope: "argument",
},
],
thread: [
{
label: "繁忙线程 TOP N (-n)",
insertText: "-n ${1:5}",
detail: "线程参数",
documentation: "查看 CPU 最繁忙的前 N 个线程。",
scope: "argument",
isSnippet: true,
},
{
label: "阻塞线程 (-b)",
insertText: "-b",
detail: "线程参数",
documentation: "查找当前阻塞其他线程的线程。",
scope: "argument",
},
{
label: "指定线程 ID",
insertText: "${1:1}",
detail: "线程参数",
documentation: "查看指定线程的详细栈信息。",
scope: "argument",
isSnippet: true,
},
],
sc: [
{
label: "类匹配模板",
insertText: "${1:com.foo.*}",
detail: "类搜索模板",
documentation: "按类名模式搜索。",
scope: "argument",
isSnippet: true,
},
{
label: "详细模式 (-d)",
insertText: "-d ${1:com.foo.OrderService}",
detail: "类搜索模板",
documentation: "输出类的详细信息。",
scope: "argument",
isSnippet: true,
},
],
sm: [
{
label: "方法签名模板",
insertText: "${1:com.foo.OrderService} ${2:submitOrder}",
detail: "方法搜索模板",
documentation: "查看类的方法签名。",
scope: "argument",
isSnippet: true,
},
{
label: "详细模式 (-d)",
insertText: "-d ${1:com.foo.OrderService} ${2:submitOrder}",
detail: "方法搜索模板",
documentation: "输出方法详细签名。",
scope: "argument",
isSnippet: true,
},
],
jad: [
{
label: "反编译模板",
insertText: "${1:com.foo.OrderService}",
detail: "反编译模板",
documentation: "反编译指定类。",
scope: "argument",
isSnippet: true,
},
],
sysprop: [
{
label: "查看属性",
insertText: "${1:java.version}",
detail: "系统属性模板",
documentation: "读取指定系统属性。",
scope: "argument",
isSnippet: true,
},
],
sysenv: [
{
label: "查看环境变量",
insertText: "${1:JAVA_HOME}",
detail: "环境变量模板",
documentation: "读取指定环境变量。",
scope: "argument",
isSnippet: true,
},
],
classloader: [
{
label: "树形视图 (-t)",
insertText: "-t",
detail: "类加载器模板",
documentation: "输出类加载器树形结构。",
scope: "argument",
},
{
label: "全部 URL 统计 (--url-stat)",
insertText: "--url-stat",
detail: "类加载器模板",
documentation: "查看类加载器 URL 统计。",
scope: "argument",
},
{
label: "指定类加载器 Hash",
insertText: "${1:19469ea2}",
detail: "类加载器模板",
documentation: "查看指定类加载器详情。",
scope: "argument",
isSnippet: true,
},
],
trace: [
{
label: "trace 模板",
insertText: "${1:com.foo.OrderService} ${2:submitOrder} '${3:#cost > 100}'",
detail: "跟踪模板",
documentation: "跟踪慢方法调用链路。",
scope: "argument",
isSnippet: true,
},
{
label: "条件过滤 '#cost > 100'",
insertText: "'${1:#cost > 100}'",
detail: "跟踪参数",
documentation: "追加 trace 条件表达式。",
scope: "argument",
isSnippet: true,
},
],
watch: [
{
label: "watch 模板",
insertText:
"${1:com.foo.OrderService} ${2:submitOrder} '${3:{params,returnObj}}' -x ${4:2}",
detail: "观察模板",
documentation: "观察入参、返回值或异常。",
scope: "argument",
isSnippet: true,
},
{
label: "展开层级 -x 2",
insertText: "-x ${1:2}",
detail: "观察参数",
documentation: "设置对象展开层级。",
scope: "argument",
isSnippet: true,
},
],
stack: [
{
label: "stack 模板",
insertText: "${1:com.foo.OrderService} ${2:submitOrder} '${3:#cost > 100}'",
detail: "调用栈模板",
documentation: "输出方法调用栈。",
scope: "argument",
isSnippet: true,
},
],
monitor: [
{
label: "monitor 模板",
insertText: "${1:com.foo.OrderService} ${2:submitOrder} -c ${3:5}",
detail: "监控模板",
documentation: "按周期统计方法调用情况。",
scope: "argument",
isSnippet: true,
},
],
tt: [
{
label: "tt 录制模板",
insertText: "-t ${1:com.foo.OrderService} ${2:submitOrder}",
detail: "时光隧道模板",
documentation: "录制指定方法调用。",
scope: "argument",
isSnippet: true,
},
{
label: "查看记录列表 (-l)",
insertText: "-l",
detail: "时光隧道模板",
documentation: "查看当前录制列表。",
scope: "argument",
},
{
label: "回放记录 (-i)",
insertText: "-i ${1:1000} -p",
detail: "时光隧道模板",
documentation: "查看指定记录详情。",
scope: "argument",
isSnippet: true,
},
],
ognl: [
{
label: "ognl 模板",
insertText: "'${1:@java.lang.System@getProperty(\"user.dir\")}'",
detail: "高风险模板",
documentation: "执行 OGNL 表达式,高风险命令默认受策略限制。",
scope: "argument",
isSnippet: true,
},
],
vmtool: [
{
label: "vmtool getInstances",
insertText:
"--action getInstances --className ${1:com.foo.OrderService} --limit ${2:10}",
detail: "高风险模板",
documentation: "获取指定类实例,高风险命令默认受策略限制。",
scope: "argument",
isSnippet: true,
},
],
redefine: [
{
label: "redefine 模板",
insertText: "${1:/tmp/OrderService.class}",
detail: "高风险模板",
documentation: "重新定义类字节码文件路径。",
scope: "argument",
isSnippet: true,
},
],
retransform: [
{
label: "retransform 模板",
insertText: "${1:com.foo.OrderService}",
detail: "高风险模板",
documentation: "重新转换指定类。",
scope: "argument",
isSnippet: true,
},
],
stop: [
{
label: "stop",
insertText: "",
detail: "控制命令",
documentation: "停止当前后台任务。",
scope: "argument",
},
],
};
const COMMAND_HEAD_SET = new Set(
BASE_COMMAND_ITEMS.map((item) => item.label.toLowerCase()),
);
const normalizeSearchText = (value: string): string =>
String(value || "").trim().toLowerCase();
const resolveCurrentLine = (textBeforeCursor: string): string =>
String(textBeforeCursor || "").split(/\r?\n/).pop() || "";
const matchesSearch = (
item: JVMDiagnosticCompletionItem,
search: string,
): boolean => {
if (!search) {
return true;
}
const normalizedSearch = normalizeSearchText(search);
const candidates = [item.label, item.insertText, item.detail];
return candidates.some((candidate) =>
String(candidate || "").toLowerCase().includes(normalizedSearch),
);
};
export const resolveJVMDiagnosticCompletionMode = (
textBeforeCursor: string,
): JVMDiagnosticCompletionState => {
const currentLine = resolveCurrentLine(textBeforeCursor);
const normalizedLine = currentLine.replace(/^\s+/, "");
if (!normalizedLine) {
return {
mode: "command",
head: "",
search: "",
};
}
const head = normalizedLine.split(/\s+/, 1)[0]?.toLowerCase() || "";
const hasWhitespaceAfterHead = /\s/.test(normalizedLine);
if (!hasWhitespaceAfterHead) {
return {
mode: "command",
head,
search: head,
};
}
const search = (normalizedLine.match(/([^\s]*)$/)?.[1] || "").toLowerCase();
if (COMMAND_HEAD_SET.has(head)) {
return {
mode: "argument",
head,
search,
};
}
return {
mode: "command",
head: "",
search,
};
};
export const resolveJVMDiagnosticCompletionItems = (
textBeforeCursor: string,
): JVMDiagnosticCompletionItem[] => {
const state = resolveJVMDiagnosticCompletionMode(textBeforeCursor);
const source =
state.mode === "argument" && state.head
? ARGUMENT_ITEMS_BY_HEAD[state.head] || []
: BASE_COMMAND_ITEMS;
return source.filter((item) => matchesSearch(item, state.search));
};

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
parseJVMDiagnosticPlan,
resolveJVMDiagnosticPlanTargetTabId,
} from "./jvmDiagnosticPlan";
describe("jvmDiagnosticPlan", () => {
it("parses arthas-style diagnostic plan payload", () => {
const plan = parseJVMDiagnosticPlan(`{
"intent": "trace_slow_method",
"transport": "agent-bridge",
"command": "trace com.foo.OrderService submitOrder '#cost > 100'",
"riskLevel": "medium",
"reason": "定位慢调用"
}`);
expect(plan?.command).toContain("trace com.foo.OrderService");
expect(plan?.riskLevel).toBe("medium");
});
it("parses fenced json blocks mixed with analysis text", () => {
const plan = parseJVMDiagnosticPlan(
[
"建议先观察再做下一步:",
"```json",
'{"intent":"dump_threads","transport":"arthas-tunnel","command":"thread -n 5","riskLevel":"low","reason":"观察阻塞线程","expectedSignals":["Top N busy threads"]}',
"```",
].join("\n"),
);
expect(plan).toEqual({
intent: "dump_threads",
transport: "arthas-tunnel",
command: "thread -n 5",
riskLevel: "low",
reason: "观察阻塞线程",
expectedSignals: ["Top N busy threads"],
});
});
it("returns null for malformed diagnostic payload", () => {
expect(parseJVMDiagnosticPlan('{"command":1}')).toBeNull();
});
});
describe("resolveJVMDiagnosticPlanTargetTabId", () => {
it("prefers the original diagnostic tab when context still matches", () => {
expect(
resolveJVMDiagnosticPlanTargetTabId(
[
{
id: "tab-diagnostic",
title: "诊断控制台",
type: "jvm-diagnostic",
connectionId: "conn-orders",
},
],
[
{
id: "conn-orders",
config: {
type: "jvm",
host: "orders.internal",
port: 9010,
user: "",
jvm: {
diagnostic: {
transport: "agent-bridge",
},
},
},
},
],
{
tabId: "tab-diagnostic",
connectionId: "conn-orders",
transport: "agent-bridge",
},
),
).toBe("tab-diagnostic");
});
it("rejects fallback tabs whose connection transport does not match", () => {
expect(
resolveJVMDiagnosticPlanTargetTabId(
[
{
id: "tab-diagnostic",
title: "诊断控制台",
type: "jvm-diagnostic",
connectionId: "conn-orders",
},
],
[
{
id: "conn-orders",
config: {
type: "jvm",
host: "orders.internal",
port: 9010,
user: "",
jvm: {
diagnostic: {
transport: "arthas-tunnel",
},
},
},
},
],
{
tabId: "tab-missing",
connectionId: "conn-orders",
transport: "agent-bridge",
},
),
).toBe("");
});
});

View File

@@ -0,0 +1,135 @@
import type {
JVMDiagnosticPlan,
JVMDiagnosticPlanContext,
SavedConnection,
TabData,
} from "../types";
const planFencePattern = /```json\s*([\s\S]*?)```/gi;
const allowedTransports = new Set<JVMDiagnosticPlan["transport"]>([
"agent-bridge",
"arthas-tunnel",
]);
const allowedRiskLevels = new Set<JVMDiagnosticPlan["riskLevel"]>([
"low",
"medium",
"high",
]);
const asTrimmedString = (value: unknown): string => String(value ?? "").trim();
const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
const normalizeTransport = (value: unknown): JVMDiagnosticPlan["transport"] => {
const transport = asTrimmedString(value) as JVMDiagnosticPlan["transport"];
return allowedTransports.has(transport) ? transport : "agent-bridge";
};
const normalizeRiskLevel = (value: unknown): JVMDiagnosticPlan["riskLevel"] => {
const riskLevel = asTrimmedString(value) as JVMDiagnosticPlan["riskLevel"];
return allowedRiskLevels.has(riskLevel) ? riskLevel : "low";
};
const normalizePlan = (value: unknown): JVMDiagnosticPlan | null => {
if (!isRecord(value)) {
return null;
}
if (typeof value.command !== "string") {
return null;
}
const command = asTrimmedString(value.command);
if (!command) {
return null;
}
const intent = asTrimmedString(value.intent) || "generic_diagnostic";
const reason = asTrimmedString(value.reason) || `AI 诊断计划:${intent}`;
return {
intent,
transport: normalizeTransport(value.transport),
command,
riskLevel: normalizeRiskLevel(value.riskLevel),
reason,
expectedSignals: Array.isArray(value.expectedSignals)
? value.expectedSignals
.map((item) => asTrimmedString(item))
.filter(Boolean)
: [],
};
};
const tryParsePlan = (content: string): JVMDiagnosticPlan | null => {
try {
return normalizePlan(JSON.parse(content));
} catch {
return null;
}
};
const resolveDiagnosticTransport = (
connection?: Pick<SavedConnection, "config">,
): JVMDiagnosticPlan["transport"] =>
normalizeTransport(connection?.config?.jvm?.diagnostic?.transport);
export const parseJVMDiagnosticPlan = (
content: string,
): JVMDiagnosticPlan | null => {
const source = String(content || "").trim();
if (!source) {
return null;
}
planFencePattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = planFencePattern.exec(source)) !== null) {
const parsed = tryParsePlan(match[1]);
if (parsed) {
return parsed;
}
}
return tryParsePlan(source);
};
export const matchesJVMDiagnosticPlanTargetTab = (
tab: Pick<TabData, "id" | "type" | "connectionId">,
connections: Pick<SavedConnection, "id" | "config">[],
context?: JVMDiagnosticPlanContext,
): boolean => {
if (!context || tab.type !== "jvm-diagnostic") {
return false;
}
const connection = connections.find((item) => item.id === tab.connectionId);
return (
tab.connectionId === context.connectionId &&
resolveDiagnosticTransport(connection) === normalizeTransport(context.transport)
);
};
export const resolveJVMDiagnosticPlanTargetTabId = (
tabs: TabData[],
connections: Pick<SavedConnection, "id" | "config">[],
context?: JVMDiagnosticPlanContext,
): string => {
if (!context) {
return "";
}
const exactMatch = tabs.find(
(tab) =>
tab.id === context.tabId &&
matchesJVMDiagnosticPlanTargetTab(tab, connections, context),
);
if (exactMatch) {
return exactMatch.id;
}
const fallbackMatch = tabs.find((tab) =>
matchesJVMDiagnosticPlanTargetTab(tab, connections, context),
);
return fallbackMatch?.id || "";
};

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
formatJVMDiagnosticChunkText,
formatJVMDiagnosticCommandTypeLabel,
formatJVMDiagnosticPhaseLabel,
formatJVMDiagnosticRiskLabel,
formatJVMDiagnosticSourceLabel,
formatJVMDiagnosticTransportLabel,
groupJVMDiagnosticPresets,
resolveJVMDiagnosticRiskColor,
} from "./jvmDiagnosticPresentation";
describe("jvmDiagnosticPresentation", () => {
it("groups presets by category in a stable order", () => {
const groups = groupJVMDiagnosticPresets();
expect(groups.map((group) => group.label)).toEqual([
"观察类命令",
"跟踪类命令",
"高风险命令",
]);
expect(groups[0].items.some((item) => item.label === "thread")).toBe(true);
});
it("formats chunk text with localized phase prefix when content exists", () => {
expect(
formatJVMDiagnosticChunkText({
sessionId: "sess-1",
phase: "running",
content: "thread -n 5",
}),
).toBe("执行中thread -n 5");
});
it("localizes diagnostic status, transport, risk and source labels", () => {
expect(formatJVMDiagnosticPhaseLabel("completed")).toBe("已完成");
expect(formatJVMDiagnosticTransportLabel("arthas-tunnel")).toBe("Arthas Tunnel");
expect(formatJVMDiagnosticRiskLabel("high")).toBe("高风险");
expect(formatJVMDiagnosticCommandTypeLabel("trace")).toBe("跟踪类");
expect(formatJVMDiagnosticSourceLabel("ai-plan")).toBe("AI 计划");
});
it("maps risk levels to tag colors", () => {
expect(resolveJVMDiagnosticRiskColor("low")).toBe("green");
expect(resolveJVMDiagnosticRiskColor("medium")).toBe("gold");
expect(resolveJVMDiagnosticRiskColor("high")).toBe("red");
});
});

View File

@@ -0,0 +1,180 @@
import type { JVMDiagnosticEventChunk } from "../types";
export type JVMDiagnosticPresetCategory = "observe" | "trace" | "mutating";
export interface JVMDiagnosticCommandPreset {
key: string;
label: string;
category: JVMDiagnosticPresetCategory;
command: string;
description: string;
riskLevel: "low" | "medium" | "high";
}
export const JVM_DIAGNOSTIC_COMMAND_PRESETS: JVMDiagnosticCommandPreset[] = [
{
key: "thread-top",
label: "thread",
category: "observe",
command: "thread -n 5",
description: "查看最繁忙线程,快速定位阻塞或高 CPU 线程。",
riskLevel: "low",
},
{
key: "dashboard",
label: "dashboard",
category: "observe",
command: "dashboard",
description: "查看 JVM 运行总览。",
riskLevel: "low",
},
{
key: "trace-slow-method",
label: "trace",
category: "trace",
command: "trace com.foo.OrderService submitOrder '#cost > 100'",
description: "跟踪慢方法调用路径。",
riskLevel: "medium",
},
{
key: "watch-return",
label: "watch",
category: "trace",
command: "watch com.foo.OrderService submitOrder '{params,returnObj}' -x 2",
description: "观察入参与返回值。",
riskLevel: "medium",
},
{
key: "ognl-sample",
label: "ognl",
category: "mutating",
command: "ognl '@java.lang.System@getProperty(\"user.dir\")'",
description: "高风险表达式命令,默认只作示意。",
riskLevel: "high",
},
];
const CATEGORY_LABELS: Record<JVMDiagnosticPresetCategory, string> = {
observe: "观察类命令",
trace: "跟踪类命令",
mutating: "高风险命令",
};
const RISK_COLORS: Record<"low" | "medium" | "high", string> = {
low: "green",
medium: "gold",
high: "red",
};
const PHASE_LABELS: Record<string, string> = {
running: "执行中",
completed: "已完成",
failed: "失败",
canceled: "已取消",
canceling: "取消中",
diagnostic: "诊断事件",
};
const EVENT_LABELS: Record<string, string> = {
diagnostic: "诊断输出",
chunk: "输出片段",
done: "执行结束",
};
const TRANSPORT_LABELS: Record<string, string> = {
"agent-bridge": "Agent Bridge",
"arthas-tunnel": "Arthas Tunnel",
};
const RISK_LABELS: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
};
const COMMAND_TYPE_LABELS: Record<string, string> = {
observe: "观察类",
trace: "跟踪类",
mutating: "高风险类",
};
const SOURCE_LABELS: Record<string, string> = {
manual: "手动输入",
"ai-plan": "AI 计划",
};
export const formatJVMDiagnosticPresetCategory = (
category: JVMDiagnosticPresetCategory,
): string => CATEGORY_LABELS[category];
export const resolveJVMDiagnosticRiskColor = (
riskLevel: "low" | "medium" | "high",
): string => RISK_COLORS[riskLevel];
const normalizeLabelKey = (value?: string | null): string =>
String(value || "").trim().toLowerCase();
const formatWithFallback = (
value: string | undefined | null,
labels: Record<string, string>,
fallback = "未知",
): string => {
const normalized = normalizeLabelKey(value);
if (!normalized) {
return fallback;
}
return labels[normalized] || String(value || "").trim();
};
export const formatJVMDiagnosticPhaseLabel = (phase?: string | null): string =>
formatWithFallback(phase, PHASE_LABELS);
export const formatJVMDiagnosticEventLabel = (event?: string | null): string =>
formatWithFallback(event, EVENT_LABELS);
export const formatJVMDiagnosticTransportLabel = (
transport?: string | null,
): string => formatWithFallback(transport, TRANSPORT_LABELS);
export const formatJVMDiagnosticRiskLabel = (risk?: string | null): string =>
formatWithFallback(risk, RISK_LABELS);
export const formatJVMDiagnosticCommandTypeLabel = (
type?: string | null,
): string => formatWithFallback(type, COMMAND_TYPE_LABELS);
export const formatJVMDiagnosticSourceLabel = (source?: string | null): string =>
formatWithFallback(source, SOURCE_LABELS);
export const groupJVMDiagnosticPresets = (
presets: JVMDiagnosticCommandPreset[] = JVM_DIAGNOSTIC_COMMAND_PRESETS,
): Array<{
category: JVMDiagnosticPresetCategory;
label: string;
items: JVMDiagnosticCommandPreset[];
}> =>
(["observe", "trace", "mutating"] as const).map((category) => ({
category,
label: formatJVMDiagnosticPresetCategory(category),
items: presets.filter((item) => item.category === category),
}));
export const formatJVMDiagnosticChunkText = (
chunk: JVMDiagnosticEventChunk,
): string => {
const rawPhase = String(chunk.phase || chunk.event || "").trim();
const phase = chunk.phase
? formatJVMDiagnosticPhaseLabel(chunk.phase)
: formatJVMDiagnosticEventLabel(chunk.event);
const content = String(chunk.content || "").trim();
if (!rawPhase && !content) {
return "空事件";
}
if (!rawPhase) {
return content;
}
if (!content) {
return phase;
}
return `${phase}${content}`;
};

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import {
buildMonitoringAvailabilityText,
formatMonitoringAxisBytes,
formatRecentGCLabel,
normalizeMonitoringProviderMode,
} from "./jvmMonitoringPresentation";
describe("jvmMonitoringPresentation", () => {
it("summarizes degraded metrics with missing items and warnings", () => {
expect(
buildMonitoringAvailabilityText({
missingMetrics: ["cpu.process", "memory.rss"],
providerWarnings: ["endpoint cpu metric unavailable"],
}),
).toContain("缺失指标");
});
it("formats recent gc event label with duration", () => {
expect(
formatRecentGCLabel({
timestamp: 1713945600000,
name: "G1 Young Generation",
durationMs: 21,
}),
).toContain("21ms");
});
it("formats byte axis ticks with compact units instead of raw byte numbers", () => {
expect(formatMonitoringAxisBytes(120_000_000)).toBe("114 MB");
expect(formatMonitoringAxisBytes(0)).toBe("0 B");
expect(formatMonitoringAxisBytes(undefined)).toBe("--");
});
it("normalizes provider mode and falls back on unknown values", () => {
expect(normalizeMonitoringProviderMode("AGENT", "jmx")).toBe("agent");
expect(normalizeMonitoringProviderMode("unsupported", "endpoint")).toBe("endpoint");
expect(normalizeMonitoringProviderMode(undefined, "jmx")).toBe("jmx");
});
});

View File

@@ -0,0 +1,176 @@
import type {
JVMMonitoringPoint,
JVMMonitoringRecentGCEvent,
JVMMonitoringSessionState,
} from "../types";
const METRIC_LABELS: Record<string, string> = {
"heap.used": "堆内存",
"heap.non_heap": "非堆内存",
"gc.count": "垃圾回收次数",
"gc.time": "垃圾回收耗时",
"gc.events": "最近垃圾回收事件",
"thread.count": "线程数",
"thread.states": "线程状态",
"class.loading": "类加载",
"cpu.process": "进程 CPU",
"cpu.system": "系统 CPU",
"memory.rss": "进程物理内存",
"memory.virtual": "进程虚拟内存",
};
export type JVMMonitoringProviderMode = JVMMonitoringSessionState["providerMode"];
const MONITORING_PROVIDER_MODES: JVMMonitoringProviderMode[] = [
"jmx",
"endpoint",
"agent",
];
const THREAD_STATE_LABELS: Record<string, string> = {
NEW: "新建",
RUNNABLE: "可运行",
BLOCKED: "阻塞",
WAITING: "等待中",
TIMED_WAITING: "限时等待",
TERMINATED: "已终止",
};
const timeFormatter = new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
export type MonitoringChartPoint = JVMMonitoringPoint & {
timeLabel: string;
};
export const resolveMonitoringMetricLabel = (metric: string): string =>
METRIC_LABELS[String(metric || "").trim()] || String(metric || "").trim();
export const resolveThreadStateLabel = (state?: string | null): string => {
const normalized = String(state || "").trim().toUpperCase();
return THREAD_STATE_LABELS[normalized] || String(state || "").trim();
};
export const formatMonitoringTime = (timestamp?: number): string => {
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
return "--";
}
return timeFormatter.format(new Date(timestamp));
};
export const formatBytes = (value?: number): string => {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return "--";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let next = value;
let unitIndex = 0;
while (next >= 1024 && unitIndex < units.length - 1) {
next /= 1024;
unitIndex += 1;
}
const precision = next >= 100 || unitIndex === 0 ? 0 : next >= 10 ? 1 : 2;
return `${next.toFixed(precision)} ${units[unitIndex]}`;
};
export const formatMonitoringAxisBytes = (value?: number): string => formatBytes(value);
export const formatPercent = (value?: number): string => {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return "--";
}
return `${(value * 100).toFixed(1)}%`;
};
export const formatCompactNumber = (value?: number): string => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return "--";
}
return value.toLocaleString("zh-CN");
};
export const formatDurationMs = (value?: number): string => {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return "--";
}
return `${Math.round(value)}ms`;
};
export const normalizeMonitoringProviderMode = (
value: unknown,
fallback: JVMMonitoringProviderMode = "jmx",
): JVMMonitoringProviderMode => {
const normalized = String(value || "").trim().toLowerCase();
if (MONITORING_PROVIDER_MODES.includes(normalized as JVMMonitoringProviderMode)) {
return normalized as JVMMonitoringProviderMode;
}
return MONITORING_PROVIDER_MODES.includes(fallback) ? fallback : "jmx";
};
export const buildMonitoringAvailabilityText = ({
missingMetrics,
providerWarnings,
}: Pick<JVMMonitoringSessionState, "missingMetrics" | "providerWarnings">): string => {
const fragments: string[] = [];
if (Array.isArray(missingMetrics) && missingMetrics.length > 0) {
fragments.push(
`缺失指标:${missingMetrics
.map((metric) => resolveMonitoringMetricLabel(metric))
.join("、")}`,
);
}
if (Array.isArray(providerWarnings) && providerWarnings.length > 0) {
fragments.push(`监控来源告警:${providerWarnings.join("")}`);
}
if (fragments.length === 0) {
return "当前监控会话未发现明显降级。";
}
return fragments.join(" | ");
};
export const formatRecentGCLabel = (
event: JVMMonitoringRecentGCEvent,
): string => {
const parts = [
formatMonitoringTime(event.timestamp),
String(event.name || "").trim(),
typeof event.durationMs === "number" ? `${event.durationMs}ms` : "",
String(event.cause || "").trim(),
].filter(Boolean);
return parts.join(" · ");
};
export const buildMonitoringChartPoints = (
points: JVMMonitoringPoint[] = [],
): MonitoringChartPoint[] =>
points.map((point) => ({
...point,
timeLabel: formatMonitoringTime(point.timestamp),
}));
export const extractThreadStateRows = (
point?: JVMMonitoringPoint,
): Array<{ state: string; label: string; count: number }> =>
Object.entries(point?.threadStateCounts || {})
.map(([state, count]) => ({
state,
label: resolveThreadStateLabel(state),
count: Number(count) || 0,
}))
.sort((left, right) => right.count - left.count);
export const monitoringMetricAvailable = (
session: Pick<JVMMonitoringSessionState, "availableMetrics"> | undefined,
metric: string,
): boolean =>
Array.isArray(session?.availableMetrics) &&
session.availableMetrics.includes(metric);

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
estimateJVMResourceEditorHeight,
formatJVMAuditResultLabel,
formatJVMActionSummary,
formatJVMRiskLevelText,
resolveJVMAuditResultColor,
resolveJVMActionDisplay,
resolveJVMValueEditorLanguage,
} from "./jvmResourcePresentation";
describe("jvmResourcePresentation", () => {
it("provides a localized fallback label for built-in JVM actions", () => {
expect(resolveJVMActionDisplay({ action: "set" })).toMatchObject({
action: "set",
label: "设置属性",
});
});
it("keeps provider-supplied action labels when they already exist", () => {
expect(
resolveJVMActionDisplay({
action: "invoke",
label: "执行重置",
description: "调用 reset 操作",
}),
).toEqual({
action: "invoke",
label: "执行重置",
description: "调用 reset 操作",
});
});
it("formats the supported action summary with both localized label and code", () => {
expect(
formatJVMActionSummary([
{ action: "set" },
{ action: "invoke", label: "执行重置" },
]),
).toBe("设置属性set, 执行重置invoke");
});
it("localizes risk levels and audit result states", () => {
expect(formatJVMRiskLevelText("medium")).toBe("中");
expect(formatJVMRiskLevelText("")).toBe("未知");
expect(formatJVMAuditResultLabel("applied")).toBe("已执行");
expect(formatJVMAuditResultLabel("error")).toBe("失败");
expect(resolveJVMAuditResultColor("warning")).toBe("gold");
});
it("uses json mode for structured snapshots", () => {
expect(resolveJVMValueEditorLanguage("json", { name: "orders" })).toBe(
"json",
);
expect(resolveJVMValueEditorLanguage("array", [{ id: 1 }])).toBe("json");
});
it("detects JSON-looking strings so the preview can use the structured editor", () => {
expect(
resolveJVMValueEditorLanguage("string", '{\"name\":\"orders\"}'),
).toBe("json");
});
it("falls back to plaintext for ordinary string values", () => {
expect(resolveJVMValueEditorLanguage("string", "cache-enabled")).toBe(
"plaintext",
);
});
it("caps editor height for very long payloads while keeping short content compact", () => {
expect(estimateJVMResourceEditorHeight("line-1")).toBe(180);
expect(
estimateJVMResourceEditorHeight(new Array(80).fill("line").join("\n")),
).toBe(420);
});
});

View File

@@ -0,0 +1,238 @@
import type { JVMActionDefinition } from "../types";
type JVMActionDisplay = {
action: string;
label: string;
description?: string;
};
const ACTION_FALLBACK_META: Record<
string,
{ label: string; description?: string }
> = {
set: {
label: "设置属性",
description: "更新当前资源暴露的可写属性值。",
},
invoke: {
label: "调用操作",
description: "调用当前资源暴露的管理操作。",
},
put: {
label: "写入资源",
description: "将 payload 内容写入当前 JVM 资源。",
},
clear: {
label: "清空资源",
description: "清空当前 JVM 资源里的数据或状态。",
},
evict: {
label: "驱逐缓存",
description: "将目标缓存项从当前 JVM 运行时中驱逐。",
},
remove: {
label: "删除条目",
description: "删除当前资源中的指定条目。",
},
delete: {
label: "删除资源",
description: "删除或注销当前资源。",
},
refresh: {
label: "刷新资源",
description: "刷新当前资源的运行时状态。",
},
reload: {
label: "重新加载",
description: "重新加载当前资源或其配置。",
},
reset: {
label: "重置状态",
description: "将当前资源恢复到初始或默认状态。",
},
};
const normalizeText = (value: unknown): string => String(value || "").trim();
const looksLikeStructuredJSONText = (value: string): boolean => {
const trimmed = normalizeText(value);
if (!trimmed) {
return false;
}
if (
!(
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
)
) {
return false;
}
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
};
export const resolveJVMActionDisplay = (
value?: Partial<JVMActionDefinition> | string | null,
): JVMActionDisplay => {
const action = normalizeText(
typeof value === "string" ? value : value?.action,
);
const fallback = ACTION_FALLBACK_META[action.toLowerCase()] || null;
const label =
normalizeText(typeof value === "string" ? "" : value?.label) ||
fallback?.label ||
action ||
"未命名动作";
const description =
normalizeText(typeof value === "string" ? "" : value?.description) ||
fallback?.description ||
"";
return {
action,
label,
description: description || undefined,
};
};
export const formatJVMActionDisplayText = (
value?: Partial<JVMActionDefinition> | string | null,
): string => {
const resolved = resolveJVMActionDisplay(value);
if (!resolved.action || resolved.label === resolved.action) {
return resolved.label;
}
return `${resolved.label}${resolved.action}`;
};
export const formatJVMActionSummary = (
actions?: JVMActionDefinition[] | null,
): string => {
if (!Array.isArray(actions) || actions.length === 0) {
return "-";
}
return actions
.map((item) => formatJVMActionDisplayText(item))
.filter((item) => item !== "")
.join(", ");
};
export const formatJVMRiskLevelText = (value?: string | null): string => {
const normalized = normalizeText(value).toLowerCase();
if (normalized === "low") {
return "低";
}
if (normalized === "medium") {
return "中";
}
if (normalized === "high") {
return "高";
}
return normalizeText(value) || "未知";
};
export const resolveJVMAuditResultColor = (value?: string | null): string => {
const normalized = normalizeText(value).toLowerCase();
if (
normalized === "applied" ||
normalized.includes("success") ||
normalized.includes("ok") ||
normalized.includes("done")
) {
return "green";
}
if (normalized.includes("warn")) {
return "gold";
}
if (
normalized.includes("block") ||
normalized.includes("deny") ||
normalized.includes("forbid") ||
normalized.includes("fail") ||
normalized.includes("error")
) {
return "red";
}
return "default";
};
export const formatJVMAuditResultLabel = (value?: string | null): string => {
const normalized = normalizeText(value).toLowerCase();
if (!normalized) {
return "未知";
}
if (normalized === "applied") {
return "已执行";
}
if (
normalized.includes("success") ||
normalized.includes("ok") ||
normalized.includes("done")
) {
return "成功";
}
if (normalized.includes("warn")) {
return "警告";
}
if (
normalized.includes("block") ||
normalized.includes("deny") ||
normalized.includes("forbid")
) {
return "已阻断";
}
if (normalized.includes("fail") || normalized.includes("error")) {
return "失败";
}
return normalizeText(value);
};
export const resolveJVMValueEditorLanguage = (
format: string,
value: unknown,
): string => {
const normalizedFormat = normalizeText(format).toLowerCase();
if (
["json", "array", "object", "number", "boolean", "null"].includes(
normalizedFormat,
)
) {
return "json";
}
if (normalizedFormat === "sql") {
return "sql";
}
if (normalizedFormat === "xml") {
return "xml";
}
if (normalizedFormat === "yaml" || normalizedFormat === "yml") {
return "yaml";
}
if (typeof value === "string") {
return looksLikeStructuredJSONText(value) ? "json" : "plaintext";
}
if (
value === null ||
typeof value === "number" ||
typeof value === "boolean" ||
Array.isArray(value)
) {
return "json";
}
if (value && typeof value === "object") {
return "json";
}
return "plaintext";
};
export const estimateJVMResourceEditorHeight = (value: unknown): number => {
const text = String(value ?? "");
const lineCount = Math.max(1, text.split(/\r?\n/).length);
return Math.min(420, Math.max(180, lineCount * 22 + 24));
};
export type { JVMActionDisplay };

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { buildJVMTabTitle, resolveJVMModeMeta } from './jvmRuntimePresentation';
describe('jvmRuntimePresentation', () => {
it('returns labels for built-in JVM modes', () => {
expect(resolveJVMModeMeta('jmx').label).toBe('JMX');
expect(resolveJVMModeMeta('endpoint').label).toBe('Endpoint');
});
it('builds overview tab titles with connection name and mode label', () => {
expect(buildJVMTabTitle('Orders JVM', 'overview', 'jmx')).toBe('[Orders JVM] JVM 概览 · JMX');
});
it('builds resource tab titles with the planned label', () => {
expect(buildJVMTabTitle('Orders JVM', 'resource', 'endpoint')).toBe('[Orders JVM] JVM 资源 · Endpoint');
});
it('builds audit tab titles with the planned label', () => {
expect(buildJVMTabTitle('Orders JVM', 'audit', 'jmx')).toBe('[Orders JVM] JVM 审计 · JMX');
});
});

View File

@@ -0,0 +1,76 @@
export type JVMRuntimeMode = 'jmx' | 'endpoint' | 'agent';
export type JVMTabKind = 'overview' | 'resource' | 'audit' | 'diagnostic' | 'monitoring';
export type JVMModeMeta = {
mode: string;
label: string;
color: string;
backgroundColor: string;
};
export const JVM_RUNTIME_MODES: JVMRuntimeMode[] = ['jmx', 'endpoint', 'agent'];
const JVM_MODE_META_MAP: Record<JVMRuntimeMode, JVMModeMeta> = {
jmx: {
mode: 'jmx',
label: 'JMX',
color: '#1D39C4',
backgroundColor: 'rgba(29, 57, 196, 0.12)',
},
endpoint: {
mode: 'endpoint',
label: 'Endpoint',
color: '#1677FF',
backgroundColor: 'rgba(22, 119, 255, 0.12)',
},
agent: {
mode: 'agent',
label: 'Agent',
color: '#FA8C16',
backgroundColor: 'rgba(250, 140, 22, 0.12)',
},
};
const JVM_TAB_KIND_LABELS: Record<JVMTabKind, string> = {
overview: 'JVM 概览',
resource: 'JVM 资源',
audit: 'JVM 审计',
diagnostic: 'JVM 诊断',
monitoring: 'JVM 监控',
};
const normalizeMode = (mode: string): string => String(mode || '').trim().toLowerCase();
const toTitleCase = (value: string): string => {
if (!value) {
return 'Unknown';
}
return value.charAt(0).toUpperCase() + value.slice(1);
};
export const resolveJVMModeMeta = (mode: string): JVMModeMeta => {
const normalizedMode = normalizeMode(mode);
if (normalizedMode in JVM_MODE_META_MAP) {
return JVM_MODE_META_MAP[normalizedMode as JVMRuntimeMode];
}
return {
mode: normalizedMode || 'unknown',
label: toTitleCase(normalizedMode || 'unknown'),
color: '#8C8C8C',
backgroundColor: 'rgba(140, 140, 140, 0.12)',
};
};
export const buildJVMTabTitle = (
connectionName: string,
tabKind: JVMTabKind,
mode: string,
): string => {
const trimmedConnectionName = String(connectionName || '').trim();
const tabLabel = JVM_TAB_KIND_LABELS[tabKind] || 'JVM';
const modeLabel = resolveJVMModeMeta(mode).label;
const prefix = trimmedConnectionName ? `[${trimmedConnectionName}] ` : '';
return `${prefix}${tabLabel} · ${modeLabel}`;
};

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
buildJVMDiagnosticActionDescriptor,
buildJVMMonitoringActionDescriptors,
} from "./jvmSidebarActions";
describe("jvmSidebarActions", () => {
it("builds direct JVM monitoring entries from probed provider capabilities", () => {
expect(
buildJVMMonitoringActionDescriptors("conn-1", [
{ mode: "jmx" },
{ mode: "endpoint" },
{ mode: "jmx" },
]),
).toEqual([
{
key: "conn-1-jvm-monitoring-jmx",
title: "持续监控 · JMX",
providerMode: "jmx",
},
{
key: "conn-1-jvm-monitoring-endpoint",
title: "持续监控 · Endpoint",
providerMode: "endpoint",
},
]);
});
it("skips providers that cannot be browsed when building monitoring entries", () => {
expect(
buildJVMMonitoringActionDescriptors("conn-1", [
{ mode: "jmx", canBrowse: true },
{ mode: "agent", canBrowse: false },
]),
).toEqual([
{
key: "conn-1-jvm-monitoring-jmx",
title: "持续监控 · JMX",
providerMode: "jmx",
},
]);
});
it("builds diagnostic entry independently from provider probing", () => {
expect(
buildJVMDiagnosticActionDescriptor("conn-1", {
enabled: true,
transport: "arthas-tunnel",
}),
).toEqual({
key: "conn-1-jvm-diagnostic",
title: "诊断增强 · Arthas Tunnel",
transport: "arthas-tunnel",
});
expect(
buildJVMDiagnosticActionDescriptor("conn-1", {
enabled: false,
transport: "agent-bridge",
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,77 @@
import type { JVMCapability } from "../types";
import {
JVM_RUNTIME_MODES,
resolveJVMModeMeta,
type JVMRuntimeMode,
} from "./jvmRuntimePresentation";
export type JVMMonitoringActionDescriptor = {
key: string;
title: string;
providerMode: JVMRuntimeMode;
};
export type JVMDiagnosticActionDescriptor = {
key: string;
title: string;
transport: "agent-bridge" | "arthas-tunnel";
};
const normalizeMonitoringMode = (value: unknown): JVMRuntimeMode | null => {
const mode = String(value || "").trim().toLowerCase();
return JVM_RUNTIME_MODES.includes(mode as JVMRuntimeMode)
? (mode as JVMRuntimeMode)
: null;
};
export const buildJVMMonitoringActionDescriptors = (
connectionId: string,
capabilities: Array<Pick<JVMCapability, "mode"> & Partial<Pick<JVMCapability, "canBrowse">>>,
): JVMMonitoringActionDescriptor[] => {
const id = String(connectionId || "").trim();
if (!id) {
return [];
}
const seen = new Set<JVMRuntimeMode>();
const descriptors: JVMMonitoringActionDescriptor[] = [];
capabilities.forEach((capability) => {
if (capability.canBrowse === false) {
return;
}
const providerMode = normalizeMonitoringMode(capability.mode);
if (!providerMode || seen.has(providerMode)) {
return;
}
seen.add(providerMode);
descriptors.push({
key: `${id}-jvm-monitoring-${providerMode}`,
title: `持续监控 · ${resolveJVMModeMeta(providerMode).label}`,
providerMode,
});
});
return descriptors;
};
export const buildJVMDiagnosticActionDescriptor = (
connectionId: string,
diagnostic: { enabled?: boolean; transport?: unknown } | undefined,
): JVMDiagnosticActionDescriptor | null => {
const id = String(connectionId || "").trim();
if (!id || diagnostic?.enabled !== true) {
return null;
}
const transport =
String(diagnostic.transport || "").trim() === "arthas-tunnel"
? "arthas-tunnel"
: "agent-bridge";
return {
key: `${id}-jvm-diagnostic`,
title: `诊断增强 · ${transport === "arthas-tunnel" ? "Arthas Tunnel" : "Agent Bridge"}`,
transport,
};
};

View File

@@ -31,6 +31,20 @@ describe('normalizeRedisSearchInput', () => {
});
});
it('uses literal key pattern without fuzzy wildcards in exact mode', () => {
expect(normalizeRedisSearchInput('Order:1001', 'exact')).toEqual({
keyword: 'Order:1001',
pattern: 'Order:1001',
});
});
it('escapes redis glob special characters in exact mode without adding wildcards', () => {
expect(normalizeRedisSearchInput('user:*:[id]?\\raw', 'exact')).toEqual({
keyword: 'user:*:[id]?\\raw',
pattern: 'user:\\*:\\[id\\]\\?\\\\raw',
});
});
it('marks empty draft changes for immediate reset search', () => {
expect(normalizeRedisSearchDraftChange('')).toEqual({
keyword: '',

View File

@@ -1,6 +1,8 @@
const REDIS_GLOB_SPECIAL_CHARS = /([*?\[\]\\])/g;
const ASCII_LETTER = /^[A-Za-z]$/;
export type RedisSearchMode = 'fuzzy' | 'exact';
const escapeRedisGlobLiteral = (value: string): string => {
return value.replace(REDIS_GLOB_SPECIAL_CHARS, '\\$1');
};
@@ -17,23 +19,32 @@ const toCaseInsensitiveRedisGlobLiteral = (value: string): string => {
}).join('');
};
export const normalizeRedisSearchInput = (rawValue: string): { keyword: string; pattern: string } => {
export const normalizeRedisSearchInput = (
rawValue: string,
mode: RedisSearchMode = 'fuzzy',
): { keyword: string; pattern: string } => {
const keyword = String(rawValue || '').trim();
if (!keyword) {
return { keyword: '', pattern: '*' };
}
if (mode === 'exact') {
return {
keyword,
pattern: escapeRedisGlobLiteral(keyword),
};
}
return {
keyword,
pattern: `*${toCaseInsensitiveRedisGlobLiteral(keyword)}*`,
};
};
export const normalizeRedisSearchDraftChange = (rawValue: string): {
export const normalizeRedisSearchDraftChange = (rawValue: string, mode: RedisSearchMode = 'fuzzy'): {
keyword: string;
pattern: string;
shouldSearchImmediately: boolean;
} => {
const normalized = normalizeRedisSearchInput(rawValue);
const normalized = normalizeRedisSearchInput(rawValue, mode);
return {
...normalized,
shouldSearchImmediately: normalized.keyword === '',

View File

@@ -20,6 +20,26 @@ describe('redisValueDisplay', () => {
});
});
it('preserves large integer literals when formatting json in auto mode', () => {
const value = '{"subSessionIds":["java.util.ArrayList",[1494694751571226624]],"currentSubSessionId":1494694751571226624}';
const formatted = formatRedisStringValue(value);
expect(formatted).toMatchObject({
isBinary: false,
isJson: true,
encoding: 'UTF-8',
});
expect(formatted.displayValue).toContain('1494694751571226624');
expect(formatted.displayValue).not.toContain('1494694751571226600');
});
it('keeps json string escape rendering consistent in auto mode', () => {
const formatted = formatRedisStringValue('{"name":"\\u4e2d\\u6587","id":1494694751571226624}');
expect(formatted.displayValue).toContain('"name": "中文"');
expect(formatted.displayValue).toContain('"id": 1494694751571226624');
});
it('falls back to hex for obvious binary values', () => {
expect(formatRedisStringValue('\u0000\u0001\u0002abc')).toMatchObject({
isBinary: true,

View File

@@ -88,13 +88,135 @@ const tryDecodeValue = (value: string): { displayValue: string; encoding: string
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
};
const tryFormatJson = (value: string): { isJson: boolean; formatted: string } => {
try {
const parsed = JSON.parse(value);
return { isJson: true, formatted: JSON.stringify(parsed, null, 2) };
} catch {
return { isJson: false, formatted: value };
const findNextNonWhitespace = (value: string, startIndex: number): string => {
for (let i = startIndex; i < value.length; i++) {
if (!/\s/.test(value[i])) {
return value[i];
}
}
return '';
};
const readJsonStringToken = (value: string, startIndex: number): { token: string; nextIndex: number } => {
let index = startIndex + 1;
let escaped = false;
while (index < value.length) {
const char = value[index];
if (escaped) {
escaped = false;
index++;
continue;
}
if (char === '\\') {
escaped = true;
index++;
continue;
}
if (char === '"') {
return { token: value.slice(startIndex, index + 1), nextIndex: index + 1 };
}
index++;
}
return { token: value.slice(startIndex), nextIndex: value.length };
};
const readJsonPrimitiveToken = (value: string, startIndex: number): { token: string; nextIndex: number } => {
let index = startIndex;
while (index < value.length && !/[\s,\]}]/.test(value[index])) {
index++;
}
return { token: value.slice(startIndex, index), nextIndex: index };
};
const formatJsonStringToken = (token: string): string => {
try {
return JSON.stringify(JSON.parse(token));
} catch {
return token;
}
};
const formatJsonPreservingNumberLiterals = (value: string): string | null => {
try {
JSON.parse(value);
} catch {
return null;
}
const indentUnit = ' ';
const indent = (depth: number) => indentUnit.repeat(Math.max(0, depth));
let result = '';
let depth = 0;
let index = 0;
let lastToken: 'open' | 'value' | 'close' | 'comma' | 'colon' | '' = '';
while (index < value.length) {
const char = value[index];
if (/\s/.test(char)) {
index++;
continue;
}
if (char === '"') {
const { token, nextIndex } = readJsonStringToken(value, index);
result += formatJsonStringToken(token);
lastToken = 'value';
index = nextIndex;
continue;
}
if (char === '{' || char === '[') {
const closeChar = char === '{' ? '}' : ']';
result += char;
depth++;
lastToken = 'open';
if (findNextNonWhitespace(value, index + 1) !== closeChar) {
result += `\n${indent(depth)}`;
}
index++;
continue;
}
if (char === '}' || char === ']') {
depth--;
if (lastToken !== 'open') {
result += `\n${indent(depth)}`;
}
result += char;
lastToken = 'close';
index++;
continue;
}
if (char === ',') {
result += `,\n${indent(depth)}`;
lastToken = 'comma';
index++;
continue;
}
if (char === ':') {
result += ': ';
lastToken = 'colon';
index++;
continue;
}
const { token, nextIndex } = readJsonPrimitiveToken(value, index);
result += token;
lastToken = 'value';
index = nextIndex;
}
return result;
};
const tryFormatJson = (value: string): { isJson: boolean; formatted: string } => {
const formatted = formatJsonPreservingNumberLiterals(value);
if (formatted !== null) {
return { isJson: true, formatted };
}
return { isJson: false, formatted: value };
};
export const toHexDisplay = (value: string): string => {

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
isMysqlFamilyDialect,
resolveColumnTypeOptions,
resolveSqlDialect,
resolveSqlFunctions,
resolveSqlKeywords,
} from './sqlDialect';
const values = (options: Array<{ value: string }>) => options.map((item) => item.value);
const names = (items: Array<{ name: string }>) => items.map((item) => item.name);
describe('sqlDialect', () => {
it('normalizes datasource aliases without collapsing all dialects to mysql', () => {
expect(resolveSqlDialect('postgresql')).toBe('postgres');
expect(resolveSqlDialect('doris')).toBe('diros');
expect(resolveSqlDialect('dameng')).toBe('dameng');
expect(resolveSqlDialect('custom', 'kingbase8')).toBe('kingbase');
expect(resolveSqlDialect('custom', 'dm8')).toBe('dameng');
expect(resolveSqlDialect('custom', 'mariadb')).toBe('mariadb');
expect(isMysqlFamilyDialect('mariadb')).toBe(true);
expect(isMysqlFamilyDialect('oracle')).toBe(false);
});
it('resolves field type options per datasource family', () => {
expect(values(resolveColumnTypeOptions('oracle'))).toContain('VARCHAR2(255)');
expect(values(resolveColumnTypeOptions('oracle'))).not.toContain('tinyint(1)');
expect(values(resolveColumnTypeOptions('dameng'))).toContain('VARCHAR2(255)');
expect(values(resolveColumnTypeOptions('kingbase'))).toContain('integer');
expect(values(resolveColumnTypeOptions('kingbase'))).not.toContain('tinyint(1)');
expect(values(resolveColumnTypeOptions('diros'))).toContain('LARGEINT');
expect(values(resolveColumnTypeOptions('sphinx'))).toContain('text');
expect(values(resolveColumnTypeOptions('clickhouse'))).toContain('DateTime64(3)');
expect(values(resolveColumnTypeOptions('tdengine'))).toContain('TIMESTAMP');
expect(values(resolveColumnTypeOptions('duckdb'))).toContain('STRUCT');
});
it('resolves oracle completion keywords and functions without mysql-only suggestions', () => {
expect(resolveSqlKeywords('oracle')).toEqual(expect.arrayContaining(['ROWNUM', 'FETCH', 'VARCHAR2', 'NUMBER']));
expect(resolveSqlKeywords('oracle')).not.toEqual(expect.arrayContaining(['AUTO_INCREMENT', 'CHANGE', 'LIMIT']));
expect(names(resolveSqlFunctions('oracle'))).toEqual(expect.arrayContaining(['NVL', 'SYSDATE', 'TO_DATE']));
expect(names(resolveSqlFunctions('oracle'))).not.toEqual(expect.arrayContaining(['DATE_FORMAT', 'GROUP_CONCAT']));
});
it('resolves mysql-family completion keywords and functions with mysql syntax', () => {
expect(resolveSqlKeywords('mariadb')).toEqual(expect.arrayContaining(['LIMIT', 'CHANGE', 'AUTO_INCREMENT']));
expect(names(resolveSqlFunctions('diros'))).toEqual(expect.arrayContaining(['DATE_FORMAT', 'GROUP_CONCAT']));
});
it('resolves sqlserver completion without mysql-only ddl tokens', () => {
expect(resolveSqlKeywords('sqlserver')).toEqual(expect.arrayContaining(['TOP', 'IDENTITY', 'NVARCHAR']));
expect(resolveSqlKeywords('sqlserver')).not.toEqual(expect.arrayContaining(['AUTO_INCREMENT', 'CHANGE']));
expect(names(resolveSqlFunctions('sqlserver'))).toEqual(expect.arrayContaining(['GETDATE', 'ISNULL', 'NEWID']));
expect(names(resolveSqlFunctions('sqlserver'))).not.toEqual(expect.arrayContaining(['GROUP_CONCAT']));
});
});

View File

@@ -0,0 +1,715 @@
export type ColumnTypeOption = { value: string };
export type SqlFunctionCompletion = {
name: string;
detail: string;
};
export type SqlDialect =
| 'mysql'
| 'mariadb'
| 'diros'
| 'sphinx'
| 'postgres'
| 'kingbase'
| 'highgo'
| 'vastbase'
| 'oracle'
| 'dameng'
| 'sqlserver'
| 'sqlite'
| 'duckdb'
| 'clickhouse'
| 'tdengine'
| 'mongodb'
| 'redis'
| 'unknown'
| string;
const unique = <T>(items: T[]): T[] => Array.from(new Set(items));
const optionValues = (values: string[]): ColumnTypeOption[] => values.map((value) => ({ value }));
const normalizeRawDialect = (value: string): string => String(value || '').trim().toLowerCase();
export const resolveSqlDialect = (rawType: string, rawDriver = ''): SqlDialect => {
const normalized = normalizeRawDialect(rawType);
const driver = normalizeRawDialect(rawDriver);
const source = normalized === 'custom' ? driver : normalized;
if (!source) return 'unknown';
switch (source) {
case 'postgresql':
case 'postgres':
case 'pg':
case 'pq':
case 'pgx':
return 'postgres';
case 'mssql':
case 'sql_server':
case 'sql-server':
return 'sqlserver';
case 'doris':
case 'diros':
return 'diros';
case 'dm':
case 'dm8':
case 'dameng':
return 'dameng';
case 'sqlite3':
case 'sqlite':
return 'sqlite';
case 'sphinxql':
return 'sphinx';
case 'kingbase8':
case 'kingbasees':
case 'kingbasev8':
return 'kingbase';
case 'mariadb':
case 'mysql':
case 'sphinx':
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'oracle':
case 'duckdb':
case 'clickhouse':
case 'tdengine':
case 'mongodb':
case 'redis':
return source;
default:
break;
}
if (source.includes('postgres')) return 'postgres';
if (source.includes('mariadb')) return 'mariadb';
if (source.includes('mysql')) return 'mysql';
if (source.includes('doris') || source.includes('diros')) return 'diros';
if (source.includes('sphinx')) return 'sphinx';
if (source.includes('kingbase')) return 'kingbase';
if (source.includes('highgo')) return 'highgo';
if (source.includes('vastbase')) return 'vastbase';
if (source.includes('oracle')) return 'oracle';
if (source.includes('dameng') || source.includes('dm8')) return 'dameng';
if (source.includes('sqlite')) return 'sqlite';
if (source.includes('duckdb')) return 'duckdb';
if (source.includes('clickhouse')) return 'clickhouse';
if (source.includes('tdengine')) return 'tdengine';
if (source.includes('sqlserver') || source.includes('mssql')) return 'sqlserver';
return source;
};
export const isMysqlFamilyDialect = (dbType: string): boolean => (
['mysql', 'mariadb', 'diros', 'sphinx', 'tidb', 'oceanbase', 'starrocks'].includes(resolveSqlDialect(dbType))
);
export const isPgLikeDialect = (dbType: string): boolean => (
['postgres', 'kingbase', 'highgo', 'vastbase'].includes(resolveSqlDialect(dbType))
);
export const isOracleLikeDialect = (dbType: string): boolean => (
['oracle', 'dameng', 'dm'].includes(resolveSqlDialect(dbType))
);
export const isSqlServerDialect = (dbType: string): boolean => resolveSqlDialect(dbType) === 'sqlserver';
export const isBacktickIdentifierDialect = (dbType: string): boolean => (
isMysqlFamilyDialect(dbType) || ['clickhouse', 'tdengine'].includes(resolveSqlDialect(dbType))
);
const stripIdentifierQuotes = (part: string): string => {
const text = String(part || '').trim();
if (!text) return '';
if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1).trim();
}
if (text.startsWith('[') && text.endsWith(']')) {
return text.slice(1, -1).replace(/]]/g, ']').trim();
}
return text;
};
const escapeBacktickIdentifier = (value: string) => String(value || '').replace(/`/g, '``');
const escapeDoubleQuoteIdentifier = (value: string) => String(value || '').replace(/"/g, '""');
const escapeBracketIdentifier = (value: string) => String(value || '').replace(/]/g, ']]');
const needsPgLikeQuote = (ident: string): boolean => !/^[a-z_][a-z0-9_]*$/.test(ident);
export const unquoteSqlIdentifierPart = stripIdentifierQuotes;
export const unquoteSqlIdentifierPath = (path: string): string => (
String(path || '')
.trim()
.split('.')
.map((part) => stripIdentifierQuotes(part))
.filter(Boolean)
.join('.')
);
export const quoteSqlIdentifierPart = (dbType: string, part: string): string => {
const ident = stripIdentifierQuotes(part);
if (!ident) return '';
const dialect = resolveSqlDialect(dbType);
if (isBacktickIdentifierDialect(dialect)) {
return `\`${escapeBacktickIdentifier(ident)}\``;
}
if (isSqlServerDialect(dialect)) {
return `[${escapeBracketIdentifier(ident)}]`;
}
if (isPgLikeDialect(dialect)) {
return needsPgLikeQuote(ident) ? `"${escapeDoubleQuoteIdentifier(ident)}"` : ident;
}
return `"${escapeDoubleQuoteIdentifier(ident)}"`;
};
export const quoteSqlIdentifierPath = (dbType: string, path: string): string => (
String(path || '')
.trim()
.split('.')
.map((part) => stripIdentifierQuotes(part))
.filter(Boolean)
.map((part) => quoteSqlIdentifierPart(dbType, part))
.join('.')
);
const MYSQL_TYPES = optionValues([
'tinyint',
'tinyint(1)',
'smallint',
'mediumint',
'int',
'bigint',
'float',
'double',
'decimal(10,2)',
'char(50)',
'varchar(255)',
'tinytext',
'text',
'mediumtext',
'longtext',
'binary(255)',
'varbinary(255)',
'tinyblob',
'blob',
'mediumblob',
'longblob',
'date',
'time',
'datetime',
'timestamp',
'year',
'json',
'enum',
'set',
'bit(1)',
]);
const PG_TYPES = optionValues([
'smallint',
'integer',
'bigint',
'real',
'double precision',
'numeric(10,2)',
'serial',
'bigserial',
'char(50)',
'varchar(255)',
'text',
'boolean',
'date',
'time',
'timestamp',
'timestamptz',
'interval',
'bytea',
'json',
'jsonb',
'uuid',
'inet',
'cidr',
'macaddr',
'xml',
'int4range',
'tsquery',
'tsvector',
]);
const SQLSERVER_TYPES = optionValues([
'tinyint',
'smallint',
'int',
'bigint',
'float',
'real',
'decimal(10,2)',
'numeric(10,2)',
'money',
'smallmoney',
'char(50)',
'varchar(255)',
'varchar(max)',
'nchar(50)',
'nvarchar(255)',
'nvarchar(max)',
'text',
'ntext',
'date',
'time',
'datetime',
'datetime2',
'datetimeoffset',
'smalldatetime',
'binary(255)',
'varbinary(255)',
'varbinary(max)',
'image',
'bit',
'uniqueidentifier',
'xml',
]);
const SQLITE_TYPES = optionValues(['INTEGER', 'REAL', 'TEXT', 'BLOB', 'NUMERIC']);
const ORACLE_TYPES = optionValues([
'NUMBER(10)',
'NUMBER(10,2)',
'FLOAT',
'BINARY_FLOAT',
'BINARY_DOUBLE',
'CHAR(50)',
'VARCHAR2(255)',
'NVARCHAR2(255)',
'CLOB',
'NCLOB',
'BLOB',
'DATE',
'TIMESTAMP',
'TIMESTAMP WITH TIME ZONE',
'RAW(255)',
'LONG RAW',
'XMLTYPE',
]);
const DAMENG_TYPES = optionValues([
'INT',
'BIGINT',
'NUMBER(10)',
'NUMBER(10,2)',
'DECIMAL(10,2)',
'CHAR(50)',
'VARCHAR(255)',
'VARCHAR2(255)',
'NVARCHAR2(255)',
'TEXT',
'CLOB',
'BLOB',
'DATE',
'TIME',
'TIMESTAMP',
'BIT',
]);
const DORIS_TYPES = optionValues([
'BOOLEAN',
'TINYINT',
'SMALLINT',
'INT',
'BIGINT',
'LARGEINT',
'FLOAT',
'DOUBLE',
'DECIMAL(10,2)',
'CHAR(50)',
'VARCHAR(255)',
'STRING',
'DATE',
'DATETIME',
'JSON',
'HLL',
'BITMAP',
'ARRAY<INT>',
'MAP<STRING,STRING>',
'STRUCT<name:STRING>',
]);
const SPHINX_TYPES = optionValues([
'text',
'string',
'integer',
'bigint',
'float',
'bool',
'timestamp',
'json',
]);
const CLICKHOUSE_TYPES = optionValues([
'Int8',
'UInt8',
'Int16',
'UInt16',
'Int32',
'UInt32',
'Int64',
'UInt64',
'Float32',
'Float64',
'Decimal(10,2)',
'String',
'FixedString(32)',
'Date',
'Date32',
'DateTime',
'DateTime64(3)',
'UUID',
'IPv4',
'IPv6',
'Array(String)',
'Nullable(String)',
'LowCardinality(String)',
"Enum8('A'=1)",
]);
const TDENGINE_TYPES = optionValues([
'TIMESTAMP',
'BOOL',
'TINYINT',
'SMALLINT',
'INT',
'BIGINT',
'FLOAT',
'DOUBLE',
'BINARY(255)',
'NCHAR(255)',
'VARBINARY(255)',
'JSON',
'GEOMETRY',
]);
const DUCKDB_TYPES = optionValues([
'BOOLEAN',
'TINYINT',
'SMALLINT',
'INTEGER',
'BIGINT',
'UTINYINT',
'USMALLINT',
'UINTEGER',
'UBIGINT',
'REAL',
'DOUBLE',
'DECIMAL(10,2)',
'VARCHAR',
'BLOB',
'DATE',
'TIME',
'TIMESTAMP',
'TIMESTAMPTZ',
'INTERVAL',
'UUID',
'JSON',
'STRUCT',
'LIST',
'MAP',
]);
const COMMON_TYPES = optionValues(['int', 'varchar(255)', 'text', 'datetime', 'decimal(10,2)', 'bigint', 'json']);
export const resolveColumnTypeOptions = (dbType: string): ColumnTypeOption[] => {
const dialect = resolveSqlDialect(dbType);
if (dialect === 'mariadb' || dialect === 'mysql') return MYSQL_TYPES;
if (dialect === 'diros') return DORIS_TYPES;
if (dialect === 'sphinx') return SPHINX_TYPES;
if (isPgLikeDialect(dialect)) return PG_TYPES;
if (dialect === 'oracle') return ORACLE_TYPES;
if (dialect === 'dameng') return DAMENG_TYPES;
if (dialect === 'sqlserver') return SQLSERVER_TYPES;
if (dialect === 'sqlite') return SQLITE_TYPES;
if (dialect === 'duckdb') return DUCKDB_TYPES;
if (dialect === 'clickhouse') return CLICKHOUSE_TYPES;
if (dialect === 'tdengine') return TDENGINE_TYPES;
return COMMON_TYPES;
};
const COMMON_KEYWORDS = [
'SELECT', 'FROM', 'WHERE', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT',
'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'HAVING', 'AS', 'AND', 'OR', 'NOT',
'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'ADD',
'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT',
'COMMENT', 'EXPLAIN', 'DISTINCT', 'UNION', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END',
];
const MYSQL_KEYWORDS = [
'LIMIT', 'OFFSET', 'MODIFY', 'CHANGE', 'AUTO_INCREMENT', 'SHOW', 'DESCRIBE',
'DESC', 'ENGINE', 'CHARSET', 'COLLATE', 'REPLACE', 'DUPLICATE KEY', 'LOCK',
];
const PG_KEYWORDS = [
'LIMIT', 'OFFSET', 'RETURNING', 'SERIAL', 'BIGSERIAL', 'BOOLEAN', 'JSONB',
'ILIKE', 'RENAME', 'TYPE', 'CASCADE', 'RESTRICT', 'ONLY',
];
const ORACLE_KEYWORDS = [
'ROWNUM', 'FETCH', 'FIRST', 'ROWS', 'ONLY', 'VARCHAR2', 'NVARCHAR2', 'NUMBER',
'DATE', 'TIMESTAMP', 'CLOB', 'BLOB', 'SEQUENCE', 'SYNONYM', 'MERGE', 'MINUS',
'CONNECT BY', 'START WITH', 'MODIFY', 'RENAME',
];
const SQLSERVER_KEYWORDS = [
'TOP', 'OFFSET', 'FETCH', 'NEXT', 'ROWS', 'ONLY', 'IDENTITY', 'NVARCHAR',
'DATETIME2', 'BIT', 'GO', 'EXEC', 'PROCEDURE', 'WITH', 'NOLOCK', 'MERGE',
];
const SQLITE_KEYWORDS = ['LIMIT', 'OFFSET', 'AUTOINCREMENT', 'PRAGMA', 'WITHOUT', 'ROWID', 'RENAME'];
const DUCKDB_KEYWORDS = ['LIMIT', 'OFFSET', 'SAMPLE', 'QUALIFY', 'STRUCT', 'LIST', 'MAP', 'JSON', 'UNNEST'];
const CLICKHOUSE_KEYWORDS = [
'LIMIT', 'OFFSET', 'FORMAT', 'ENGINE', 'PARTITION', 'ORDER BY', 'PRIMARY KEY',
'SAMPLE', 'MATERIALIZED', 'ALIAS', 'SETTINGS', 'TTL', 'CODEC',
];
const TDENGINE_KEYWORDS = ['LIMIT', 'SLIMIT', 'SOFFSET', 'TAGS', 'USING', 'INTERVAL', 'FILL', 'PARTITION BY'];
export const resolveSqlKeywords = (dbType: string): string[] => {
const dialect = resolveSqlDialect(dbType);
if (isMysqlFamilyDialect(dialect)) return unique([...COMMON_KEYWORDS, ...MYSQL_KEYWORDS]);
if (isPgLikeDialect(dialect)) return unique([...COMMON_KEYWORDS, ...PG_KEYWORDS]);
if (isOracleLikeDialect(dialect)) return unique([...COMMON_KEYWORDS, ...ORACLE_KEYWORDS]);
if (dialect === 'sqlserver') return unique([...COMMON_KEYWORDS, ...SQLSERVER_KEYWORDS]);
if (dialect === 'sqlite') return unique([...COMMON_KEYWORDS, ...SQLITE_KEYWORDS]);
if (dialect === 'duckdb') return unique([...COMMON_KEYWORDS, ...DUCKDB_KEYWORDS]);
if (dialect === 'clickhouse') return unique([...COMMON_KEYWORDS, ...CLICKHOUSE_KEYWORDS]);
if (dialect === 'tdengine') return unique([...COMMON_KEYWORDS, ...TDENGINE_KEYWORDS]);
return COMMON_KEYWORDS;
};
const fn = (name: string, detail: string): SqlFunctionCompletion => ({ name, detail });
const COMMON_FUNCTIONS = [
fn('COUNT', '聚合 - 计数'),
fn('SUM', '聚合 - 求和'),
fn('AVG', '聚合 - 平均值'),
fn('MAX', '聚合 - 最大值'),
fn('MIN', '聚合 - 最小值'),
fn('CONCAT', '字符串 - 拼接'),
fn('SUBSTRING', '字符串 - 截取子串'),
fn('SUBSTR', '字符串 - 截取子串'),
fn('LENGTH', '字符串 - 长度'),
fn('UPPER', '字符串 - 转大写'),
fn('LOWER', '字符串 - 转小写'),
fn('TRIM', '字符串 - 去空格'),
fn('LTRIM', '字符串 - 去左空格'),
fn('RTRIM', '字符串 - 去右空格'),
fn('REPLACE', '字符串 - 替换'),
fn('ABS', '数学 - 绝对值'),
fn('CEIL', '数学 - 向上取整'),
fn('CEILING', '数学 - 向上取整'),
fn('FLOOR', '数学 - 向下取整'),
fn('ROUND', '数学 - 四舍五入'),
fn('MOD', '数学 - 取模'),
fn('POWER', '数学 - 幂运算'),
fn('SQRT', '数学 - 平方根'),
fn('LOG', '数学 - 对数'),
fn('EXP', '数学 - e 的次方'),
fn('COALESCE', '条件 - 返回第一个非 NULL'),
fn('NULLIF', '条件 - 相等返回 NULL'),
fn('CAST', '转换 - 类型转换'),
fn('CONVERT', '转换 - 类型转换'),
fn('ROW_NUMBER', '窗口 - 行号'),
fn('RANK', '窗口 - 排名'),
fn('DENSE_RANK', '窗口 - 连续排名'),
fn('LAG', '窗口 - 前一行'),
fn('LEAD', '窗口 - 后一行'),
fn('FIRST_VALUE', '窗口 - 第一个值'),
fn('LAST_VALUE', '窗口 - 最后一个值'),
];
const MYSQL_FUNCTIONS = [
fn('GROUP_CONCAT', 'MySQL - 分组拼接'),
fn('CONCAT_WS', 'MySQL - 带分隔符拼接'),
fn('LEFT', 'MySQL - 从左截取'),
fn('RIGHT', 'MySQL - 从右截取'),
fn('CHAR_LENGTH', 'MySQL - 字符长度'),
fn('REVERSE', 'MySQL - 字符串反转'),
fn('REPEAT', 'MySQL - 重复字符串'),
fn('LPAD', 'MySQL - 左填充'),
fn('RPAD', 'MySQL - 右填充'),
fn('INSTR', 'MySQL - 查找位置'),
fn('LOCATE', 'MySQL - 查找位置'),
fn('FIND_IN_SET', 'MySQL - 集合查找'),
fn('FORMAT', 'MySQL - 数字格式化'),
fn('TRUNCATE', 'MySQL - 截断小数'),
fn('RAND', 'MySQL - 随机数'),
fn('POW', 'MySQL - 幂运算'),
fn('LOG2', 'MySQL - 以 2 为底对数'),
fn('LOG10', 'MySQL - 以 10 为底对数'),
fn('NOW', 'MySQL - 当前日期时间'),
fn('CURDATE', 'MySQL - 当前日期'),
fn('CURTIME', 'MySQL - 当前时间'),
fn('DATE_FORMAT', 'MySQL - 日期格式化'),
fn('DATE_ADD', 'MySQL - 日期加法'),
fn('DATE_SUB', 'MySQL - 日期减法'),
fn('DATEDIFF', 'MySQL - 日期差'),
fn('TIMESTAMPDIFF', 'MySQL - 时间戳差'),
fn('STR_TO_DATE', 'MySQL - 字符串转日期'),
fn('UNIX_TIMESTAMP', 'MySQL - Unix 时间戳'),
fn('IF', 'MySQL - 条件判断'),
fn('IFNULL', 'MySQL - NULL 替换'),
fn('JSON_EXTRACT', 'MySQL - JSON 提取'),
fn('JSON_UNQUOTE', 'MySQL - JSON 去引号'),
fn('JSON_SET', 'MySQL - JSON 设置'),
fn('MD5', 'MySQL - MD5 哈希'),
fn('SHA1', 'MySQL - SHA1 哈希'),
fn('SHA2', 'MySQL - SHA2 哈希'),
fn('UUID', 'MySQL - 生成 UUID'),
fn('DATABASE', 'MySQL - 当前数据库'),
fn('VERSION', 'MySQL - 版本'),
fn('LAST_INSERT_ID', 'MySQL - 最后插入 ID'),
];
const PG_FUNCTIONS = [
fn('STRING_AGG', 'PostgreSQL - 字符串聚合'),
fn('ARRAY_AGG', 'PostgreSQL - 数组聚合'),
fn('BOOL_AND', 'PostgreSQL - 布尔与聚合'),
fn('BOOL_OR', 'PostgreSQL - 布尔或聚合'),
fn('POSITION', 'PostgreSQL - 查找位置'),
fn('EXTRACT', 'PostgreSQL - 日期字段提取'),
fn('DATE_TRUNC', 'PostgreSQL - 日期截断'),
fn('NOW', 'PostgreSQL - 当前时间'),
fn('TO_CHAR', 'PostgreSQL - 格式化为文本'),
fn('TO_DATE', 'PostgreSQL - 文本转日期'),
fn('TO_TIMESTAMP', 'PostgreSQL - 文本转时间戳'),
fn('AGE', 'PostgreSQL - 时间差'),
fn('RANDOM', 'PostgreSQL - 随机数'),
fn('CURRENT_DATABASE', 'PostgreSQL - 当前数据库'),
fn('JSONB_EXTRACT_PATH', 'PostgreSQL - JSONB 路径提取'),
];
const ORACLE_FUNCTIONS = [
fn('LISTAGG', 'Oracle - 字符串聚合'),
fn('NVL', 'Oracle - NULL 替换'),
fn('NVL2', 'Oracle - NULL 分支'),
fn('DECODE', 'Oracle - 条件映射'),
fn('TO_DATE', 'Oracle - 文本转日期'),
fn('TO_TIMESTAMP', 'Oracle - 文本转时间戳'),
fn('TO_CHAR', 'Oracle - 格式化为文本'),
fn('TO_NUMBER', 'Oracle - 转数字'),
fn('TRUNC', 'Oracle - 截断日期或数字'),
fn('ADD_MONTHS', 'Oracle - 增加月份'),
fn('MONTHS_BETWEEN', 'Oracle - 月份差'),
fn('LAST_DAY', 'Oracle - 月末日期'),
fn('SYSDATE', 'Oracle - 数据库当前时间'),
fn('SYSTIMESTAMP', 'Oracle - 当前时间戳'),
fn('INSTR', 'Oracle - 查找位置'),
fn('REGEXP_LIKE', 'Oracle - 正则匹配'),
fn('REGEXP_REPLACE', 'Oracle - 正则替换'),
fn('USER', 'Oracle - 当前用户'),
];
const SQLSERVER_FUNCTIONS = [
fn('GETDATE', 'SQL Server - 当前日期时间'),
fn('SYSDATETIME', 'SQL Server - 高精度当前时间'),
fn('DATEADD', 'SQL Server - 日期加法'),
fn('DATEDIFF', 'SQL Server - 日期差'),
fn('FORMAT', 'SQL Server - 格式化'),
fn('ISNULL', 'SQL Server - NULL 替换'),
fn('IIF', 'SQL Server - 条件判断'),
fn('NEWID', 'SQL Server - 生成 GUID'),
fn('STRING_AGG', 'SQL Server - 字符串聚合'),
fn('LEFT', 'SQL Server - 从左截取'),
fn('RIGHT', 'SQL Server - 从右截取'),
fn('LEN', 'SQL Server - 字符长度'),
fn('CHARINDEX', 'SQL Server - 查找位置'),
fn('TRY_CAST', 'SQL Server - 尝试转换'),
fn('TRY_CONVERT', 'SQL Server - 尝试转换'),
fn('DB_NAME', 'SQL Server - 当前数据库'),
];
const SQLITE_FUNCTIONS = [
fn('DATE', 'SQLite - 日期'),
fn('TIME', 'SQLite - 时间'),
fn('DATETIME', 'SQLite - 日期时间'),
fn('JULIANDAY', 'SQLite - 儒略日'),
fn('STRFTIME', 'SQLite - 日期格式化'),
fn('IFNULL', 'SQLite - NULL 替换'),
fn('RANDOM', 'SQLite - 随机数'),
fn('PRINTF', 'SQLite - 格式化'),
fn('HEX', 'SQLite - 十六进制'),
fn('QUOTE', 'SQLite - SQL 字面量'),
fn('JSON_EXTRACT', 'SQLite - JSON 提取'),
];
const DUCKDB_FUNCTIONS = [
fn('LIST', 'DuckDB - 列表聚合'),
fn('STRUCT_PACK', 'DuckDB - 构造结构体'),
fn('UNNEST', 'DuckDB - 展开列表'),
fn('STRFTIME', 'DuckDB - 日期格式化'),
fn('EPOCH', 'DuckDB - 时间戳秒数'),
fn('RANDOM', 'DuckDB - 随机数'),
fn('UUID', 'DuckDB - 生成 UUID'),
];
const CLICKHOUSE_FUNCTIONS = [
fn('now', 'ClickHouse - 当前时间'),
fn('today', 'ClickHouse - 当前日期'),
fn('toDate', 'ClickHouse - 转日期'),
fn('toDateTime', 'ClickHouse - 转日期时间'),
fn('formatDateTime', 'ClickHouse - 日期格式化'),
fn('groupArray', 'ClickHouse - 数组聚合'),
fn('groupUniqArray', 'ClickHouse - 去重数组聚合'),
fn('uniq', 'ClickHouse - 近似去重'),
fn('uniqExact', 'ClickHouse - 精确去重'),
fn('quantile', 'ClickHouse - 分位数'),
fn('JSONExtractString', 'ClickHouse - JSON 字符串提取'),
fn('toString', 'ClickHouse - 转字符串'),
fn('toInt64', 'ClickHouse - 转 Int64'),
];
const TDENGINE_FUNCTIONS = [
fn('NOW', 'TDengine - 当前时间'),
fn('TODAY', 'TDengine - 当前日期'),
fn('TIMEDIFF', 'TDengine - 时间差'),
fn('ELAPSED', 'TDengine - 经过时间'),
fn('SPREAD', 'TDengine - 最大最小差'),
fn('TWA', 'TDengine - 时间加权平均'),
fn('LEASTSQUARES', 'TDengine - 最小二乘'),
fn('APERCENTILE', 'TDengine - 近似百分位'),
fn('FIRST', 'TDengine - 首值'),
fn('LAST', 'TDengine - 末值'),
fn('LAST_ROW', 'TDengine - 最后一行'),
fn('INTERP', 'TDengine - 插值'),
fn('RATE', 'TDengine - 变化率'),
fn('IRATE', 'TDengine - 瞬时变化率'),
];
const mergeFunctions = (items: SqlFunctionCompletion[]): SqlFunctionCompletion[] => {
const seen = new Set<string>();
const result: SqlFunctionCompletion[] = [];
for (const item of items) {
const key = item.name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
};
export const resolveSqlFunctions = (dbType: string): SqlFunctionCompletion[] => {
const dialect = resolveSqlDialect(dbType);
if (isMysqlFamilyDialect(dialect)) return mergeFunctions([...COMMON_FUNCTIONS, ...MYSQL_FUNCTIONS]);
if (isPgLikeDialect(dialect)) return mergeFunctions([...COMMON_FUNCTIONS, ...PG_FUNCTIONS]);
if (isOracleLikeDialect(dialect)) return mergeFunctions([...COMMON_FUNCTIONS, ...ORACLE_FUNCTIONS]);
if (dialect === 'sqlserver') return mergeFunctions([...COMMON_FUNCTIONS, ...SQLSERVER_FUNCTIONS]);
if (dialect === 'sqlite') return mergeFunctions([...COMMON_FUNCTIONS, ...SQLITE_FUNCTIONS]);
if (dialect === 'duckdb') return mergeFunctions([...COMMON_FUNCTIONS, ...DUCKDB_FUNCTIONS]);
if (dialect === 'clickhouse') return mergeFunctions([...COMMON_FUNCTIONS, ...CLICKHOUSE_FUNCTIONS]);
if (dialect === 'tdengine') return mergeFunctions([...COMMON_FUNCTIONS, ...TDENGINE_FUNCTIONS]);
return COMMON_FUNCTIONS;
};

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import {
TABLE_OVERVIEW_RENDER_BATCH_SIZE,
buildTableOverviewSearchIndex,
filterAndSortTableOverviewRows,
resolveTableOverviewVisibleRows,
} from './tableOverviewFilter';
const buildRows = (count: number) => Array.from({ length: count }, (_, index) => ({
name: `table_${String(index).padStart(4, '0')}`,
comment: index === count - 1 ? 'target table comment' : 'normal table',
rows: index,
dataSize: count - index,
indexSize: 0,
}));
describe('tableOverviewFilter', () => {
it('filters against the full table set before applying the render limit', () => {
const rows = buildRows(1200);
const indexed = buildTableOverviewSearchIndex(rows);
const filtered = filterAndSortTableOverviewRows(indexed, 'target', 'name', 'asc');
expect(filtered).toHaveLength(1);
expect(filtered[0].name).toBe('table_1199');
});
it('caps initially rendered rows for large overview result sets', () => {
const rows = buildRows(1200);
const visible = resolveTableOverviewVisibleRows(rows, TABLE_OVERVIEW_RENDER_BATCH_SIZE);
expect(visible.visibleRows).toHaveLength(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
expect(visible.hiddenCount).toBe(1200 - TABLE_OVERVIEW_RENDER_BATCH_SIZE);
expect(visible.totalCount).toBe(1200);
});
it('sorts with precomputed normalized table names', () => {
const indexed = buildTableOverviewSearchIndex([
{ name: 'z_table', comment: '', rows: 1, dataSize: 10, indexSize: 0 },
{ name: 'A_table', comment: '', rows: 2, dataSize: 5, indexSize: 0 },
]);
expect(filterAndSortTableOverviewRows(indexed, '', 'name', 'asc').map((item) => item.name)).toEqual([
'A_table',
'z_table',
]);
});
});

View File

@@ -0,0 +1,66 @@
export const TABLE_OVERVIEW_RENDER_BATCH_SIZE = 300;
export type TableOverviewSortField = 'name' | 'rows' | 'dataSize';
export type TableOverviewSortOrder = 'asc' | 'desc';
export interface TableOverviewFilterRow {
name: string;
comment?: string;
rows: number;
dataSize: number;
indexSize: number;
}
export interface TableOverviewSearchIndexItem<T extends TableOverviewFilterRow> {
row: T;
searchText: string;
sortName: string;
}
export const buildTableOverviewSearchIndex = <T extends TableOverviewFilterRow>(
rows: T[],
): TableOverviewSearchIndexItem<T>[] => rows.map((row) => ({
row,
searchText: `${row.name}\n${row.comment || ''}`.toLowerCase(),
sortName: row.name.toLowerCase(),
}));
export const filterAndSortTableOverviewRows = <T extends TableOverviewFilterRow>(
indexedRows: TableOverviewSearchIndexItem<T>[],
rawSearchText: string,
sortField: TableOverviewSortField,
sortOrder: TableOverviewSortOrder,
): T[] => {
const keyword = String(rawSearchText || '').trim().toLowerCase();
const matched = keyword
? indexedRows.filter((item) => item.searchText.includes(keyword))
: [...indexedRows];
matched.sort((a, b) => {
let cmp = 0;
if (sortField === 'name') {
cmp = a.sortName.localeCompare(b.sortName);
} else if (sortField === 'rows') {
cmp = a.row.rows - b.row.rows;
} else if (sortField === 'dataSize') {
cmp = a.row.dataSize - b.row.dataSize;
}
return sortOrder === 'asc' ? cmp : -cmp;
});
return matched.map((item) => item.row);
};
export const resolveTableOverviewVisibleRows = <T>(
rows: T[],
rawLimit: number,
): { visibleRows: T[]; hiddenCount: number; totalCount: number } => {
const limit = Number.isFinite(rawLimit) && rawLimit > 0
? Math.min(Math.floor(rawLimit), rows.length)
: Math.min(TABLE_OVERVIEW_RENDER_BATCH_SIZE, rows.length);
return {
visibleRows: rows.slice(0, limit),
hiddenCount: Math.max(0, rows.length - limit),
totalCount: rows.length,
};
};

View File

@@ -3,6 +3,7 @@
import {connection} from '../models';
import {sync} from '../models';
import {app} from '../models';
import {jvm} from '../models';
import {redis} from '../models';
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
@@ -127,6 +128,36 @@ export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string,ar
export function InstallUpdateAndRestart():Promise<connection.QueryResult>;
export function JVMApplyChange(arg1:connection.ConnectionConfig,arg2:jvm.ChangeRequest):Promise<connection.QueryResult>;
export function JVMCancelDiagnosticCommand(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function JVMExecuteDiagnosticCommand(arg1:connection.ConnectionConfig,arg2:string,arg3:jvm.DiagnosticCommandRequest):Promise<connection.QueryResult>;
export function JVMGetMonitoringHistory(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function JVMGetValue(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function JVMListAuditRecords(arg1:string,arg2:number):Promise<connection.QueryResult>;
export function JVMListDiagnosticAuditRecords(arg1:string,arg2:number):Promise<connection.QueryResult>;
export function JVMListResources(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function JVMPreviewChange(arg1:connection.ConnectionConfig,arg2:jvm.ChangeRequest):Promise<connection.QueryResult>;
export function JVMProbeCapabilities(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function JVMProbeDiagnosticCapabilities(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function JVMStartDiagnosticSession(arg1:connection.ConnectionConfig,arg2:jvm.DiagnosticSessionRequest):Promise<connection.QueryResult>;
export function JVMStartMonitoring(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function JVMStopMonitoring(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function ListSQLDirectory(arg1:string):Promise<connection.QueryResult>;
export function LogWindowDiagnostic(arg1:string,arg2:string):Promise<void>;
export function MongoDiscoverMembers(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
@@ -149,8 +180,6 @@ export function OpenDriverDownloadDirectory(arg1:string):Promise<connection.Quer
export function OpenSQLFile():Promise<connection.QueryResult>;
export function ListSQLDirectory(arg1:string):Promise<connection.QueryResult>;
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
export function ReadSQLFile(arg1:string):Promise<connection.QueryResult>;
@@ -223,8 +252,6 @@ export function RetrySecurityUpdateCurrentRound(arg1:app.RetrySecurityUpdateRequ
export function SaveConnection(arg1:connection.SavedConnectionInput):Promise<connection.SavedConnectionView>;
export function SelectSQLDirectory(arg1:string):Promise<connection.QueryResult>;
export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
export function SelectDataRootDirectory(arg1:string):Promise<connection.QueryResult>;
@@ -237,6 +264,8 @@ export function SelectDriverPackageDirectory(arg1:string):Promise<connection.Que
export function SelectDriverPackageFile(arg1:string):Promise<connection.QueryResult>;
export function SelectSQLDirectory(arg1:string):Promise<connection.QueryResult>;
export function SelectSSHKeyFile(arg1:string):Promise<connection.QueryResult>;
export function SetMacNativeWindowControls(arg1:boolean):Promise<void>;
@@ -247,4 +276,6 @@ export function StartSecurityUpdate(arg1:app.StartSecurityUpdateRequest):Promise
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function TestJVMConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;

View File

@@ -246,6 +246,66 @@ export function InstallUpdateAndRestart() {
return window['go']['app']['App']['InstallUpdateAndRestart']();
}
export function JVMApplyChange(arg1, arg2) {
return window['go']['app']['App']['JVMApplyChange'](arg1, arg2);
}
export function JVMCancelDiagnosticCommand(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['JVMCancelDiagnosticCommand'](arg1, arg2, arg3, arg4);
}
export function JVMExecuteDiagnosticCommand(arg1, arg2, arg3) {
return window['go']['app']['App']['JVMExecuteDiagnosticCommand'](arg1, arg2, arg3);
}
export function JVMGetMonitoringHistory(arg1, arg2) {
return window['go']['app']['App']['JVMGetMonitoringHistory'](arg1, arg2);
}
export function JVMGetValue(arg1, arg2) {
return window['go']['app']['App']['JVMGetValue'](arg1, arg2);
}
export function JVMListAuditRecords(arg1, arg2) {
return window['go']['app']['App']['JVMListAuditRecords'](arg1, arg2);
}
export function JVMListDiagnosticAuditRecords(arg1, arg2) {
return window['go']['app']['App']['JVMListDiagnosticAuditRecords'](arg1, arg2);
}
export function JVMListResources(arg1, arg2) {
return window['go']['app']['App']['JVMListResources'](arg1, arg2);
}
export function JVMPreviewChange(arg1, arg2) {
return window['go']['app']['App']['JVMPreviewChange'](arg1, arg2);
}
export function JVMProbeCapabilities(arg1) {
return window['go']['app']['App']['JVMProbeCapabilities'](arg1);
}
export function JVMProbeDiagnosticCapabilities(arg1) {
return window['go']['app']['App']['JVMProbeDiagnosticCapabilities'](arg1);
}
export function JVMStartDiagnosticSession(arg1, arg2) {
return window['go']['app']['App']['JVMStartDiagnosticSession'](arg1, arg2);
}
export function JVMStartMonitoring(arg1) {
return window['go']['app']['App']['JVMStartMonitoring'](arg1);
}
export function JVMStopMonitoring(arg1, arg2) {
return window['go']['app']['App']['JVMStopMonitoring'](arg1, arg2);
}
export function ListSQLDirectory(arg1) {
return window['go']['app']['App']['ListSQLDirectory'](arg1);
}
export function LogWindowDiagnostic(arg1, arg2) {
return window['go']['app']['App']['LogWindowDiagnostic'](arg1, arg2);
}
@@ -290,10 +350,6 @@ export function OpenSQLFile() {
return window['go']['app']['App']['OpenSQLFile']();
}
export function ListSQLDirectory(arg1) {
return window['go']['app']['App']['ListSQLDirectory'](arg1);
}
export function PreviewImportFile(arg1) {
return window['go']['app']['App']['PreviewImportFile'](arg1);
}
@@ -438,10 +494,6 @@ export function SaveConnection(arg1) {
return window['go']['app']['App']['SaveConnection'](arg1);
}
export function SelectSQLDirectory(arg1) {
return window['go']['app']['App']['SelectSQLDirectory'](arg1);
}
export function SaveGlobalProxy(arg1) {
return window['go']['app']['App']['SaveGlobalProxy'](arg1);
}
@@ -466,6 +518,10 @@ export function SelectDriverPackageFile(arg1) {
return window['go']['app']['App']['SelectDriverPackageFile'](arg1);
}
export function SelectSQLDirectory(arg1) {
return window['go']['app']['App']['SelectSQLDirectory'](arg1);
}
export function SelectSSHKeyFile(arg1) {
return window['go']['app']['App']['SelectSSHKeyFile'](arg1);
}
@@ -486,6 +542,10 @@ export function TestConnection(arg1) {
return window['go']['app']['App']['TestConnection'](arg1);
}
export function TestJVMConnection(arg1) {
return window['go']['app']['App']['TestJVMConnection'](arg1);
}
export function TruncateTables(arg1, arg2, arg3) {
return window['go']['app']['App']['TruncateTables'](arg1, arg2, arg3);
}

View File

@@ -456,6 +456,136 @@ export namespace connection {
return a;
}
}
export class JVMDiagnosticConfig {
enabled?: boolean;
transport?: string;
baseUrl?: string;
targetId?: string;
apiKey?: string;
allowObserveCommands?: boolean;
allowTraceCommands?: boolean;
allowMutatingCommands?: boolean;
timeoutSeconds?: number;
static createFrom(source: any = {}) {
return new JVMDiagnosticConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.transport = source["transport"];
this.baseUrl = source["baseUrl"];
this.targetId = source["targetId"];
this.apiKey = source["apiKey"];
this.allowObserveCommands = source["allowObserveCommands"];
this.allowTraceCommands = source["allowTraceCommands"];
this.allowMutatingCommands = source["allowMutatingCommands"];
this.timeoutSeconds = source["timeoutSeconds"];
}
}
export class JVMAgentConfig {
enabled?: boolean;
baseUrl?: string;
apiKey?: string;
timeoutSeconds?: number;
static createFrom(source: any = {}) {
return new JVMAgentConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.baseUrl = source["baseUrl"];
this.apiKey = source["apiKey"];
this.timeoutSeconds = source["timeoutSeconds"];
}
}
export class JVMEndpointConfig {
enabled?: boolean;
baseUrl?: string;
apiKey?: string;
timeoutSeconds?: number;
static createFrom(source: any = {}) {
return new JVMEndpointConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.baseUrl = source["baseUrl"];
this.apiKey = source["apiKey"];
this.timeoutSeconds = source["timeoutSeconds"];
}
}
export class JVMJMXConfig {
enabled?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
domainAllowlist?: string[];
static createFrom(source: any = {}) {
return new JVMJMXConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.host = source["host"];
this.port = source["port"];
this.username = source["username"];
this.password = source["password"];
this.domainAllowlist = source["domainAllowlist"];
}
}
export class JVMConfig {
environment?: string;
readOnly?: boolean;
allowedModes?: string[];
preferredMode?: string;
jmx?: JVMJMXConfig;
endpoint?: JVMEndpointConfig;
agent?: JVMAgentConfig;
diagnostic?: JVMDiagnosticConfig;
static createFrom(source: any = {}) {
return new JVMConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.environment = source["environment"];
this.readOnly = source["readOnly"];
this.allowedModes = source["allowedModes"];
this.preferredMode = source["preferredMode"];
this.jmx = this.convertValues(source["jmx"], JVMJMXConfig);
this.endpoint = this.convertValues(source["endpoint"], JVMEndpointConfig);
this.agent = this.convertValues(source["agent"], JVMAgentConfig);
this.diagnostic = this.convertValues(source["diagnostic"], JVMDiagnosticConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class HTTPTunnelConfig {
host: string;
port: number;
@@ -549,6 +679,7 @@ export namespace connection {
mongoAuthMechanism?: string;
mongoReplicaUser?: string;
mongoReplicaPassword?: string;
jvm?: JVMConfig;
static createFrom(source: any = {}) {
return new ConnectionConfig(source);
@@ -590,6 +721,7 @@ export namespace connection {
this.mongoAuthMechanism = source["mongoAuthMechanism"];
this.mongoReplicaUser = source["mongoReplicaUser"];
this.mongoReplicaPassword = source["mongoReplicaPassword"];
this.jvm = this.convertValues(source["jvm"], JVMConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -638,6 +770,11 @@ export namespace connection {
}
export class QueryResult {
success: boolean;
message: string;
@@ -802,6 +939,69 @@ export namespace connection {
}
export namespace jvm {
export class ChangeRequest {
providerMode: string;
resourceId: string;
action: string;
reason: string;
source?: string;
expectedVersion?: string;
payload?: Record<string, any>;
static createFrom(source: any = {}) {
return new ChangeRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.providerMode = source["providerMode"];
this.resourceId = source["resourceId"];
this.action = source["action"];
this.reason = source["reason"];
this.source = source["source"];
this.expectedVersion = source["expectedVersion"];
this.payload = source["payload"];
}
}
export class DiagnosticCommandRequest {
sessionId: string;
commandId: string;
command: string;
source?: string;
reason?: string;
static createFrom(source: any = {}) {
return new DiagnosticCommandRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.sessionId = source["sessionId"];
this.commandId = source["commandId"];
this.command = source["command"];
this.source = source["source"];
this.reason = source["reason"];
}
}
export class DiagnosticSessionRequest {
title?: string;
reason?: string;
static createFrom(source: any = {}) {
return new DiagnosticSessionRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.title = source["title"];
this.reason = source["reason"];
}
}
}
export namespace redis {
export class ZSetMember {

2
go.mod
View File

@@ -62,7 +62,7 @@ require (
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/gorilla/websocket v1.5.3
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect

194
internal/app/methods_jvm.go Normal file
View File

@@ -0,0 +1,194 @@
package app
import (
"path/filepath"
"strings"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/jvm"
)
var newJVMProvider = jvm.NewProvider
func buildJVMCapabilityError(mode string, cfg connection.ConnectionConfig, err error) jvm.Capability {
probeCfg := cfg
probeCfg.JVM.PreferredMode = mode
return jvm.Capability{
Mode: mode,
DisplayLabel: jvm.ModeDisplayLabel(mode),
Reason: jvm.DescribeConnectionTestError(probeCfg, err),
}
}
func resolveJVMProvider(cfg connection.ConnectionConfig) (connection.ConnectionConfig, jvm.Provider, error) {
return resolveJVMProviderForMode(cfg, "")
}
func resolveJVMProviderForMode(cfg connection.ConnectionConfig, mode string) (connection.ConnectionConfig, jvm.Provider, error) {
normalized, selectedMode, err := jvm.ResolveProviderMode(cfg, mode)
if err != nil {
return connection.ConnectionConfig{}, nil, err
}
normalized.JVM.PreferredMode = selectedMode
provider, err := newJVMProvider(selectedMode)
if err != nil {
return connection.ConnectionConfig{}, nil, err
}
return normalized, provider, nil
}
func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.QueryResult {
normalized, provider, err := resolveJVMProvider(cfg)
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: jvm.DescribeConnectionTestError(normalized, err)}
}
return connection.QueryResult{Success: true, Message: "JVM 连接成功"}
}
func (a *App) JVMListResources(cfg connection.ConnectionConfig, parentPath string) connection.QueryResult {
normalized, provider, err := resolveJVMProvider(cfg)
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, provider, err := resolveJVMProvider(cfg)
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}
}
func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRequest) connection.QueryResult {
var err error
req, err = jvm.NormalizeChangeRequest(req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
normalized, provider, err := resolveJVMProviderForMode(cfg, req.ProviderMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
preview, err := jvm.BuildChangePreview(a.ctx, provider, normalized, 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 {
var err error
req, err = jvm.NormalizeChangeRequest(req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
normalized, provider, err := resolveJVMProviderForMode(cfg, req.ProviderMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
preview, err := jvm.BuildChangePreview(a.ctx, provider, normalized, req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if !preview.Allowed {
message := strings.TrimSpace(preview.BlockingReason)
if message == "" {
message = "当前变更被 Guard 拦截"
}
return connection.QueryResult{Success: false, Message: message}
}
result, err := provider.ApplyChange(a.ctx, normalized, req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl")).Append(jvm.AuditRecord{
ConnectionID: normalized.ID,
ProviderMode: normalized.JVM.PreferredMode,
ResourceID: req.ResourceID,
Action: req.Action,
Reason: req.Reason,
Source: req.Source,
Result: result.Status,
}); err != nil {
if strings.TrimSpace(result.Message) == "" {
result.Message = "变更已执行,但审计记录写入失败: " + err.Error()
} else {
result.Message += ";审计记录写入失败: " + err.Error()
}
}
return connection.QueryResult{Success: true, Data: result}
}
func (a *App) JVMListAuditRecords(connectionID string, limit int) connection.QueryResult {
records, err := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl")).List(connectionID, limit)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: records}
}
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 {
probeCfg := normalized
probeCfg.JVM.PreferredMode = mode
provider, providerErr := newJVMProvider(mode)
if providerErr != nil {
items = append(items, buildJVMCapabilityError(mode, probeCfg, providerErr))
continue
}
caps, probeErr := provider.ProbeCapabilities(a.ctx, probeCfg)
if probeErr != nil {
items = append(items, buildJVMCapabilityError(mode, probeCfg, probeErr))
continue
}
items = append(items, caps...)
}
return connection.QueryResult{Success: true, Data: items}
}
func (a *App) auditRootDir() string {
if strings.TrimSpace(a.configDir) != "" {
return a.configDir
}
return resolveAppConfigDir()
}

View File

@@ -0,0 +1,294 @@
package app
import (
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/jvm"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
var newJVMDiagnosticTransport = jvm.NewDiagnosticTransport
const diagnosticChunkEvent = "jvm:diagnostic:chunk"
type diagnosticChunkEventPayload struct {
TabID string `json:"tabId"`
Chunk jvm.DiagnosticEventChunk `json:"chunk"`
}
func swapJVMDiagnosticTransportFactory(factory func(mode string) (jvm.DiagnosticTransport, error)) func() {
prev := newJVMDiagnosticTransport
newJVMDiagnosticTransport = factory
return func() { newJVMDiagnosticTransport = prev }
}
func resolveJVMDiagnosticTransport(cfg connection.ConnectionConfig) (connection.ConnectionConfig, jvm.DiagnosticTransport, error) {
normalized, err := jvm.NormalizeConnectionConfig(cfg)
if err != nil {
return connection.ConnectionConfig{}, nil, err
}
diagCfg, err := jvm.NormalizeDiagnosticConfig(normalized)
if err != nil {
return connection.ConnectionConfig{}, nil, err
}
if !diagCfg.Enabled {
return connection.ConnectionConfig{}, nil, errors.New("当前连接未启用 JVM 诊断增强模式")
}
normalized.JVM.Diagnostic = diagCfg
transport, err := newJVMDiagnosticTransport(diagCfg.Transport)
if err != nil {
return connection.ConnectionConfig{}, nil, err
}
return normalized, transport, nil
}
func (a *App) JVMProbeDiagnosticCapabilities(cfg connection.ConnectionConfig) connection.QueryResult {
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
items, err := transport.ProbeCapabilities(a.ctx, normalized)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: items}
}
func (a *App) JVMStartDiagnosticSession(cfg connection.ConnectionConfig, req jvm.DiagnosticSessionRequest) connection.QueryResult {
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
handle, err := transport.StartSession(a.ctx, normalized, req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: handle}
}
func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID string, req jvm.DiagnosticCommandRequest) connection.QueryResult {
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
req.SessionID = strings.TrimSpace(req.SessionID)
req.CommandID = strings.TrimSpace(req.CommandID)
req.Command = strings.TrimSpace(req.Command)
req.Source = strings.TrimSpace(req.Source)
req.Reason = strings.TrimSpace(req.Reason)
if req.SessionID == "" {
return connection.QueryResult{Success: false, Message: "诊断会话 ID 不能为空,请先创建会话"}
}
if req.Command == "" {
return connection.QueryResult{Success: false, Message: "诊断命令不能为空"}
}
if req.CommandID == "" {
req.CommandID = fmt.Sprintf("diag-%d", time.Now().UnixNano())
}
if req.Source == "" {
req.Source = "manual"
}
commandType, err := jvm.ValidateDiagnosticCommandPolicy(normalized.JVM.Diagnostic, req.Command)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
riskLevel := diagnosticRiskLevel(commandType)
auditStore := jvm.NewDiagnosticAuditStore(filepath.Join(a.auditRootDir(), "jvm_diag_audit.jsonl"))
var auditWarnings []string
if err := auditStore.Append(jvm.DiagnosticAuditRecord{
ConnectionID: normalized.ID,
SessionID: req.SessionID,
CommandID: req.CommandID,
Transport: normalized.JVM.Diagnostic.Transport,
Command: req.Command,
CommandType: commandType,
Source: req.Source,
Reason: req.Reason,
RiskLevel: riskLevel,
Status: "running",
}); err != nil {
auditWarnings = append(auditWarnings, "审计记录写入失败: "+err.Error())
}
terminalSeen := false
appendTerminalAudit := func(status string) {
if terminalSeen {
return
}
terminalSeen = true
if err := auditStore.Append(jvm.DiagnosticAuditRecord{
ConnectionID: normalized.ID,
SessionID: req.SessionID,
CommandID: req.CommandID,
Transport: normalized.JVM.Diagnostic.Transport,
Command: req.Command,
CommandType: commandType,
Source: req.Source,
Reason: req.Reason,
RiskLevel: riskLevel,
Status: status,
}); err != nil {
auditWarnings = append(auditWarnings, "审计记录写入失败: "+err.Error())
}
}
if binder, ok := transport.(interface{ SetEventSink(jvm.DiagnosticEventSink) }); ok {
binder.SetEventSink(func(chunk jvm.DiagnosticEventChunk) {
if chunk.Timestamp == 0 {
chunk.Timestamp = time.Now().UnixMilli()
}
if strings.TrimSpace(chunk.SessionID) == "" {
chunk.SessionID = req.SessionID
}
if strings.TrimSpace(chunk.CommandID) == "" {
chunk.CommandID = req.CommandID
}
a.emitDiagnosticChunk(tabID, chunk)
if isDiagnosticTerminalPhase(chunk.Phase) {
appendTerminalAudit(chunk.Phase)
}
})
}
if err := transport.ExecuteCommand(a.ctx, normalized, req); err != nil {
phase := "failed"
if strings.Contains(strings.ToLower(err.Error()), "canceled") {
phase = "canceled"
}
if !terminalSeen {
chunk := jvm.DiagnosticEventChunk{
SessionID: req.SessionID,
CommandID: req.CommandID,
Event: "diagnostic",
Phase: phase,
Content: err.Error(),
Timestamp: time.Now().UnixMilli(),
}
a.emitDiagnosticChunk(tabID, chunk)
appendTerminalAudit(phase)
}
return connection.QueryResult{Success: false, Message: joinDiagnosticMessages(err.Error(), auditWarnings)}
}
if !terminalSeen {
chunk := jvm.DiagnosticEventChunk{
SessionID: req.SessionID,
CommandID: req.CommandID,
Event: "diagnostic",
Phase: "completed",
Content: "诊断命令执行完成",
Timestamp: time.Now().UnixMilli(),
}
a.emitDiagnosticChunk(tabID, chunk)
appendTerminalAudit("completed")
}
return connection.QueryResult{
Success: true,
Message: joinDiagnosticMessages("", auditWarnings),
Data: map[string]any{
"sessionId": req.SessionID,
"commandId": req.CommandID,
"status": "accepted",
},
}
}
func (a *App) JVMCancelDiagnosticCommand(cfg connection.ConnectionConfig, tabID string, sessionID string, commandID string) connection.QueryResult {
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
sessionID = strings.TrimSpace(sessionID)
commandID = strings.TrimSpace(commandID)
if sessionID == "" || commandID == "" {
return connection.QueryResult{Success: false, Message: "取消命令缺少 sessionId 或 commandId"}
}
if err := transport.CancelCommand(a.ctx, normalized, sessionID, commandID); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
a.emitDiagnosticChunk(tabID, jvm.DiagnosticEventChunk{
SessionID: sessionID,
CommandID: commandID,
Event: "diagnostic",
Phase: "canceling",
Content: "已发送取消请求,等待诊断桥接端结束命令",
Timestamp: time.Now().UnixMilli(),
})
return connection.QueryResult{
Success: true,
Data: map[string]any{
"sessionId": sessionID,
"commandId": commandID,
"status": "cancel-requested",
},
}
}
func (a *App) JVMListDiagnosticAuditRecords(connectionID string, limit int) connection.QueryResult {
records, err := jvm.NewDiagnosticAuditStore(filepath.Join(a.auditRootDir(), "jvm_diag_audit.jsonl")).List(connectionID, limit)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: records}
}
func (a *App) emitDiagnosticChunk(tabID string, chunk jvm.DiagnosticEventChunk) {
if a.ctx == nil {
return
}
runtime.EventsEmit(a.ctx, diagnosticChunkEvent, diagnosticChunkEventPayload{
TabID: strings.TrimSpace(tabID),
Chunk: chunk,
})
}
func diagnosticRiskLevel(commandType string) string {
switch strings.TrimSpace(commandType) {
case jvm.DiagnosticCommandCategoryObserve:
return "low"
case jvm.DiagnosticCommandCategoryTrace:
return "medium"
default:
return "high"
}
}
func isDiagnosticTerminalPhase(phase string) bool {
switch strings.ToLower(strings.TrimSpace(phase)) {
case "completed", "failed", "canceled":
return true
default:
return false
}
}
func joinDiagnosticMessages(primary string, warnings []string) string {
items := make([]string, 0, 1+len(warnings))
if strings.TrimSpace(primary) != "" {
items = append(items, strings.TrimSpace(primary))
}
for _, warning := range warnings {
if strings.TrimSpace(warning) == "" {
continue
}
items = append(items, strings.TrimSpace(warning))
}
return strings.Join(items, "")
}

View File

@@ -0,0 +1,255 @@
package app
import (
"context"
"path/filepath"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/jvm"
)
type fakeDiagnosticTransport struct {
testErr error
caps []jvm.DiagnosticCapability
capsErr error
handle jvm.DiagnosticSessionHandle
startErr error
executeReq jvm.DiagnosticCommandRequest
executeErr error
cancelSession string
cancelCommand string
cancelErr error
}
func (f fakeDiagnosticTransport) Mode() string { return jvm.DiagnosticTransportAgentBridge }
func (f fakeDiagnosticTransport) TestConnection(context.Context, connection.ConnectionConfig) error {
return f.testErr
}
func (f fakeDiagnosticTransport) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]jvm.DiagnosticCapability, error) {
return f.caps, f.capsErr
}
func (f fakeDiagnosticTransport) StartSession(context.Context, connection.ConnectionConfig, jvm.DiagnosticSessionRequest) (jvm.DiagnosticSessionHandle, error) {
return f.handle, f.startErr
}
func (f fakeDiagnosticTransport) ExecuteCommand(context.Context, connection.ConnectionConfig, jvm.DiagnosticCommandRequest) error {
return f.executeErr
}
func (f fakeDiagnosticTransport) CancelCommand(context.Context, connection.ConnectionConfig, string, string) error {
return f.cancelErr
}
func (f fakeDiagnosticTransport) CloseSession(context.Context, connection.ConnectionConfig, string) error {
return nil
}
func TestJVMProbeDiagnosticCapabilitiesReturnsTransportPayload(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return fakeDiagnosticTransport{
caps: []jvm.DiagnosticCapability{{
Transport: jvm.DiagnosticTransportAgentBridge,
CanOpenSession: true,
CanStream: true,
}},
}, nil
})
defer restore()
res := app.JVMProbeDiagnosticCapabilities(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
items, ok := res.Data.([]jvm.DiagnosticCapability)
if !ok {
t.Fatalf("expected diagnostic capability payload, got %#v", res.Data)
}
if len(items) != 1 || items[0].Transport != jvm.DiagnosticTransportAgentBridge {
t.Fatalf("unexpected diagnostic capabilities: %#v", items)
}
}
func TestJVMStartDiagnosticSessionReturnsHandle(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return fakeDiagnosticTransport{
handle: jvm.DiagnosticSessionHandle{
SessionID: "sess-1",
Transport: jvm.DiagnosticTransportAgentBridge,
StartedAt: 1713945600000,
},
}, nil
})
defer restore()
res := app.JVMStartDiagnosticSession(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
},
},
}, jvm.DiagnosticSessionRequest{
Title: "排查线程堆积",
Reason: "先建立诊断会话",
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
handle, ok := res.Data.(jvm.DiagnosticSessionHandle)
if !ok {
t.Fatalf("expected diagnostic session handle, got %#v", res.Data)
}
if handle.SessionID != "sess-1" || handle.Transport != jvm.DiagnosticTransportAgentBridge {
t.Fatalf("unexpected diagnostic session handle: %#v", handle)
}
}
func TestJVMExecuteDiagnosticCommandReturnsAccepted(t *testing.T) {
app := NewAppWithSecretStore(nil)
recorder := &fakeDiagnosticTransport{}
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return diagnosticTransportRecorder{recorder: recorder}, nil
})
defer restore()
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-1",
Command: "thread -n 5",
Source: "manual",
Reason: "定位线程堆积",
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
if recorder.executeReq.Command != "thread -n 5" || recorder.executeReq.SessionID != "sess-1" {
t.Fatalf("unexpected execute request: %#v", recorder.executeReq)
}
}
func TestJVMCancelDiagnosticCommandDelegatesToTransport(t *testing.T) {
app := NewAppWithSecretStore(nil)
recorder := &fakeDiagnosticTransport{}
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return diagnosticTransportRecorder{recorder: recorder}, nil
})
defer restore()
res := app.JVMCancelDiagnosticCommand(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
},
},
}, "tab-diag-1", "sess-1", "cmd-1")
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
if recorder.cancelSession != "sess-1" || recorder.cancelCommand != "cmd-1" {
t.Fatalf("unexpected cancel request: %#v", recorder)
}
}
func TestJVMListDiagnosticAuditRecordsReturnsRecords(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
store := jvm.NewDiagnosticAuditStore(filepath.Join(app.auditRootDir(), "jvm_diag_audit.jsonl"))
if err := store.Append(jvm.DiagnosticAuditRecord{
ConnectionID: "conn-orders",
Transport: jvm.DiagnosticTransportAgentBridge,
SessionID: "sess-1",
CommandID: "cmd-1",
Command: "thread -n 5",
CommandType: jvm.DiagnosticCommandCategoryObserve,
RiskLevel: "low",
Status: "completed",
Reason: "定位线程堆积",
}); err != nil {
t.Fatalf("append audit record failed: %v", err)
}
res := app.JVMListDiagnosticAuditRecords("conn-orders", 10)
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
records, ok := res.Data.([]jvm.DiagnosticAuditRecord)
if !ok {
t.Fatalf("expected audit record slice, got %#v", res.Data)
}
if len(records) != 1 || records[0].Command != "thread -n 5" {
t.Fatalf("unexpected audit records: %#v", records)
}
}
type diagnosticTransportRecorder struct {
recorder *fakeDiagnosticTransport
}
func (d diagnosticTransportRecorder) Mode() string { return jvm.DiagnosticTransportAgentBridge }
func (d diagnosticTransportRecorder) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error {
return d.recorder.TestConnection(ctx, cfg)
}
func (d diagnosticTransportRecorder) ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]jvm.DiagnosticCapability, error) {
return d.recorder.ProbeCapabilities(ctx, cfg)
}
func (d diagnosticTransportRecorder) StartSession(ctx context.Context, cfg connection.ConnectionConfig, req jvm.DiagnosticSessionRequest) (jvm.DiagnosticSessionHandle, error) {
return d.recorder.StartSession(ctx, cfg, req)
}
func (d diagnosticTransportRecorder) ExecuteCommand(ctx context.Context, cfg connection.ConnectionConfig, req jvm.DiagnosticCommandRequest) error {
d.recorder.executeReq = req
return d.recorder.ExecuteCommand(ctx, cfg, req)
}
func (d diagnosticTransportRecorder) CancelCommand(ctx context.Context, cfg connection.ConnectionConfig, sessionID string, commandID string) error {
d.recorder.cancelSession = sessionID
d.recorder.cancelCommand = commandID
return d.recorder.CancelCommand(ctx, cfg, sessionID, commandID)
}
func (d diagnosticTransportRecorder) CloseSession(ctx context.Context, cfg connection.ConnectionConfig, sessionID string) error {
return d.recorder.CloseSession(ctx, cfg, sessionID)
}

View File

@@ -0,0 +1,77 @@
package app
import (
"context"
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/jvm"
)
type jvmMonitoringService interface {
Start(ctx context.Context, cfg connection.ConnectionConfig, requestedMode string) (jvm.MonitoringSessionSnapshot, error)
GetHistory(connectionID string, providerMode string) (jvm.MonitoringSessionSnapshot, error)
Stop(connectionID string, providerMode string) error
}
var currentJVMMonitoringManager jvmMonitoringService = jvm.NewMonitoringManager()
func (a *App) JVMStartMonitoring(cfg connection.ConnectionConfig) connection.QueryResult {
snapshot, err := currentJVMMonitoringManager.Start(a.ctx, cfg, "")
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: snapshot}
}
func (a *App) JVMGetMonitoringHistory(cfg connection.ConnectionConfig, providerMode string) connection.QueryResult {
connectionID, resolvedMode, err := resolveJVMMonitoringLookup(cfg, providerMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
snapshot, err := currentJVMMonitoringManager.GetHistory(connectionID, resolvedMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: snapshot}
}
func (a *App) JVMStopMonitoring(cfg connection.ConnectionConfig, providerMode string) connection.QueryResult {
connectionID, resolvedMode, err := resolveJVMMonitoringLookup(cfg, providerMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := currentJVMMonitoringManager.Stop(connectionID, resolvedMode); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: map[string]any{
"connectionId": connectionID,
"providerMode": resolvedMode,
"status": "stopped",
}}
}
func resolveJVMMonitoringLookup(cfg connection.ConnectionConfig, requestedMode string) (string, string, error) {
normalized, resolvedMode, err := jvm.ResolveProviderMode(cfg, requestedMode)
if err != nil {
return "", "", err
}
return resolveJVMMonitoringConnectionID(normalized), resolvedMode, nil
}
func resolveJVMMonitoringConnectionID(cfg connection.ConnectionConfig) string {
if trimmed := strings.TrimSpace(cfg.ID); trimmed != "" {
return trimmed
}
host := strings.TrimSpace(cfg.Host)
if host == "" {
host = "unknown"
}
if cfg.Port > 0 {
return fmt.Sprintf("%s:%d", host, cfg.Port)
}
return host
}

View File

@@ -0,0 +1,147 @@
package app
import (
"context"
"errors"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/jvm"
)
type fakeJVMMonitoringManager struct {
startSnapshot jvm.MonitoringSessionSnapshot
startErr error
historySnapshot jvm.MonitoringSessionSnapshot
historyErr error
stopErr error
startCfg connection.ConnectionConfig
startMode string
historyConnection string
historyMode string
stopConnection string
stopMode string
}
func (f *fakeJVMMonitoringManager) Start(_ context.Context, cfg connection.ConnectionConfig, mode string) (jvm.MonitoringSessionSnapshot, error) {
f.startCfg = cfg
f.startMode = mode
return f.startSnapshot, f.startErr
}
func (f *fakeJVMMonitoringManager) GetHistory(connectionID string, providerMode string) (jvm.MonitoringSessionSnapshot, error) {
f.historyConnection = connectionID
f.historyMode = providerMode
return f.historySnapshot, f.historyErr
}
func (f *fakeJVMMonitoringManager) Stop(connectionID string, providerMode string) error {
f.stopConnection = connectionID
f.stopMode = providerMode
return f.stopErr
}
func swapJVMMonitoringManager(manager jvmMonitoringService) func() {
prev := currentJVMMonitoringManager
currentJVMMonitoringManager = manager
return func() { currentJVMMonitoringManager = prev }
}
func TestJVMStartMonitoringReturnsManagerSnapshot(t *testing.T) {
app := NewAppWithSecretStore(nil)
manager := &fakeJVMMonitoringManager{
startSnapshot: jvm.MonitoringSessionSnapshot{
ConnectionID: "conn-monitor",
ProviderMode: jvm.ModeEndpoint,
Running: true,
Points: []jvm.JVMMonitoringPoint{
{Timestamp: 1713945600000, ThreadCount: 21},
},
},
}
restore := swapJVMMonitoringManager(manager)
defer restore()
res := app.JVMStartMonitoring(connection.ConnectionConfig{
ID: "conn-monitor",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: jvm.ModeEndpoint,
AllowedModes: []string{jvm.ModeEndpoint},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
snapshot, ok := res.Data.(jvm.MonitoringSessionSnapshot)
if !ok {
t.Fatalf("expected monitoring snapshot, got %#v", res.Data)
}
if !snapshot.Running || len(snapshot.Points) != 1 {
t.Fatalf("unexpected snapshot: %#v", snapshot)
}
if manager.startCfg.ID != "conn-monitor" {
t.Fatalf("expected manager to receive config ID, got %#v", manager.startCfg)
}
}
func TestJVMGetMonitoringHistoryResolvesPreferredMode(t *testing.T) {
app := NewAppWithSecretStore(nil)
manager := &fakeJVMMonitoringManager{
historySnapshot: jvm.MonitoringSessionSnapshot{
ConnectionID: "conn-history",
ProviderMode: jvm.ModeJMX,
Running: true,
},
}
restore := swapJVMMonitoringManager(manager)
defer restore()
res := app.JVMGetMonitoringHistory(connection.ConnectionConfig{
ID: "conn-history",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: jvm.ModeJMX,
AllowedModes: []string{jvm.ModeJMX},
},
}, "")
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
if manager.historyConnection != "conn-history" || manager.historyMode != jvm.ModeJMX {
t.Fatalf("unexpected manager history args: connection=%q mode=%q", manager.historyConnection, manager.historyMode)
}
}
func TestJVMStopMonitoringReturnsManagerError(t *testing.T) {
app := NewAppWithSecretStore(nil)
manager := &fakeJVMMonitoringManager{
stopErr: errors.New("session not found"),
}
restore := swapJVMMonitoringManager(manager)
defer restore()
res := app.JVMStopMonitoring(connection.ConnectionConfig{
ID: "conn-stop",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: jvm.ModeAgent,
AllowedModes: []string{jvm.ModeAgent},
},
}, "")
if res.Success {
t.Fatalf("expected failure, got %+v", res)
}
if res.Message != "session not found" {
t.Fatalf("expected message %q, got %#v", "session not found", res)
}
if manager.stopConnection != "conn-stop" || manager.stopMode != jvm.ModeAgent {
t.Fatalf("unexpected manager stop args: connection=%q mode=%q", manager.stopConnection, manager.stopMode)
}
}

View File

@@ -0,0 +1,770 @@
package app
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/jvm"
)
type fakeJVMProvider struct {
testErr error
probe []jvm.Capability
probeErr error
list []jvm.ResourceSummary
listErr error
value jvm.ValueSnapshot
valueErr error
preview jvm.ChangePreview
previewSet bool
previewErr error
apply jvm.ApplyResult
applyErr error
previewReq *jvm.ChangeRequest
applyReq *jvm.ChangeRequest
}
func (f fakeJVMProvider) Mode() string { return jvm.ModeJMX }
func (f fakeJVMProvider) TestConnection(context.Context, connection.ConnectionConfig) error {
return f.testErr
}
func (f fakeJVMProvider) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]jvm.Capability, error) {
return f.probe, f.probeErr
}
func (f fakeJVMProvider) ListResources(context.Context, connection.ConnectionConfig, string) ([]jvm.ResourceSummary, error) {
return f.list, f.listErr
}
func (f fakeJVMProvider) GetValue(context.Context, connection.ConnectionConfig, string) (jvm.ValueSnapshot, error) {
return f.value, f.valueErr
}
func (f fakeJVMProvider) PreviewChange(_ context.Context, _ connection.ConnectionConfig, req jvm.ChangeRequest) (jvm.ChangePreview, error) {
if f.previewReq != nil {
*f.previewReq = req
}
if !f.previewSet {
return jvm.ChangePreview{Allowed: true, Summary: "preview", RiskLevel: "low"}, f.previewErr
}
return f.preview, f.previewErr
}
func (f fakeJVMProvider) ApplyChange(_ context.Context, _ connection.ConnectionConfig, req jvm.ChangeRequest) (jvm.ApplyResult, error) {
if f.applyReq != nil {
*f.applyReq = req
}
return f.apply, f.applyErr
}
func swapJVMProviderFactory(factory func(mode string) (jvm.Provider, error)) func() {
prev := newJVMProvider
newJVMProvider = factory
return func() { newJVMProvider = prev }
}
func TestTestJVMConnectionUsesPreferredProvider(t *testing.T) {
app := NewAppWithSecretStore(nil)
var gotMode string
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
gotMode = mode
return fakeJVMProvider{}, nil
})
defer restore()
res := app.TestJVMConnection(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "endpoint",
AllowedModes: []string{"jmx", "endpoint"},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
if gotMode != "endpoint" {
t.Fatalf("expected provider mode endpoint, got %q", gotMode)
}
if res.Message != "JVM 连接成功" {
t.Fatalf("expected success message %q, got %q", "JVM 连接成功", res.Message)
}
}
func TestTestJVMConnectionReturnsProviderError(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{testErr: errors.New("dial failed")}, nil
})
defer restore()
res := app.TestJVMConnection(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
})
if res.Success {
t.Fatalf("expected failure, got %+v", res)
}
if res.Message != "dial failed" {
t.Fatalf("expected message %q, got %q", "dial failed", res.Message)
}
}
func TestTestJVMConnectionTranslatesJMXBusinessPortError(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{testErr: errors.New("jmx test connection failed: jmx helper ping failed for localhost:18080: JMX command ping failed for localhost:18080: Failed to retrieve RMIServer stub: javax.naming.CommunicationException [Root exception is java.rmi.ConnectIOException: non-JRMP server at remote endpoint]; details={\"exception\":\"java.lang.IllegalStateException\"}")}, nil
})
defer restore()
res := app.TestJVMConnection(connection.ConnectionConfig{
Type: "jvm",
Host: "localhost",
Port: 18080,
JVM: connection.JVMConfig{
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
})
if res.Success {
t.Fatalf("expected failure, got %+v", res)
}
if !strings.Contains(res.Message, "不是标准 JMX 远程管理端口") {
t.Fatalf("expected translated summary, got %q", res.Message)
}
if !strings.Contains(res.Message, "业务 `server.port`") {
t.Fatalf("expected actionable suggestion, got %q", res.Message)
}
if !strings.Contains(res.Message, "技术细节:") {
t.Fatalf("expected raw technical detail to be preserved, got %q", res.Message)
}
}
func TestTestJVMConnectionTranslatesAgentConnectionRefused(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{testErr: errors.New("agent probe request failed: Get \"http://127.0.0.1:19090/gonavi/agent/jvm\": dial tcp 127.0.0.1:19090: connect: connection refused")}, nil
})
defer restore()
res := app.TestJVMConnection(connection.ConnectionConfig{
Type: "jvm",
Host: "127.0.0.1",
JVM: connection.JVMConfig{
PreferredMode: "agent",
AllowedModes: []string{"agent"},
},
})
if res.Success {
t.Fatalf("expected failure, got %+v", res)
}
if !strings.Contains(res.Message, "目标 Agent 管理端口未监听") {
t.Fatalf("expected translated summary, got %q", res.Message)
}
if !strings.Contains(res.Message, "`-javaagent`") {
t.Fatalf("expected actionable suggestion, got %q", res.Message)
}
}
func TestTestJVMConnectionReturnsProviderFactoryError(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return nil, errors.New("factory unavailable")
})
defer restore()
res := app.TestJVMConnection(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "endpoint",
AllowedModes: []string{"endpoint"},
},
})
if res.Success {
t.Fatalf("expected failure, got %+v", res)
}
if res.Message != "factory unavailable" {
t.Fatalf("expected message %q, got %q", "factory unavailable", res.Message)
}
}
func TestJVMProbeCapabilitiesReturnsCapabilityArray(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
probe: []jvm.Capability{{Mode: jvm.ModeJMX, CanBrowse: true, CanWrite: false, CanPreview: false, DisplayLabel: "JMX"}},
}, nil
})
defer restore()
res := app.JVMProbeCapabilities(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
items, ok := res.Data.([]jvm.Capability)
if !ok || len(items) != 1 {
t.Fatalf("expected one capability, got %#v", res.Data)
}
}
func TestJVMProbeCapabilitiesIncludesReasonWhenProbeFails(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
probeErr: errors.New("probe failed"),
}, nil
})
defer restore()
res := app.JVMProbeCapabilities(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
items, ok := res.Data.([]jvm.Capability)
if !ok || len(items) != 1 {
t.Fatalf("expected one capability, got %#v", res.Data)
}
if items[0].Reason != "probe failed" {
t.Fatalf("expected reason %q, got %#v", "probe failed", items[0])
}
}
func TestJVMProbeCapabilitiesTranslatesJMXProbeErrorUsingCurrentMode(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
probeErr: errors.New("jmx test connection failed: jmx helper ping failed for localhost:18080: JMX command ping failed for localhost:18080: Failed to retrieve RMIServer stub: javax.naming.CommunicationException [Root exception is java.rmi.ConnectIOException: non-JRMP server at remote endpoint]; details={\"exception\":\"java.lang.IllegalStateException\"}"),
}, nil
})
defer restore()
res := app.JVMProbeCapabilities(connection.ConnectionConfig{
Type: "jvm",
Host: "localhost",
Port: 18080,
JVM: connection.JVMConfig{
PreferredMode: "endpoint",
AllowedModes: []string{"jmx"},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
items, ok := res.Data.([]jvm.Capability)
if !ok || len(items) != 1 {
t.Fatalf("expected one capability, got %#v", res.Data)
}
if !strings.Contains(items[0].Reason, "不是标准 JMX 远程管理端口") {
t.Fatalf("expected translated JMX reason, got %#v", items[0])
}
}
func TestJVMProbeCapabilitiesIncludesReasonWhenProviderFactoryFails(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return nil, errors.New("provider disabled")
})
defer restore()
res := app.JVMProbeCapabilities(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "endpoint",
AllowedModes: []string{"endpoint"},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
items, ok := res.Data.([]jvm.Capability)
if !ok || len(items) != 1 {
t.Fatalf("expected one capability, got %#v", res.Data)
}
if items[0].Reason != "provider disabled" {
t.Fatalf("expected reason %q, got %#v", "provider disabled", items[0])
}
if items[0].DisplayLabel != "Endpoint" {
t.Fatalf("expected display label %q, got %#v", "Endpoint", items[0])
}
}
func TestJVMProbeCapabilitiesUsesReadableLabelForAgentValidationError(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(jvm.NewProvider)
defer restore()
res := app.JVMProbeCapabilities(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "agent",
AllowedModes: []string{"agent"},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
items, ok := res.Data.([]jvm.Capability)
if !ok || len(items) != 1 {
t.Fatalf("expected one capability, got %#v", res.Data)
}
if items[0].DisplayLabel != "Agent" {
t.Fatalf("expected display label %q, got %#v", "Agent", items[0])
}
if !strings.Contains(items[0].Reason, "未填写 Agent Base URL") {
t.Fatalf("expected agent validation error, got %#v", items[0])
}
}
func TestJVMProbeCapabilitiesUsesReadableLabelForEndpointValidationError(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(jvm.NewProvider)
defer restore()
res := app.JVMProbeCapabilities(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "endpoint",
AllowedModes: []string{"endpoint"},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
items, ok := res.Data.([]jvm.Capability)
if !ok || len(items) != 1 {
t.Fatalf("expected one capability, got %#v", res.Data)
}
if items[0].DisplayLabel != "Endpoint" {
t.Fatalf("expected display label %q, got %#v", "Endpoint", items[0])
}
if !strings.Contains(items[0].Reason, "未填写 Endpoint Base URL") {
t.Fatalf("expected endpoint validation error, got %#v", items[0])
}
}
func TestJVMListResourcesReturnsProviderPayload(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
list: []jvm.ResourceSummary{
{
ID: "memory.heap",
Kind: "folder",
Name: "Heap",
Path: "/memory/heap",
ProviderMode: jvm.ModeJMX,
CanRead: true,
HasChildren: true,
},
},
}, nil
})
defer restore()
res := app.JVMListResources(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, "/memory")
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 summary, got %#v", res.Data)
}
if items[0].Path != "/memory/heap" {
t.Fatalf("expected resource path %q, got %#v", "/memory/heap", items[0])
}
}
func TestJVMGetValueReturnsProviderPayload(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
value: jvm.ValueSnapshot{
ResourceID: "memory.heap.used",
Kind: "metric",
Format: "number",
Value: 128,
Metadata: map[string]any{
"unit": "MiB",
},
},
}, nil
})
defer restore()
res := app.JVMGetValue(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, "/memory/heap/used")
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
snapshot, ok := res.Data.(jvm.ValueSnapshot)
if !ok {
t.Fatalf("expected value snapshot, got %#v", res.Data)
}
if snapshot.ResourceID != "memory.heap.used" {
t.Fatalf("expected resource id %q, got %#v", "memory.heap.used", snapshot)
}
if snapshot.Metadata["unit"] != "MiB" {
t.Fatalf("expected unit metadata %q, got %#v", "MiB", snapshot.Metadata)
}
}
func TestJVMApplyChangeReturnsProviderPayload(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
readOnly := false
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
value: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "stale",
},
},
apply: jvm.ApplyResult{
Status: "applied",
Message: "ok",
UpdatedValue: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "ready",
},
},
},
}, nil
})
defer restore()
res := app.JVMApplyChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Payload: map[string]any{
"status": "ready",
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
result, ok := res.Data.(jvm.ApplyResult)
if !ok {
t.Fatalf("expected apply result, got %#v", res.Data)
}
if result.Status != "applied" {
t.Fatalf("expected status %q, got %#v", "applied", result)
}
if result.UpdatedValue.ResourceID != "/cache/orders" {
t.Fatalf("expected updated resource id %q, got %#v", "/cache/orders", result.UpdatedValue)
}
}
func TestJVMApplyChangePersistsAuditSource(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
readOnly := false
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
value: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "stale",
},
},
apply: jvm.ApplyResult{
Status: "applied",
UpdatedValue: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "ready",
},
},
},
}, nil
})
defer restore()
res := app.JVMApplyChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: "endpoint",
AllowedModes: []string{"endpoint"},
},
}, jvm.ChangeRequest{
ProviderMode: "endpoint",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Source: "ai-plan",
Payload: map[string]any{
"status": "ready",
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
listRes := app.JVMListAuditRecords("conn-orders", 10)
if !listRes.Success {
t.Fatalf("expected audit list success, got %+v", listRes)
}
records, ok := listRes.Data.([]jvm.AuditRecord)
if !ok || len(records) != 1 {
t.Fatalf("expected one audit record, got %#v", listRes.Data)
}
if records[0].Source != "ai-plan" {
t.Fatalf("expected audit source %q, got %#v", "ai-plan", records[0])
}
}
func TestJVMApplyChangeNormalizesRequestBeforeProviderAndAudit(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
readOnly := false
var previewReq jvm.ChangeRequest
var applyReq jvm.ChangeRequest
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
value: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
},
previewReq: &previewReq,
applyReq: &applyReq,
apply: jvm.ApplyResult{
Status: "applied",
UpdatedValue: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
},
},
}, nil
})
defer restore()
res := app.JVMApplyChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: "endpoint",
AllowedModes: []string{"endpoint"},
},
}, jvm.ChangeRequest{
ProviderMode: " endpoint ",
ResourceID: " /cache/orders ",
Action: " put ",
Reason: " repair cache ",
Source: " manual ",
Payload: map[string]any{
"status": "ready",
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
if previewReq.ProviderMode != "endpoint" || previewReq.ResourceID != "/cache/orders" || previewReq.Action != "put" || previewReq.Reason != "repair cache" {
t.Fatalf("expected normalized preview request, got %#v", previewReq)
}
if applyReq.ProviderMode != "endpoint" || applyReq.ResourceID != "/cache/orders" || applyReq.Action != "put" || applyReq.Reason != "repair cache" || applyReq.Source != "manual" {
t.Fatalf("expected normalized apply request, got %#v", applyReq)
}
listRes := app.JVMListAuditRecords("conn-orders", 10)
if !listRes.Success {
t.Fatalf("expected audit list success, got %+v", listRes)
}
records, ok := listRes.Data.([]jvm.AuditRecord)
if !ok || len(records) != 1 {
t.Fatalf("expected one audit record, got %#v", listRes.Data)
}
if records[0].ProviderMode != "endpoint" || records[0].ResourceID != "/cache/orders" || records[0].Action != "put" || records[0].Reason != "repair cache" || records[0].Source != "manual" {
t.Fatalf("expected normalized audit record, got %#v", records[0])
}
}
func TestJVMPreviewChangeRejectsModeOutsideAllowedModes(t *testing.T) {
app := NewAppWithSecretStore(nil)
res := app.JVMPreviewChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "endpoint",
AllowedModes: []string{"endpoint"},
},
}, jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
})
if res.Success {
t.Fatalf("expected preview request to be rejected, got %+v", res)
}
if !strings.Contains(res.Message, "不允许使用") {
t.Fatalf("expected disallowed mode error, got %+v", res)
}
}
func TestJVMListAuditRecordsReturnsLatestRecords(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
store := jvm.NewAuditStore(filepath.Join(app.configDir, "jvm_audit.jsonl"))
for _, record := range []jvm.AuditRecord{
{Timestamp: 100, ConnectionID: "conn-orders", ProviderMode: "jmx", ResourceID: "/cache/orders", Action: "put", Reason: "first", Result: "applied"},
{Timestamp: 200, ConnectionID: "conn-other", ProviderMode: "jmx", ResourceID: "/cache/other", Action: "put", Reason: "other", Result: "applied"},
{Timestamp: 300, ConnectionID: "conn-orders", ProviderMode: "jmx", ResourceID: "/cache/orders", Action: "put", Reason: "latest", Result: "applied"},
} {
if err := store.Append(record); err != nil {
t.Fatalf("Append returned error: %v", err)
}
}
res := app.JVMListAuditRecords("conn-orders", 1)
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
records, ok := res.Data.([]jvm.AuditRecord)
if !ok {
t.Fatalf("expected audit record slice, got %#v", res.Data)
}
if len(records) != 1 {
t.Fatalf("expected one audit record, got %#v", records)
}
if records[0].Timestamp != 300 {
t.Fatalf("expected latest timestamp %d, got %#v", 300, records[0])
}
}
func TestJVMApplyChangeSurfacesAuditWriteFailure(t *testing.T) {
app := NewAppWithSecretStore(nil)
tempDir := t.TempDir()
blockerPath := filepath.Join(tempDir, "audit-blocker")
if err := os.WriteFile(blockerPath, []byte("blocker"), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
app.configDir = blockerPath
readOnly := false
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
value: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "stale",
},
},
apply: jvm.ApplyResult{
Status: "applied",
},
}, nil
})
defer restore()
res := app.JVMApplyChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Payload: map[string]any{
"status": "ready",
},
})
if !res.Success {
t.Fatalf("expected success despite audit failure, got %+v", res)
}
result, ok := res.Data.(jvm.ApplyResult)
if !ok {
t.Fatalf("expected apply result, got %#v", res.Data)
}
if !strings.Contains(result.Message, "审计记录写入失败") {
t.Fatalf("expected audit failure message, got %#v", result)
}
}

View File

@@ -11,6 +11,21 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
)
func (a *App) resolveDataSyncConfigSecrets(config sync.SyncConfig) (sync.SyncConfig, error) {
resolved := config
sourceConfig, err := a.resolveConnectionSecrets(config.SourceConfig)
if err != nil {
return resolved, fmt.Errorf("恢复源数据库连接密文失败: %w", err)
}
targetConfig, err := a.resolveConnectionSecrets(config.TargetConfig)
if err != nil {
return resolved, fmt.Errorf("恢复目标数据库连接密文失败: %w", err)
}
resolved.SourceConfig = sourceConfig
resolved.TargetConfig = targetConfig
return resolved, nil
}
// DataSync executes a data synchronization task
func (a *App) DataSync(config sync.SyncConfig) sync.SyncResult {
jobID := strings.TrimSpace(config.JobID)
@@ -33,8 +48,22 @@ func (a *App) DataSync(config sync.SyncConfig) sync.SyncResult {
"total": len(config.Tables),
})
resolvedConfig, err := a.resolveDataSyncConfigSecrets(config)
if err != nil {
res := sync.SyncResult{
Success: false,
Message: err.Error(),
Logs: []string{err.Error()},
}
runtime.EventsEmit(a.ctx, sync.EventSyncDone, map[string]any{
"jobId": jobID,
"result": res,
})
return res
}
engine := sync.NewSyncEngine(reporter)
res := engine.RunSync(config)
res := engine.RunSync(resolvedConfig)
runtime.EventsEmit(a.ctx, sync.EventSyncDone, map[string]any{
"jobId": jobID,
@@ -67,8 +96,19 @@ func (a *App) DataSyncAnalyze(config sync.SyncConfig) connection.QueryResult {
"type": "analyze",
})
resolvedConfig, err := a.resolveDataSyncConfigSecrets(config)
if err != nil {
res := sync.SyncResult{Success: false, Message: err.Error(), Logs: []string{err.Error()}}
runtime.EventsEmit(a.ctx, sync.EventSyncDone, map[string]any{
"jobId": jobID,
"result": res,
"type": "analyze",
})
return connection.QueryResult{Success: false, Message: err.Error(), Data: res}
}
engine := sync.NewSyncEngine(reporter)
res := engine.Analyze(config)
res := engine.Analyze(resolvedConfig)
runtime.EventsEmit(a.ctx, sync.EventSyncDone, map[string]any{
"jobId": jobID,
@@ -90,8 +130,13 @@ func (a *App) DataSyncPreview(config sync.SyncConfig, tableName string, limit in
config.JobID = jobID
}
resolvedConfig, err := a.resolveDataSyncConfigSecrets(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
engine := sync.NewSyncEngine(sync.Reporter{})
preview, err := engine.Preview(config, tableName, limit)
preview, err := engine.Preview(resolvedConfig, tableName, limit)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}

View File

@@ -0,0 +1,78 @@
package app
import (
"testing"
"GoNavi-Wails/internal/connection"
datasync "GoNavi-Wails/internal/sync"
)
func TestResolveDataSyncConfigSecretsRestoresSavedSourceAndTargetPasswords(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "source-pg",
Name: "Source PostgreSQL",
Config: connection.ConnectionConfig{
ID: "source-pg",
Type: "postgres",
Host: "source.local",
Port: 5432,
User: "postgres",
Password: "source-secret",
Database: "schedule",
},
})
if err != nil {
t.Fatalf("SaveConnection source returned error: %v", err)
}
_, err = app.SaveConnection(connection.SavedConnectionInput{
ID: "target-pg",
Name: "Target PostgreSQL",
Config: connection.ConnectionConfig{
ID: "target-pg",
Type: "postgres",
Host: "target.local",
Port: 5432,
User: "postgres",
Password: "target-secret",
Database: "warehouse",
},
})
if err != nil {
t.Fatalf("SaveConnection target returned error: %v", err)
}
resolved, err := app.resolveDataSyncConfigSecrets(datasync.SyncConfig{
SourceConfig: connection.ConnectionConfig{
ID: "source-pg",
Type: "postgres",
Host: "source.local",
Port: 5432,
User: "postgres",
Database: "schedule",
},
TargetConfig: connection.ConnectionConfig{
ID: "target-pg",
Type: "postgres",
Host: "target.local",
Port: 5432,
User: "postgres",
Database: "warehouse",
},
Tables: []string{"jobs"},
})
if err != nil {
t.Fatalf("resolveDataSyncConfigSecrets returned error: %v", err)
}
if resolved.SourceConfig.Password != "source-secret" {
t.Fatalf("expected source password to be restored, got %q", resolved.SourceConfig.Password)
}
if resolved.TargetConfig.Password != "target-secret" {
t.Fatalf("expected target password to be restored, got %q", resolved.TargetConfig.Password)
}
if resolved.SourceConfig.Database != "schedule" || resolved.TargetConfig.Database != "warehouse" {
t.Fatalf("expected selected databases to be preserved, got source=%q target=%q", resolved.SourceConfig.Database, resolved.TargetConfig.Database)
}
}

View File

@@ -9,12 +9,16 @@ import (
var AppVersion = "0.0.0"
var AppBuildTime = ""
var developmentVersionPathResolver = defaultDevelopmentVersionPaths
var packageVersionPathResolver = defaultPackageVersionPaths
func getCurrentVersion() string {
version := strings.TrimSpace(AppVersion)
if version == "" || version == "0.0.0" {
if env := strings.TrimSpace(os.Getenv("GONAVI_VERSION")); env != "" {
version = env
} else if devVersion, err := readDevelopmentVersion(); err == nil && devVersion != "" {
version = devVersion
} else if pkgVersion, err := readPackageVersion(); err == nil && pkgVersion != "" {
version = pkgVersion
}
@@ -22,7 +26,20 @@ func getCurrentVersion() string {
return normalizeVersion(version)
}
func readPackageVersion() (string, error) {
func defaultDevelopmentVersionPaths() []string {
paths := []string{
filepath.Join("version", "dev-version.txt"),
}
exe, err := os.Executable()
if err == nil {
base := filepath.Dir(exe)
paths = append(paths, filepath.Join(base, "version", "dev-version.txt"))
paths = append(paths, filepath.Join(base, "..", "version", "dev-version.txt"))
}
return paths
}
func defaultPackageVersionPaths() []string {
paths := []string{
filepath.Join("frontend", "package.json"),
}
@@ -32,7 +49,32 @@ func readPackageVersion() (string, error) {
paths = append(paths, filepath.Join(base, "frontend", "package.json"))
paths = append(paths, filepath.Join(base, "..", "frontend", "package.json"))
}
return paths
}
func readDevelopmentVersion() (string, error) {
return readPlainVersionFromPaths(developmentVersionPathResolver())
}
func readPackageVersion() (string, error) {
return readJSONVersionFromPaths(packageVersionPathResolver())
}
func readPlainVersionFromPaths(paths []string) (string, error) {
for _, p := range paths {
data, err := os.ReadFile(p)
if err != nil {
continue
}
if version := strings.TrimSpace(string(data)); version != "" {
return version, nil
}
}
return "", os.ErrNotExist
}
func readJSONVersionFromPaths(paths []string) (string, error) {
for _, p := range paths {
data, err := os.ReadFile(p)
if err != nil {

View File

@@ -0,0 +1,67 @@
package app
import (
"os"
"path/filepath"
"testing"
)
func TestGetCurrentVersionUsesDevelopmentVersionFileWhenUnset(t *testing.T) {
tempDir := t.TempDir()
devVersionPath := filepath.Join(tempDir, "dev-version.txt")
if err := os.WriteFile(devVersionPath, []byte("0.0.1-test\n"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
originalAppVersion := AppVersion
originalDevResolver := developmentVersionPathResolver
originalPackageResolver := packageVersionPathResolver
AppVersion = "0.0.0"
developmentVersionPathResolver = func() []string {
return []string{devVersionPath}
}
packageVersionPathResolver = func() []string {
return nil
}
t.Setenv("GONAVI_VERSION", "")
defer func() {
AppVersion = originalAppVersion
developmentVersionPathResolver = originalDevResolver
packageVersionPathResolver = originalPackageResolver
}()
got := getCurrentVersion()
if got != "0.0.1-test" {
t.Fatalf("expected development version file fallback, got %q", got)
}
}
func TestGetCurrentVersionPrefersEnvOverDevelopmentVersionFile(t *testing.T) {
tempDir := t.TempDir()
devVersionPath := filepath.Join(tempDir, "dev-version.txt")
if err := os.WriteFile(devVersionPath, []byte("0.0.1-test\n"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
originalAppVersion := AppVersion
originalDevResolver := developmentVersionPathResolver
originalPackageResolver := packageVersionPathResolver
AppVersion = "0.0.0"
developmentVersionPathResolver = func() []string {
return []string{devVersionPath}
}
packageVersionPathResolver = func() []string {
return nil
}
t.Setenv("GONAVI_VERSION", "dev-override")
defer func() {
AppVersion = originalAppVersion
developmentVersionPathResolver = originalDevResolver
packageVersionPathResolver = originalPackageResolver
}()
got := getCurrentVersion()
if got != "dev-override" {
t.Fatalf("expected env override, got %q", got)
}
}

Some files were not shown because too many files have changed in this diff Show More