mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-11 22:59:48 +08:00
Release/0.7.0
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
357
build-release.sh
357
build-release.sh
@@ -1,16 +1,42 @@
|
||||
#!/bin/bash
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# 配置
|
||||
APP_NAME="GoNavi"
|
||||
DIST_DIR="dist"
|
||||
BUILD_BIN_DIR="build/bin"
|
||||
DEFAULT_BINARY_NAME="GoNavi" # 对应 wails.json 中的 outputfilename
|
||||
DEV_VERSION_FILE="version/dev-version.txt"
|
||||
DEFAULT_DEV_VERSION="0.0.1-test"
|
||||
|
||||
# 提取版本号
|
||||
VERSION=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]')
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="0.0.0"
|
||||
fi
|
||||
resolve_build_version() {
|
||||
if [ -n "${GONAVI_VERSION:-}" ]; then
|
||||
printf '%s\n' "${GONAVI_VERSION}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -f "$DEV_VERSION_FILE" ]; then
|
||||
local dev_version
|
||||
dev_version=$(head -n 1 "$DEV_VERSION_FILE" | tr -d '\r' | tr -d '[:space:]')
|
||||
if [ -n "$dev_version" ]; then
|
||||
printf '%s\n' "$dev_version"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
local package_version
|
||||
package_version=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[:space:]')
|
||||
if [ -n "$package_version" ]; then
|
||||
printf '%s\n' "$package_version"
|
||||
return
|
||||
fi
|
||||
|
||||
printf '%s\n' "$DEFAULT_DEV_VERSION"
|
||||
}
|
||||
|
||||
VERSION="$(resolve_build_version)"
|
||||
echo "ℹ️ 检测到版本号: $VERSION"
|
||||
LDFLAGS="-s -w -X GoNavi-Wails/internal/app.AppVersion=$VERSION"
|
||||
|
||||
@@ -94,280 +120,79 @@ clear_macos_bundle_xattrs() {
|
||||
fi
|
||||
}
|
||||
|
||||
verify_macos_dmg_bundle_signature() {
|
||||
local dmg_path="$1"
|
||||
local mount_dir=""
|
||||
local app_path=""
|
||||
package_macos_bundle_zip() {
|
||||
local app_path="$1"
|
||||
local archive_path="$2"
|
||||
local archive_abs
|
||||
|
||||
if [ -z "$dmg_path" ] || [ ! -f "$dmg_path" ]; then
|
||||
echo -e "${RED} ❌ DMG 文件不存在,无法校验签名:$dmg_path${NC}"
|
||||
return 1
|
||||
fi
|
||||
if ! command -v hdiutil >/dev/null 2>&1 || ! command -v codesign >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW} ⚠️ 当前环境缺少 hdiutil 或 codesign,跳过 DMG 内应用签名校验。${NC}"
|
||||
return 0
|
||||
if [ ! -d "$app_path" ]; then
|
||||
echo -e "${RED} ❌ 未找到 macOS 应用包:$app_path${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mount_dir=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-dmg-verify.XXXXXX")
|
||||
if [ -z "$mount_dir" ] || [ ! -d "$mount_dir" ]; then
|
||||
echo -e "${RED} ❌ 创建 DMG 校验挂载目录失败。${NC}"
|
||||
return 1
|
||||
archive_abs="$(cd "$(dirname "$archive_path")" && pwd)/$(basename "$archive_path")"
|
||||
rm -f "$archive_path"
|
||||
if command -v ditto >/dev/null 2>&1; then
|
||||
ditto -c -k --sequesterRsrc --keepParent "$app_path" "$archive_abs"
|
||||
elif command -v zip >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$(dirname "$app_path")" && \
|
||||
zip -qry "$archive_abs" "$(basename "$app_path")"
|
||||
)
|
||||
else
|
||||
echo -e "${RED} ❌ 未找到 ditto/zip,无法打包 macOS 应用。${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! hdiutil attach -nobrowse -readonly -mountpoint "$mount_dir" "$dmg_path" >/dev/null 2>&1; then
|
||||
rmdir "$mount_dir" >/dev/null 2>&1 || true
|
||||
echo -e "${RED} ❌ 挂载 DMG 失败,无法校验签名。${NC}"
|
||||
return 1
|
||||
if [ ! -f "$archive_abs" ]; then
|
||||
echo -e "${RED} ❌ macOS 应用归档失败:$archive_abs${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
app_path=$(find "$mount_dir" -maxdepth 1 -name "*.app" -print -quit)
|
||||
if [ -z "$app_path" ] || [ ! -d "$app_path" ]; then
|
||||
hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
|
||||
rmdir "$mount_dir" >/dev/null 2>&1 || true
|
||||
echo -e "${RED} ❌ DMG 内未找到 .app 应用包。${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! codesign --verify --deep --strict --verbose=4 "$app_path" >/dev/null 2>&1; then
|
||||
echo -e "${RED} ❌ DMG 内 .app 签名校验失败:$(basename "$app_path")${NC}"
|
||||
codesign --verify --deep --strict --verbose=4 "$app_path" 2>&1 | sed 's/^/ /'
|
||||
hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
|
||||
rmdir "$mount_dir" >/dev/null 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
|
||||
rmdir "$mount_dir" >/dev/null 2>&1 || true
|
||||
return 0
|
||||
}
|
||||
|
||||
MAC_VOLICON_PATH="build/darwin/icon.icns"
|
||||
if [ ! -f "$MAC_VOLICON_PATH" ]; then
|
||||
MAC_VOLICON_PATH=""
|
||||
fi
|
||||
package_macos_release() {
|
||||
local platform="$1"
|
||||
local archive_suffix="$2"
|
||||
|
||||
echo -e "${GREEN}🍎 正在构建 macOS (${platform})...${NC}"
|
||||
wails build -platform "darwin/${platform}" -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED} ❌ macOS ${platform} 构建失败。${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
local app_src="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
||||
local app_dest_name="${APP_NAME}-${VERSION}-${archive_suffix}.app"
|
||||
local zip_name="${APP_NAME}-${VERSION}-${archive_suffix}.zip"
|
||||
|
||||
mv "$app_src" "$DIST_DIR/$app_dest_name"
|
||||
|
||||
local app_bin_path
|
||||
app_bin_path=$(find "$DIST_DIR/$app_dest_name/Contents/MacOS" -maxdepth 1 -type f -print -quit)
|
||||
if [ -z "$app_bin_path" ] || [ ! -f "$app_bin_path" ]; then
|
||||
echo -e "${RED} ❌ 未找到 macOS ${platform} 主程序文件。${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW} ⚠️ macOS ${platform} 改为无交互 ZIP 打包,不再生成 DMG。${NC}"
|
||||
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (${platform})..."
|
||||
clear_macos_bundle_xattrs "$DIST_DIR/$app_dest_name"
|
||||
codesign --force --deep --sign - "$DIST_DIR/$app_dest_name"
|
||||
|
||||
echo " 📦 正在打包 macOS 应用归档 (${platform})..."
|
||||
package_macos_bundle_zip "$DIST_DIR/$app_dest_name" "$DIST_DIR/$zip_name"
|
||||
rm -rf "$DIST_DIR/$app_dest_name"
|
||||
echo " ✅ 已生成 $zip_name"
|
||||
}
|
||||
|
||||
echo -e "${GREEN}🚀 开始构建 $APP_NAME $VERSION...${NC}"
|
||||
|
||||
# 清理并创建输出目录
|
||||
rm -rf $DIST_DIR
|
||||
mkdir -p $DIST_DIR
|
||||
rm -rf "$DIST_DIR"
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
# --- macOS ARM64 构建 ---
|
||||
echo -e "${GREEN}🍎 正在构建 macOS (arm64)...${NC}"
|
||||
wails build -platform darwin/arm64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
||||
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-arm64.app"
|
||||
DMG_NAME="${APP_NAME}-${VERSION}-mac-arm64.dmg"
|
||||
|
||||
# 移动 .app 到 dist
|
||||
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
|
||||
|
||||
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
|
||||
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
|
||||
echo -e "${YELLOW} ⚠️ macOS arm64 不再执行 UPX 压缩,保留原始主程序。${NC}"
|
||||
else
|
||||
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件。${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏)
|
||||
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..."
|
||||
clear_macos_bundle_xattrs "$DIST_DIR/$APP_DEST_NAME"
|
||||
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
|
||||
|
||||
# 创建 DMG
|
||||
if command -v create-dmg &> /dev/null; then
|
||||
echo " 📦 正在打包 DMG (arm64)..."
|
||||
# 移除已存在的 DMG (以防万一)
|
||||
rm -f "$DIST_DIR/$DMG_NAME"
|
||||
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
|
||||
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-arm64.XXXXXX")
|
||||
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
|
||||
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
|
||||
else
|
||||
if command -v ditto &> /dev/null; then
|
||||
ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
else
|
||||
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
fi
|
||||
|
||||
# 注意:本地验证表明 `--sandbox-safe` 与“目录作为 source”组合会污染 DMG 内 .app 的扩展属性,
|
||||
# 导致签名校验失败,因此这里显式禁用该参数,优先保证产物可打开。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO)
|
||||
if [ -n "$MAC_VOLICON_PATH" ]; then
|
||||
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon。${NC}"
|
||||
fi
|
||||
|
||||
create-dmg "${CREATE_DMG_ARGS[@]}" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 800 400 \
|
||||
--icon-size 100 \
|
||||
--icon "$APP_DEST_NAME" 200 190 \
|
||||
--hide-extension "$APP_DEST_NAME" \
|
||||
--app-drop-link 600 185 \
|
||||
"$DIST_DIR/$DMG_NAME" \
|
||||
"$STAGE_DIR"
|
||||
|
||||
CREATE_DMG_EXIT_CODE=$?
|
||||
rm -rf "$STAGE_DIR"
|
||||
|
||||
if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then
|
||||
echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}"
|
||||
else
|
||||
# create-dmg 可能会在失败时遗留 rw.*.dmg 中间产物;不要直接当作最终产物使用
|
||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
||||
RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit)
|
||||
if [ -n "$RW_FILE" ]; then
|
||||
echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}"
|
||||
hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
rm -f "$RW_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 防御性:即使生成了目标文件,也要确保不是 UDRW(UDRW 在 Finder 下可能表现为“已损坏/无法打开”)
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}')
|
||||
if [ "$DMG_FORMAT" = "UDRW" ]; then
|
||||
echo -e "${YELLOW} ⚠️ 检测到 UDRW(可写原始映像),正在转换为 UDZO...${NC}"
|
||||
TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME"
|
||||
rm -f "$TMP_UDZO"
|
||||
hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
|
||||
elif ! verify_macos_dmg_bundle_signature "$DIST_DIR/$DMG_NAME"; then
|
||||
echo -e "${RED} ❌ DMG 内应用签名校验失败,保留 .app 与 .dmg 以便排查。${NC}"
|
||||
else
|
||||
# 删除中间的 .app 文件,保持目录整洁
|
||||
rm -rf "$DIST_DIR/$APP_DEST_NAME"
|
||||
echo " ✅ 已生成 $DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
||||
echo -e "${RED} ❌ DMG 生成失败,请检查 create-dmg 输出。${NC}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具,跳过 DMG 打包,仅保留 .app。${NC}"
|
||||
echo " 安装命令: brew install create-dmg"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED} ❌ macOS arm64 构建失败。${NC}"
|
||||
fi
|
||||
|
||||
# --- macOS AMD64 构建 ---
|
||||
echo -e "${GREEN}🍎 正在构建 macOS (amd64)...${NC}"
|
||||
wails build -platform darwin/amd64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
||||
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-amd64.app"
|
||||
DMG_NAME="${APP_NAME}-${VERSION}-mac-amd64.dmg"
|
||||
|
||||
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
|
||||
|
||||
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
|
||||
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
|
||||
echo -e "${YELLOW} ⚠️ macOS amd64 不再执行 UPX 压缩,保留原始主程序。${NC}"
|
||||
else
|
||||
echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件。${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ad-hoc 代码签名
|
||||
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..."
|
||||
clear_macos_bundle_xattrs "$DIST_DIR/$APP_DEST_NAME"
|
||||
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
|
||||
|
||||
if command -v create-dmg &> /dev/null; then
|
||||
echo " 📦 正在打包 DMG (amd64)..."
|
||||
rm -f "$DIST_DIR/$DMG_NAME"
|
||||
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
|
||||
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-amd64.XXXXXX")
|
||||
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
|
||||
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
|
||||
else
|
||||
if command -v ditto &> /dev/null; then
|
||||
ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
else
|
||||
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
fi
|
||||
|
||||
# 注意:本地验证表明 `--sandbox-safe` 与“目录作为 source”组合会污染 DMG 内 .app 的扩展属性,
|
||||
# 导致签名校验失败,因此这里显式禁用该参数,优先保证产物可打开。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO)
|
||||
if [ -n "$MAC_VOLICON_PATH" ]; then
|
||||
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon。${NC}"
|
||||
fi
|
||||
|
||||
create-dmg "${CREATE_DMG_ARGS[@]}" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 800 400 \
|
||||
--icon-size 100 \
|
||||
--icon "$APP_DEST_NAME" 200 190 \
|
||||
--hide-extension "$APP_DEST_NAME" \
|
||||
--app-drop-link 600 185 \
|
||||
"$DIST_DIR/$DMG_NAME" \
|
||||
"$STAGE_DIR"
|
||||
|
||||
CREATE_DMG_EXIT_CODE=$?
|
||||
rm -rf "$STAGE_DIR"
|
||||
|
||||
if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then
|
||||
echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}"
|
||||
else
|
||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
||||
RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit)
|
||||
if [ -n "$RW_FILE" ]; then
|
||||
echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}"
|
||||
hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
rm -f "$RW_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}')
|
||||
if [ "$DMG_FORMAT" = "UDRW" ]; then
|
||||
echo -e "${YELLOW} ⚠️ 检测到 UDRW(可写原始映像),正在转换为 UDZO...${NC}"
|
||||
TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME"
|
||||
rm -f "$TMP_UDZO"
|
||||
hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
|
||||
elif ! verify_macos_dmg_bundle_signature "$DIST_DIR/$DMG_NAME"; then
|
||||
echo -e "${RED} ❌ DMG 内应用签名校验失败,保留 .app 与 .dmg 以便排查。${NC}"
|
||||
else
|
||||
rm -rf "$DIST_DIR/$APP_DEST_NAME"
|
||||
echo " ✅ 已生成 $DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
||||
echo -e "${RED} ❌ DMG 生成失败。${NC}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具。${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED} ❌ macOS amd64 构建失败。${NC}"
|
||||
fi
|
||||
package_macos_release "arm64" "mac-arm64"
|
||||
package_macos_release "amd64" "mac-amd64"
|
||||
|
||||
# --- Windows AMD64 构建 ---
|
||||
echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}"
|
||||
|
||||
1432
docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md
Normal file
1432
docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,483 @@
|
||||
# JVM 缓存可视化编辑设计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前用户在公司 Java 项目中经常把缓存或运行时状态直接保存在 JVM 内存中。出现数据脏值、缓存穿透、临时纠偏或排障时,通常只有两种方式:
|
||||
|
||||
- 为特定业务临时补管理接口
|
||||
- 重启应用并依赖重新初始化
|
||||
|
||||
这两种方式都存在明显问题:
|
||||
|
||||
- 临时接口会污染业务代码,并带来后续维护和权限风险
|
||||
- 重启应用成本高,且不适合用于精确修复单个缓存项
|
||||
|
||||
GoNavi 现有已具备三类可复用基础:
|
||||
|
||||
- 统一连接与工作台能力:`frontend/src/components/ConnectionModal.tsx`、`frontend/src/components/Sidebar.tsx`、`frontend/src/components/TabManager.tsx`
|
||||
- 独立运行时能力样板:Redis 通过 `internal/app/methods_redis.go` 和专用前端视图实现,不依赖 SQL `Database` 抽象
|
||||
- AI 与日志能力底座:`frontend/src/components/AIChatPanel.tsx`、`frontend/src/components/QueryEditor.tsx`、`frontend/src/components/LogPanel.tsx`
|
||||
|
||||
因此,GoNavi 有条件扩展出 JVM 运行时连接与受控编辑能力,但不能简单把该需求理解为“新数据库驱动”。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 为 GoNavi 增加统一的 `JVM Connector` 子系统,用于连接和浏览 Java 服务的运行时缓存/管理对象
|
||||
- 在同一套 UI 下支持多种接入模式,并根据目标 JVM 能力自动协商或手动切换
|
||||
- 提供结构化的缓存浏览、值检查、受控修改、操作预览和审计记录
|
||||
- 允许 AI 参与解释、分析和生成修改计划,但不默认开放 AI 自动执行
|
||||
- 尽量避免强依赖 `-javaagent` 或运行时动态 attach,适配企业内对生产进程注入普遍敏感的环境
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
- 不承诺“任意 JVM 内任意对象均可直接读写”
|
||||
- 不在首期支持任意 Java 表达式执行、任意反射路径写值或任意 classloader 深度探测
|
||||
- 不把 JVM 功能强行塞进现有 SQL `Database` / driver-agent 抽象
|
||||
- 不承诺通过 Agent 模式支持所有缓存框架或任意深层对象写入
|
||||
- 不绕过目标服务现有认证、鉴权和网络边界
|
||||
|
||||
## 4. 需求与约束
|
||||
|
||||
### 4.1 需求清单
|
||||
|
||||
- 统一配置 JVM 连接
|
||||
- 探测当前 JVM 支持的接入模式与可用能力
|
||||
- 浏览缓存空间、管理对象和受控操作
|
||||
- 查看值快照与元数据
|
||||
- 执行受控修改,并提供 before/after 预览
|
||||
- 将操作结果写入审计记录
|
||||
- 支持 AI 对资源结构和修改方案进行分析
|
||||
|
||||
### 4.2 已确认约束
|
||||
|
||||
- 用户倾向通用型产品形态,但目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
|
||||
- 企业环境下,稳定性与安全性优先级高于“黑科技式通用能力”
|
||||
- 一期应优先基于标准协议和业务可控接入面,而不是侵入式 runtime 操作
|
||||
|
||||
## 5. 现状分析
|
||||
|
||||
### 5.1 GoNavi 架构启示
|
||||
|
||||
- `internal/db/database.go` 面向标准化数据源 CRUD,适合 SQL 类资源
|
||||
- `internal/app/methods_redis.go` 证明 GoNavi 已支持“独立运行时系统能力线”
|
||||
- `frontend/src/components/RedisViewer.tsx` 与 `frontend/src/components/RedisCommandEditor.tsx` 提供了树形浏览、结构化值编辑和控制台交互样板
|
||||
- `frontend/src/components/AIChatPanel.tsx` 与 `frontend/src/components/ai/AIMessageBubble.tsx` 已具备 AI 交互和危险执行确认能力
|
||||
|
||||
### 5.2 结论
|
||||
|
||||
JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector` 子系统,而不是复用 SQL `Database` 接口。
|
||||
|
||||
## 6. 方案比较
|
||||
|
||||
### 方案 A:单一路径通用 Agent
|
||||
|
||||
- 描述:统一要求目标 JVM 通过 `-javaagent` 或运行时 attach 暴露运行时对象访问能力
|
||||
- 优点:
|
||||
- 理论能力上限最高
|
||||
- 可覆盖更多自研缓存和深层对象
|
||||
- 缺点:
|
||||
- 与已知企业约束直接冲突
|
||||
- 风险最高,部署与安全成本高
|
||||
- 与首期产品化目标不匹配
|
||||
|
||||
### 方案 B:多接入模式 + 能力协商
|
||||
|
||||
- 描述:统一做 `JVM Connector`,底层同时支持 `JMX`、`Management Endpoint`、`Agent`
|
||||
- 优点:
|
||||
- 产品形态统一
|
||||
- 能根据目标 JVM 能力降级
|
||||
- 可先做低风险路径,后续再扩展高级模式
|
||||
- 缺点:
|
||||
- 不同模式能力不一致,UI 与权限模型更复杂
|
||||
|
||||
### 方案 C:只做业务侧管理端点
|
||||
|
||||
- 描述:完全放弃通用接入,只提供官方 Starter/管理端点接入
|
||||
- 优点:
|
||||
- 结构最稳,AI 最容易接入
|
||||
- 权限、审计、预览、回滚最好做
|
||||
- 缺点:
|
||||
- 不满足“尽量通用”的产品定位
|
||||
- 无法覆盖仅开放 JMX 的存量系统
|
||||
|
||||
## 7. 选型
|
||||
|
||||
采用方案 B。当前已落地:
|
||||
|
||||
- `JMX Provider`
|
||||
- `Management Endpoint Provider`
|
||||
- `Agent Provider`(高级可选模式,要求目标 Java 服务显式预埋 GoNavi Java Agent)
|
||||
|
||||
## 8. 目标架构
|
||||
|
||||
### 8.1 总体结构
|
||||
|
||||
新增统一的 `JVM Connector` 子系统,分为五层:
|
||||
|
||||
- `Connection Layer`
|
||||
- 新增 `jvm` 连接类型
|
||||
- 保存目标地址、认证、允许模式、首选模式、环境标签等配置
|
||||
- `Capability Layer`
|
||||
- 建立连接后探测当前支持的 provider 与能力矩阵
|
||||
- `Provider Layer`
|
||||
- `JMX Provider`
|
||||
- `Management Endpoint Provider`
|
||||
- `Agent Provider`(预留)
|
||||
- `Resource Layer`
|
||||
- 将不同来源统一映射为结构化资源
|
||||
- `Guard Layer`
|
||||
- 统一负责预览、确认、审计、回读验证、错误归一化
|
||||
|
||||
### 8.2 设计原则
|
||||
|
||||
- UI 统一,协议多态
|
||||
- 读写分离,修改必须经过 Guard Layer
|
||||
- provider 不得自行绕过权限与审计链路
|
||||
- 能力不足时显式降级,不提供“看似可用、实际不可执行”的假入口
|
||||
|
||||
## 9. Provider 设计
|
||||
|
||||
### 9.1 JMX Provider
|
||||
|
||||
- 负责:
|
||||
- 建立 JMX/RMI 连接
|
||||
- 发现 MBean
|
||||
- 读取属性
|
||||
- 调用白名单操作
|
||||
- 写入允许修改的白名单属性
|
||||
- 适用场景:
|
||||
- 目标 JVM 已开放 JMX
|
||||
- 缓存或管理对象已暴露为 MBean
|
||||
- 特点:
|
||||
- 低侵入、标准化、可落地
|
||||
- key/value 级资源能力通常有限
|
||||
|
||||
### 9.2 Management Endpoint Provider
|
||||
|
||||
- 负责:
|
||||
- 调用业务服务暴露的 GoNavi 管理端点或 Starter
|
||||
- 返回结构化缓存资源、元数据和受控动作
|
||||
- 提供修改预览与回滚信息
|
||||
- 适用场景:
|
||||
- 业务方愿意接入轻量 Starter/管理端点
|
||||
- 需要更强的 key/value 级浏览与修改能力
|
||||
- 特点:
|
||||
- 最适合产品化和 AI 协同
|
||||
- 权限、脱敏、审计、回滚最容易做
|
||||
|
||||
### 9.3 Agent Provider
|
||||
|
||||
- 负责:
|
||||
- 在特定环境下通过 GoNavi Java Agent 暴露受控管理端口
|
||||
- 提供比 JMX 更贴近缓存资源模型的结构化浏览、预览与写入能力
|
||||
- 定位:
|
||||
- 高级模式
|
||||
- 不默认启用
|
||||
- 需要目标 Java 服务以 `-javaagent` 方式显式启动
|
||||
|
||||
## 10. 统一资源模型
|
||||
|
||||
建议统一抽象以下资源:
|
||||
|
||||
- `runtime`
|
||||
- 目标 JVM 实例
|
||||
- `cacheNamespace`
|
||||
- 缓存空间,如某个 CacheManager 下的 cacheName
|
||||
- `cacheEntry`
|
||||
- 具体缓存项 key/value
|
||||
- `managedBean`
|
||||
- 可读写的托管对象或 MBean
|
||||
- `operation`
|
||||
- 受控操作,如 `evict`、`put`、`refresh`、`clear`
|
||||
- `auditRecord`
|
||||
- 每次读写与 AI 建议的审计记录
|
||||
|
||||
统一资源模型要求:
|
||||
|
||||
- 每个资源都有稳定 ID、显示名、provider 来源、能力标签、敏感级别
|
||||
- 值快照必须区分原始值、展示值和可编辑值
|
||||
- 资源定位信息必须可写入审计
|
||||
|
||||
## 11. AI 协同设计
|
||||
|
||||
### 11.1 AI 的角色
|
||||
|
||||
AI 在 JVM 场景中只能作为“受控编排者”,不能作为直接执行者。
|
||||
|
||||
AI 可以:
|
||||
|
||||
- 解释缓存/Bean 的结构和当前状态
|
||||
- 生成筛选条件和定位建议
|
||||
- 生成结构化修改计划
|
||||
- 生成风险说明和回滚建议
|
||||
- 对执行前后结果做对比分析
|
||||
|
||||
AI 不应默认做:
|
||||
|
||||
- 直接执行 JVM 修改
|
||||
- 自由生成任意脚本并直写内存
|
||||
- 绕过人工确认直接调用 provider
|
||||
|
||||
### 11.2 AI 输出形态
|
||||
|
||||
AI 不直接输出脚本,而输出结构化变更计划,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"targetType": "cacheEntry",
|
||||
"selector": {
|
||||
"namespace": "userSessionCache",
|
||||
"key": "user:1001"
|
||||
},
|
||||
"action": "updateValue",
|
||||
"payload": {
|
||||
"format": "json",
|
||||
"value": {
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
},
|
||||
"reason": "修复错误缓存态"
|
||||
}
|
||||
```
|
||||
|
||||
### 11.3 AI 执行链路
|
||||
|
||||
1. AI 读取结构化上下文
|
||||
2. AI 产出结构化变更计划
|
||||
3. Guard Layer 校验目标资源、能力和权限
|
||||
4. UI 展示修改预览与风险提示
|
||||
5. 用户确认
|
||||
6. provider 执行
|
||||
7. 系统回读验证并写审计
|
||||
|
||||
### 11.4 一期 AI 边界
|
||||
|
||||
- 支持 AI 分析资源
|
||||
- 支持 AI 生成修改计划
|
||||
- 不默认支持 AI 自动执行修改
|
||||
|
||||
## 12. 页面与交互设计
|
||||
|
||||
### 12.1 连接层
|
||||
|
||||
在 `ConnectionModal` 中新增 `JVM` 类型,建议配置:
|
||||
|
||||
- 连接名称
|
||||
- 目标地址/端口
|
||||
- 认证信息
|
||||
- 允许模式列表
|
||||
- 首选模式
|
||||
- 环境标签(DEV/UAT/PROD)
|
||||
- 默认权限级别(只读/读写)
|
||||
|
||||
### 12.2 侧边栏
|
||||
|
||||
展示结构:
|
||||
|
||||
- 连接
|
||||
- 模式能力
|
||||
- 资源类型
|
||||
- `cacheNamespace` / `managedBean` / `operation`
|
||||
|
||||
每个连接或节点显示能力徽标,例如:
|
||||
|
||||
- `JMX`
|
||||
- `Endpoint`
|
||||
- `Agent`
|
||||
- `只读`
|
||||
- `可写`
|
||||
|
||||
### 12.3 主工作区 Tab
|
||||
|
||||
建议新增以下 Tab 类型:
|
||||
|
||||
- `概览`
|
||||
- `资源浏览`
|
||||
- `值检查器`
|
||||
- `修改预览`
|
||||
- `AI 助手`
|
||||
- `审计记录`
|
||||
|
||||
### 12.4 标准操作流
|
||||
|
||||
1. 用户连接 JVM
|
||||
2. 系统探测 provider 能力
|
||||
3. 用户选择资源并读取快照
|
||||
4. 用户手工修改或让 AI 生成计划
|
||||
5. 系统生成 before/after 预览
|
||||
6. 用户二次确认
|
||||
7. provider 执行
|
||||
8. 系统回读验证
|
||||
9. 写入审计与操作日志
|
||||
|
||||
## 13. 权限与审计
|
||||
|
||||
### 13.1 权限模型
|
||||
|
||||
权限建议分四层:
|
||||
|
||||
- `连接级`
|
||||
- 决定默认 `readonly` / `readwrite`
|
||||
- `模式级`
|
||||
- 决定某 provider 支持哪些动作
|
||||
- `资源级`
|
||||
- 某些资源永远只读
|
||||
- `环境级`
|
||||
- `PROD` 默认强制二次确认,禁用 AI 自动执行
|
||||
|
||||
### 13.2 审计要求
|
||||
|
||||
JVM 审计日志不应复用 SQL 日志数据结构,但可以复用现有 LogPanel 样式。
|
||||
|
||||
建议记录:
|
||||
|
||||
- 连接 ID / 名称
|
||||
- provider 类型
|
||||
- 资源定位信息
|
||||
- 动作类型
|
||||
- 修改原因
|
||||
- AI 是否参与
|
||||
- 执行前摘要
|
||||
- 执行后摘要
|
||||
- 结果状态
|
||||
- 耗时
|
||||
- 错误信息
|
||||
|
||||
建议本地独立落盘为 `jvm_audit.jsonl` 或等价结构,不混入 `sqlLogs`。
|
||||
|
||||
## 14. 错误处理与兼容性边界
|
||||
|
||||
### 14.1 错误分层
|
||||
|
||||
- `连接层失败`
|
||||
- 认证失败、证书失败、JMX/RMI 不通、端点 401/403
|
||||
- `能力层失败`
|
||||
- 连接成功但不支持列 key、写值或批量操作
|
||||
- `执行层失败`
|
||||
- 资源不存在、值格式非法、provider 拒绝写入
|
||||
- `验证层失败`
|
||||
- 执行返回成功但回读校验不一致
|
||||
|
||||
所有错误都应显式标明是哪个 provider、哪一层失败,避免泛化为“修改失败”。
|
||||
|
||||
### 14.2 首期兼容性承诺
|
||||
|
||||
优先承诺以下边界:
|
||||
|
||||
- Java 8 / 11 / 17 / 21
|
||||
- Spring Boot 服务优先
|
||||
- JMX 标准 MBean
|
||||
- Management Endpoint 模式下优先支持:
|
||||
- Caffeine
|
||||
- Ehcache
|
||||
- Guava Cache
|
||||
- Spring Cache 抽象下可枚举缓存
|
||||
- 接入 GoNavi Starter 的自研缓存
|
||||
- 值类型首期优先:
|
||||
- string
|
||||
- number
|
||||
- boolean
|
||||
- JSON object / JSON array
|
||||
- map / list 的结构化展示
|
||||
|
||||
### 14.3 首期不承诺
|
||||
|
||||
- 任意 Java 对象深度反射编辑
|
||||
- 无类型信息的二进制对象直接改写
|
||||
- 跨 classloader 任意对象定位
|
||||
- 生产环境默认开放批量危险写入
|
||||
|
||||
## 15. MVP 分期
|
||||
|
||||
### Phase 1:连接与只读探测
|
||||
|
||||
- JVM 连接类型
|
||||
- JMX / Endpoint 能力探测
|
||||
- 资源树浏览
|
||||
- 值查看
|
||||
- 概览页与能力徽标
|
||||
- 不开放写入
|
||||
|
||||
### Phase 2:受控修改与审计
|
||||
|
||||
- 白名单资源写入
|
||||
- before/after 预览
|
||||
- 二次确认
|
||||
- 审计日志
|
||||
- 回读验证
|
||||
- 环境级保护策略
|
||||
|
||||
### Phase 3:AI 协同
|
||||
|
||||
- AI 解释资源
|
||||
- AI 生成修改计划
|
||||
- AI 风险分析
|
||||
- AI 回滚建议
|
||||
- 仍默认不允许 AI 自动执行
|
||||
|
||||
### Phase 4:高级模式
|
||||
|
||||
- Agent Provider
|
||||
- 预埋 Java Agent 的 runtime 资源治理能力
|
||||
- 仅在特殊环境启用
|
||||
|
||||
## 16. 验证策略
|
||||
|
||||
### 16.1 功能验证
|
||||
|
||||
- 能连接 JMX 目标
|
||||
- 能连接 Endpoint 目标
|
||||
- 能列出缓存空间
|
||||
- 能查看 key/value
|
||||
- 能完成受控修改并回读成功
|
||||
|
||||
### 16.2 兼容性验证
|
||||
|
||||
- Java 8 / 11 / 17 / 21
|
||||
- 本地、容器、K8s 内网场景
|
||||
- 开启认证 / 不开启认证
|
||||
- 仅 JMX、仅 Endpoint、双模式并存
|
||||
|
||||
### 16.3 安全验证
|
||||
|
||||
- 只读连接无法写入
|
||||
- `PROD` 环境必须二次确认
|
||||
- AI 无法绕过人工确认直接执行
|
||||
- 审计日志完整记录修改链路
|
||||
|
||||
### 16.4 稳定性验证
|
||||
|
||||
- 目标 JVM 不可达时 UI 不假死
|
||||
- 资源树大数量时支持分页或懒加载
|
||||
- 回读失败时标识“不确定状态”
|
||||
- provider 超时、部分失败、降级路径清晰
|
||||
|
||||
## 17. 风险与缓解
|
||||
|
||||
### 17.1 风险
|
||||
|
||||
- 多 provider 模式会带来能力不一致,用户可能误解“所有 JVM 都能随便改”
|
||||
- JMX 模式的 key/value 级能力可能明显不足
|
||||
- 管理端点模式需要业务接入,推广成本高于纯客户端方案
|
||||
- 若未来引入 Agent 模式,可能引入新的安全审核和兼容性成本
|
||||
|
||||
### 17.2 缓解
|
||||
|
||||
- 在 UI 中显式展示能力矩阵和当前 provider 来源
|
||||
- 所有修改都强制经过预览、确认与审计
|
||||
- 首期将“通用”定义为“统一入口 + 多模式协商”,而不是“单通道万能能力”
|
||||
- Agent 仅作为高级扩展位,避免污染 MVP 边界
|
||||
|
||||
## 18. 最终结论
|
||||
|
||||
JVM 缓存可视化编辑能力在 GoNavi 中具备落地基础,但必须采用“统一入口、多 provider、能力协商、强 Guard Layer”的产品化方案。
|
||||
|
||||
推荐结论如下:
|
||||
|
||||
- 新增独立的 `JVM Connector` 子系统
|
||||
- 首期支持 `JMX + Management Endpoint`
|
||||
- `Agent` 作为高级可选模式交付
|
||||
- AI 首期支持分析与生成修改计划,不默认开放自动执行
|
||||
- 所有修改必须经过预览、确认、审计和回读验证
|
||||
|
||||
这一路径能够在兼顾企业安全约束的前提下,为用户提供可持续演进的 JVM 运行时缓存治理能力。
|
||||
246
docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md
Normal file
246
docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md
Normal 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 1:JVM 共享契约与配置归一化
|
||||
- 已完成 Task 2:Provider 注册、连接测试与能力探测 API
|
||||
- 已完成 Task 3:JVM 连接表单、图标与展示文案接入
|
||||
- 已完成 Task 4:只读资源浏览与 JVM Tab
|
||||
- 已完成 Task 5:写入预览、Guard 和审计记录
|
||||
- 已完成 Task 6:AI 结构化变更计划
|
||||
- 已完成 Task 7:全量回归、文档回填与交付检查
|
||||
- 已完成 JVM AI 计划解析、资源定位解析、AI 计划到当前 JVM 变更草稿的显式映射,避免把 `payload.format/value` 包装层直接透传到现有 JVM 写入契约
|
||||
- 已完成 AI 聊天面板 JVM 上下文注入、AI 气泡“应用到 JVM 预览”入口以及 JVM 资源页草稿回填闭环
|
||||
- 已完成 JVM AI 计划来源上下文绑定:消息现在绑定生成时的 `tabId + connectionId + providerMode + resourcePath`,避免切换 JVM 页签后误投递到当前激活页
|
||||
- 已完成 Endpoint provider 真实 HTTP contract 与补测,支持资源浏览、读值、预览和执行
|
||||
- 已完成可手工启动的 Java Endpoint fixture 与真实集成补测,可直接验证 Endpoint 模式端到端行为
|
||||
- 已完成 JMX provider 真实 helper 接入与补测,支持 `domain -> mbean -> attribute/operation` 浏览、attribute `set`、operation `invoke`
|
||||
- 已完成 JMX helper 预编译 runtime jar 内嵌分发,运行时不再依赖仓库源码目录,也不再要求本地 `javac`
|
||||
- 已完成 JVM 快照动作提示与 payload 模板回填,前端可直接根据 `supportedActions` 生成草稿
|
||||
- 已完成 AI 参与来源写入 JVM 审计记录,审计页可区分“手工”与“AI 辅助”
|
||||
- 已完成 Agent provider、Agent 连接表单与概览展示,支持通过独立 Agent Base URL 接入 GoNavi Java Agent
|
||||
- 已完成真实 Java Agent fixture 与集成验证,可通过 `-javaagent` 方式真实验证 Agent 模式资源浏览、预览与执行
|
||||
- 已完成 JVM 收口优化:Endpoint 能力探测遵循只读配置,概览页能力矩阵补齐模式能力探测与多行错误展示,能力探测失败与风险/结果状态文案统一收口为中文业务语义
|
||||
- 待处理:
|
||||
- 无阻塞性交付项;后续仅保留复杂对象参数、`CompositeData` / `TabularData` 等高级类型写入扩展作为增强项
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:
|
||||
- 直接修改 JVM 内对象属于高风险运行时操作,误改可能造成业务状态污染
|
||||
- 不同缓存框架(Caffeine/Ehcache/Guava/自研 Map)缺少统一标准协议
|
||||
- 若依赖 attach agent 或表达式执行,需严格控制安全边界与可观测性
|
||||
- 若目标 JVM 不允许预埋或动态注入 Agent,则“通用型”能力边界会明显收缩
|
||||
- 多接入模式会带来能力不一致问题,UI 与权限模型必须显式展示“当前模式支持什么/不支持什么”
|
||||
- 当前 AI 能力边界仍是“分析 + 生成结构化计划 + 回填预览草稿”,不直接执行 JVM 写入,真实执行仍取决于 Guard、人工确认和 provider 能力
|
||||
- 当前 AI 计划若只提供 `namespace + key`,仍更适合 endpoint/cache 风格资源;JMX 复杂 target 仍建议优先使用 `resourcePath`
|
||||
- JMX helper 已改为内嵌 jar 分发,但操作者机器仍需本地存在可用 `java`
|
||||
- Agent 模式要求目标 Java 服务显式以 `-javaagent` 方式启动 GoNavi Java Agent,并额外暴露管理端口
|
||||
- JMX operation preview 仅做参数/签名校验和预览快照,不预测真实副作用
|
||||
- JMX 参数转换当前覆盖基础类型、`ObjectName` 和部分数组;复杂对象写入仍是后续扩展项
|
||||
- 历史旧 AI 消息不包含 JVM 来源上下文,若需要应用到预览,需在目标 JVM 资源页重新生成计划
|
||||
- 阻塞:
|
||||
- 当前开发收口阶段无新增阻塞
|
||||
- 缓解措施:
|
||||
- 优先收敛到标准接入面(JMX / Spring Actuator / Java Agent 三选一)
|
||||
- 首期只支持白名单对象类型与受控写操作
|
||||
- 要求变更审计、预览、确认与失败回滚路径
|
||||
- 在交付说明中明确“AI 只生成草稿,不直接执行 JVM 写入”
|
||||
- JMX helper 改为内嵌 runtime jar,默认写入用户缓存目录;必要时允许通过 `GONAVI_JMX_HELPER_CLASSPATH` 覆盖 classpath
|
||||
- 对复杂参数调用保持白名单和人工确认,不开放脚本式自由执行
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:先做可行性评估与方案设计,不直接进入实现
|
||||
- 决策 2:默认优先复用 GoNavi 现有 driver-agent 与前端编辑器能力,避免侵入式重构主流程
|
||||
- 决策 3:已按完整模式推进,后续方案将优先评估通用 Agent 路径是否成立
|
||||
- 决策 4:由于目标服务大概率不允许 agent/attach,后续推荐方向转为“多接入模式 + 能力协商”
|
||||
- 决策 5:AI 在 JVM 场景中只负责分析与生成结构化计划,不直接执行运行时写入
|
||||
- 决策 6:AI 计划应用入口只回填 JVM 预览草稿,后续仍必须经过 `JVMPreviewChange`、Guard 校验和人工确认
|
||||
- 决策 7:当前 MVP 中 `updateValue` 会映射到现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象
|
||||
- 决策 8:JVM AI 计划必须绑定生成时的 JVM 上下文,只允许投递到匹配的 `tabId + connectionId + providerMode + resourcePath`
|
||||
- 决策 9:JMX helper 采用 Java 8 兼容的预编译 runtime jar 内嵌分发,运行时只依赖本地 `java`
|
||||
- 决策 10:Agent 模式按“预埋 GoNavi Java Agent + 独立 Agent Base URL 接入”落地,不在当前版本实现动态 attach
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:
|
||||
- GoNavi 驱动代理机制核查
|
||||
- GoNavi 现有 Redis/编辑器/UI 复用能力核查
|
||||
- JVM Connector 正式设计文档自检
|
||||
- JVM Connector 实施计划文档自检
|
||||
- Task 1:JVM 共享契约与配置归一化
|
||||
- Task 2:Provider 注册、连接测试与能力探测 API
|
||||
- Task 6:AI 计划解析、资源定位解析、契约映射与页签上下文隔离
|
||||
- Task 7:Java Endpoint fixture 真实集成验证
|
||||
- Task 7:JMX helper 内嵌分发与运行时缓存验证
|
||||
- Task 7:Agent provider 与真实 Java Agent 集成验证
|
||||
- Task 7:后端全量测试
|
||||
- Task 7:前端全量测试
|
||||
- Task 7:前端生产构建
|
||||
- Task 7:Wails 生产构建
|
||||
- 结果:
|
||||
- 已确认存在可复用的连接桥接与编辑器基础设施
|
||||
- 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突
|
||||
- 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节
|
||||
- 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测
|
||||
- Task 1 已完成规格审查与代码质量审查,结论均通过
|
||||
- 已完成 JVM Provider 工厂、JMX/Endpoint provider 骨架、App 层连接测试与能力探测 API
|
||||
- Task 2 已完成规格审查与代码质量审查,结论均通过
|
||||
- 已完成 JVM 连接类型卡片、最小表单字段、连接测试分发与展示文案接入
|
||||
- Task 3 已完成规格审查与代码质量审查;过程中修复了 JVM 标题文案偏差、模式选项暴露范围、编辑态模式静默降级和 endpoint timeout 失真问题
|
||||
- 已完成 JVM 只读资源浏览链路:后端新增 `JVMListResources` / `JVMGetValue`,前端新增 `jvm-overview` / `jvm-resource` tab 与侧边栏 JVM 模式/资源节点
|
||||
- Task 4 已完成规格复审;代码质量复审确认真实 provider 浏览能力仍为后续任务范围,另外已修正 JVM 资源 tab 同名问题
|
||||
- 已完成 Task 5:后端新增 `JVMPreviewChange` / `JVMApplyChange` / `JVMListAuditRecords`,补齐 Guard、审计 JSONL 落盘与审计读取能力
|
||||
- Task 5 已补齐只读拦截、`prod` 环境确认、provider preview 错误透出、审计写入失败显式回传、连接 `allowedModes` 约束和局部快照合并保底
|
||||
- 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤
|
||||
- 已完成 Task 6:AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测
|
||||
- 已完成 Task 6:AI 聊天消息与 JVM 来源页签绑定,AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递
|
||||
- 已完成 Task 7:Java Endpoint fixture,可真实验证 `resources / value / preview / apply` 四个 endpoint contract
|
||||
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 通过
|
||||
- 已完成 Task 7:JMX helper 改为预编译 jar 内嵌分发,并补齐 classpath 覆盖与缓存落盘单测
|
||||
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` 通过
|
||||
- 已完成 Task 7:Agent provider、Java agent fixture 与真实 `-javaagent` 集成测试
|
||||
- `go test ./internal/jvm -run 'TestAgentProvider' -count=1` 通过
|
||||
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` 通过(11 tests)
|
||||
- `go test ./... -count=1` 通过
|
||||
- `cd frontend && npm test -- --run` 通过(61 files,259 tests)
|
||||
- `cd frontend && npm run build` 通过;构建中存在既有 chunk size / dynamic import 警告,但未阻塞产物生成
|
||||
- `wails build -clean` 通过,成功生成 macOS 应用包
|
||||
- 已完成 JVM 收口优化:模式能力探测现在按当前 mode 做业务化错误翻译,避免概览页继续回显 `non-JRMP server`、`baseURL is required` 这类原始报错
|
||||
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 再次通过(Endpoint 能力探测只读语义回归)
|
||||
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` 再次通过(能力探测模式透传与中文错误翻译回归)
|
||||
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx` 通过(JVM 资源页布局回归)
|
||||
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts` 通过(风险等级、审计结果等本地化展示回归)
|
||||
- `cd frontend && npm run build` 再次通过
|
||||
- `wails build -clean` 再次通过,成功生成最新可验收桌面包
|
||||
- 证据(日志/截图/链接):
|
||||
- `cmd/optional-driver-agent/main.go`
|
||||
- `internal/db/database.go`
|
||||
- `frontend/src/components/RedisViewer.tsx`
|
||||
- `frontend/src/components/RedisCommandEditor.tsx`
|
||||
- `frontend/src/components/QueryEditor.tsx`
|
||||
- `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md`
|
||||
- `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md`
|
||||
- `internal/connection/types.go`
|
||||
- `internal/jvm/types.go`
|
||||
- `internal/jvm/config.go`
|
||||
- `internal/jvm/config_test.go`
|
||||
- `frontend/src/types.ts`
|
||||
- `frontend/src/utils/jvmConnectionConfig.ts`
|
||||
- `frontend/src/utils/jvmConnectionConfig.test.ts`
|
||||
- `go test ./internal/jvm -count=1`
|
||||
- `go test ./...`
|
||||
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
|
||||
- `cd frontend && npm test -- --run`
|
||||
- `cd frontend && npm run build`
|
||||
- `internal/jvm/provider.go`
|
||||
- `internal/jvm/jmx_provider.go`
|
||||
- `internal/jvm/http_provider.go`
|
||||
- `internal/jvm/http_provider_test.go`
|
||||
- `internal/jvm/jmx_helper.go`
|
||||
- `internal/jvm/jmx_helper_test.go`
|
||||
- `internal/jvm/provider_contract_test.go`
|
||||
- `internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar`
|
||||
- `internal/jvm/jmxhelper_assets/README.md`
|
||||
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/EndpointTestServer.java`
|
||||
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/MiniJson.java`
|
||||
- `tools/jmx-helper/src/com/gonavi/jmxhelper/JmxHelperMain.java`
|
||||
- `internal/app/methods_jvm.go`
|
||||
- `internal/app/methods_jvm_test.go`
|
||||
- `frontend/wailsjs/go/app/App.d.ts`
|
||||
- `frontend/wailsjs/go/app/App.js`
|
||||
- `frontend/wailsjs/go/models.ts`
|
||||
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1`
|
||||
- `go test ./internal/jvm ./internal/app -count=1`
|
||||
- `wails build -clean`
|
||||
- `frontend/src/components/DatabaseIcons.tsx`
|
||||
- `frontend/src/components/ConnectionModal.tsx`
|
||||
- `frontend/src/utils/jvmRuntimePresentation.ts`
|
||||
- `frontend/src/utils/jvmRuntimePresentation.test.ts`
|
||||
- `frontend/src/utils/jvmConnectionConfig.ts`
|
||||
- `frontend/src/utils/jvmConnectionConfig.test.ts`
|
||||
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
|
||||
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
|
||||
- `cd frontend && npm run build`
|
||||
- `internal/app/methods_jvm.go`
|
||||
- `internal/app/methods_jvm_test.go`
|
||||
- `frontend/src/components/Sidebar.tsx`
|
||||
- `frontend/src/components/TabManager.tsx`
|
||||
- `frontend/src/components/JVMOverview.tsx`
|
||||
- `frontend/src/components/JVMResourceBrowser.tsx`
|
||||
- `frontend/src/components/jvm/JVMModeBadge.tsx`
|
||||
- `frontend/src/store.ts`
|
||||
- `frontend/src/types.ts`
|
||||
- `go test ./internal/app -run 'TestJVM(ListResources|GetValue)' -count=1`
|
||||
- `go test ./internal/app -run 'TestJVMProbeCapabilities|TestTestJVMConnection' -count=1`
|
||||
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
|
||||
- `cd frontend && npm run build`
|
||||
- `internal/jvm/guard.go`
|
||||
- `internal/jvm/guard_test.go`
|
||||
- `internal/jvm/audit_store.go`
|
||||
- `internal/jvm/audit_store_test.go`
|
||||
- `internal/app/methods_jvm.go`
|
||||
- `internal/app/methods_jvm_test.go`
|
||||
- `frontend/src/components/JVMAuditViewer.tsx`
|
||||
- `frontend/src/components/jvm/JVMChangePreviewModal.tsx`
|
||||
- `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestPreviewChangeReturnsProviderPreviewErrorWhenWriteAllowed|TestPreviewChangeMarksProdWritesAsConfirmationRequired|TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults|TestJVMApplyChangeReturnsProviderPayload|TestJVMPreviewChangeRejectsModeOutsideAllowedModes|TestJVMListAuditRecordsReturnsLatestRecords|TestJVMApplyChangeSurfacesAuditWriteFailure' -count=1`
|
||||
- `go test ./internal/jvm ./internal/app -count=1`
|
||||
- `cd frontend && npm run build`
|
||||
- `frontend/src/utils/jvmAiPlan.ts`
|
||||
- `frontend/src/utils/jvmAiPlan.test.ts`
|
||||
- `frontend/src/components/AIChatPanel.tsx`
|
||||
- `frontend/src/components/ai/AIMessageBubble.tsx`
|
||||
- `frontend/src/components/JVMResourceBrowser.tsx`
|
||||
- `frontend/src/types.ts`
|
||||
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts`
|
||||
- `go test ./... -count=1`
|
||||
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1`
|
||||
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1`
|
||||
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx`
|
||||
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts`
|
||||
- `cd frontend && npm test -- --run`
|
||||
- `wails build -clean`
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:由用户按真实 JVM / endpoint 场景执行验收验证;若验收通过,再决定是否提交、推送或继续扩展高级类型写入
|
||||
- 负责人:Codex
|
||||
24
docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md
Normal file
24
docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# SQL 方言适配需求进度追踪
|
||||
|
||||
## 背景
|
||||
|
||||
- Oracle 等非 MySQL 数据源在表设计 DDL 预览中可能回落到 MySQL 语法,导致修改字段名、字段属性等操作执行失败。
|
||||
- GitHub 相关问题:Refs #402(金仓字段类型/DDL 方言)、Refs #409(Oracle 删除数据 DATE 字面量)。
|
||||
|
||||
## 范围
|
||||
|
||||
- 表设计 ALTER TABLE 预览:按 MySQL-family、PostgreSQL-family、Oracle/Dameng、SQL Server、SQLite、DuckDB、ClickHouse、TDengine 分支生成。
|
||||
- 新建表 DDL 预览:避免 Oracle/Dameng/SQL Server/SQLite/DuckDB/ClickHouse/TDengine 输出 MySQL 表选项。
|
||||
- SQL 自动补全:按当前连接方言解析关键字和函数,避免 Oracle/SQL Server 出现 MySQL-only 提示。
|
||||
- 表设计字段类型:按数据源给出候选类型,不再大量回退到 MySQL 通用类型。
|
||||
- Oracle/Dameng 数据复制/删除 SQL:DATE/TIMESTAMP 字段使用 Oracle 时间构造函数。
|
||||
|
||||
## 验证
|
||||
|
||||
- `npm test -- tableDesignerSchemaSql.test.ts sqlDialect.test.ts dataGridCopyInsert.test.ts`
|
||||
- `npm run build`
|
||||
|
||||
## 风险与后续
|
||||
|
||||
- ClickHouse/TDengine 的字段约束、默认值、备注语法差异较大,当前策略是生成有限原生 ALTER,并用中文注释阻止 MySQL 专属子句外溢。
|
||||
- SQL Server 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。
|
||||
71
docs/需求追踪/需求进度追踪-发布脚本测试版号与Mac打包无交互-20260424.md
Normal file
71
docs/需求追踪/需求进度追踪-发布脚本测试版号与Mac打包无交互-20260424.md
Normal 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
|
||||
@@ -1 +1 @@
|
||||
26a843d5fd071d0c7e9d8022e98eb4e3
|
||||
571d014306268cf67665967059cda912
|
||||
@@ -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
@@ -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 后面的条件');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
48
frontend/src/components/JVMAuditViewer.test.tsx
Normal file
48
frontend/src/components/JVMAuditViewer.test.tsx
Normal 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 条");
|
||||
});
|
||||
});
|
||||
271
frontend/src/components/JVMAuditViewer.tsx
Normal file
271
frontend/src/components/JVMAuditViewer.tsx
Normal 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;
|
||||
272
frontend/src/components/JVMDiagnosticConsole.test.tsx
Normal file
272
frontend/src/components/JVMDiagnosticConsole.test.tsx
Normal 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 ",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
1094
frontend/src/components/JVMDiagnosticConsole.tsx
Normal file
1094
frontend/src/components/JVMDiagnosticConsole.tsx
Normal file
File diff suppressed because it is too large
Load Diff
85
frontend/src/components/JVMMonitoringDashboard.test.tsx
Normal file
85
frontend/src/components/JVMMonitoringDashboard.test.tsx
Normal 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)");
|
||||
});
|
||||
});
|
||||
392
frontend/src/components/JVMMonitoringDashboard.tsx
Normal file
392
frontend/src/components/JVMMonitoringDashboard.tsx
Normal 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;
|
||||
65
frontend/src/components/JVMOverview.test.tsx
Normal file
65
frontend/src/components/JVMOverview.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
239
frontend/src/components/JVMOverview.tsx
Normal file
239
frontend/src/components/JVMOverview.tsx
Normal 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;
|
||||
118
frontend/src/components/JVMResourceBrowser.layout.test.tsx
Normal file
118
frontend/src/components/JVMResourceBrowser.layout.test.tsx
Normal 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)');
|
||||
});
|
||||
});
|
||||
946
frontend/src/components/JVMResourceBrowser.tsx
Normal file
946
frontend/src/components/JVMResourceBrowser.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
187
frontend/src/components/TableDesignerSqlPreview.test.tsx
Normal file
187
frontend/src/components/TableDesignerSqlPreview.test.tsx
Normal 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('"readOnly":true');
|
||||
expect(markup).toContain('"lineNumbers":"on"');
|
||||
expect(markup).not.toContain('"glyphMargin":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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
250
frontend/src/components/TableDesignerSqlPreview.tsx
Normal file
250
frontend/src/components/TableDesignerSqlPreview.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
41
frontend/src/components/dataGridRowClipboard.test.ts
Normal file
41
frontend/src/components/dataGridRowClipboard.test.ts
Normal 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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
66
frontend/src/components/dataGridRowClipboard.ts
Normal file
66
frontend/src/components/dataGridRowClipboard.ts
Normal 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),
|
||||
}));
|
||||
172
frontend/src/components/jvm/JVMChangePreviewModal.tsx
Normal file
172
frontend/src/components/jvm/JVMChangePreviewModal.tsx
Normal 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;
|
||||
67
frontend/src/components/jvm/JVMCommandPresetBar.tsx
Normal file
67
frontend/src/components/jvm/JVMCommandPresetBar.tsx
Normal 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;
|
||||
97
frontend/src/components/jvm/JVMDiagnosticHistory.tsx
Normal file
97
frontend/src/components/jvm/JVMDiagnosticHistory.tsx
Normal 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;
|
||||
65
frontend/src/components/jvm/JVMDiagnosticOutput.tsx
Normal file
65
frontend/src/components/jvm/JVMDiagnosticOutput.tsx
Normal 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;
|
||||
67
frontend/src/components/jvm/JVMModeBadge.tsx
Normal file
67
frontend/src/components/jvm/JVMModeBadge.tsx
Normal 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;
|
||||
119
frontend/src/components/jvm/JVMMonitoringCharts.test.tsx
Normal file
119
frontend/src/components/jvm/JVMMonitoringCharts.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
185
frontend/src/components/jvm/JVMMonitoringCharts.tsx
Normal file
185
frontend/src/components/jvm/JVMMonitoringCharts.tsx
Normal 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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
154
frontend/src/components/jvm/JVMMonitoringDetailPanel.tsx
Normal file
154
frontend/src/components/jvm/JVMMonitoringDetailPanel.tsx
Normal 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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
92
frontend/src/components/jvm/JVMMonitoringStatusCards.tsx
Normal file
92
frontend/src/components/jvm/JVMMonitoringStatusCards.tsx
Normal 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;
|
||||
128
frontend/src/components/jvm/JVMWorkspaceLayout.tsx
Normal file
128
frontend/src/components/jvm/JVMWorkspaceLayout.tsx
Normal 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>
|
||||
);
|
||||
@@ -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('`');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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('文件型数据库');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
46
frontend/src/utils/connectionVisual.test.ts
Normal file
46
frontend/src/utils/connectionVisual.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
40
frontend/src/utils/connectionVisual.ts
Normal file
40
frontend/src/utils/connectionVisual.ts
Normal 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));
|
||||
};
|
||||
113
frontend/src/utils/dataGridWhereFilter.test.ts
Normal file
113
frontend/src/utils/dataGridWhereFilter.test.ts
Normal 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` = ');
|
||||
});
|
||||
});
|
||||
242
frontend/src/utils/dataGridWhereFilter.ts
Normal file
242
frontend/src/utils/dataGridWhereFilter.ts
Normal 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;
|
||||
};
|
||||
174
frontend/src/utils/jvmAiPlan.test.ts
Normal file
174
frontend/src/utils/jvmAiPlan.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
321
frontend/src/utils/jvmAiPlan.ts
Normal file
321
frontend/src/utils/jvmAiPlan.ts
Normal 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');
|
||||
};
|
||||
183
frontend/src/utils/jvmConnectionConfig.test.ts
Normal file
183
frontend/src/utils/jvmConnectionConfig.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
265
frontend/src/utils/jvmConnectionConfig.ts
Normal file
265
frontend/src/utils/jvmConnectionConfig.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
53
frontend/src/utils/jvmDiagnosticCompletion.test.ts
Normal file
53
frontend/src/utils/jvmDiagnosticCompletion.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
499
frontend/src/utils/jvmDiagnosticCompletion.ts
Normal file
499
frontend/src/utils/jvmDiagnosticCompletion.ts
Normal 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));
|
||||
};
|
||||
119
frontend/src/utils/jvmDiagnosticPlan.test.ts
Normal file
119
frontend/src/utils/jvmDiagnosticPlan.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
135
frontend/src/utils/jvmDiagnosticPlan.ts
Normal file
135
frontend/src/utils/jvmDiagnosticPlan.ts
Normal 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 || "";
|
||||
};
|
||||
48
frontend/src/utils/jvmDiagnosticPresentation.test.ts
Normal file
48
frontend/src/utils/jvmDiagnosticPresentation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
180
frontend/src/utils/jvmDiagnosticPresentation.ts
Normal file
180
frontend/src/utils/jvmDiagnosticPresentation.ts
Normal 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}`;
|
||||
};
|
||||
41
frontend/src/utils/jvmMonitoringPresentation.test.ts
Normal file
41
frontend/src/utils/jvmMonitoringPresentation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
176
frontend/src/utils/jvmMonitoringPresentation.ts
Normal file
176
frontend/src/utils/jvmMonitoringPresentation.ts
Normal 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);
|
||||
77
frontend/src/utils/jvmResourcePresentation.test.ts
Normal file
77
frontend/src/utils/jvmResourcePresentation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
238
frontend/src/utils/jvmResourcePresentation.ts
Normal file
238
frontend/src/utils/jvmResourcePresentation.ts
Normal 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 };
|
||||
22
frontend/src/utils/jvmRuntimePresentation.test.ts
Normal file
22
frontend/src/utils/jvmRuntimePresentation.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
76
frontend/src/utils/jvmRuntimePresentation.ts
Normal file
76
frontend/src/utils/jvmRuntimePresentation.ts
Normal 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}`;
|
||||
};
|
||||
64
frontend/src/utils/jvmSidebarActions.test.ts
Normal file
64
frontend/src/utils/jvmSidebarActions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
77
frontend/src/utils/jvmSidebarActions.ts
Normal file
77
frontend/src/utils/jvmSidebarActions.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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 === '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
58
frontend/src/utils/sqlDialect.test.ts
Normal file
58
frontend/src/utils/sqlDialect.test.ts
Normal 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']));
|
||||
});
|
||||
});
|
||||
715
frontend/src/utils/sqlDialect.ts
Normal file
715
frontend/src/utils/sqlDialect.ts
Normal 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;
|
||||
};
|
||||
48
frontend/src/utils/tableOverviewFilter.test.ts
Normal file
48
frontend/src/utils/tableOverviewFilter.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
66
frontend/src/utils/tableOverviewFilter.ts
Normal file
66
frontend/src/utils/tableOverviewFilter.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
39
frontend/wailsjs/go/app/App.d.ts
vendored
39
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
2
go.mod
@@ -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
194
internal/app/methods_jvm.go
Normal 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()
|
||||
}
|
||||
294
internal/app/methods_jvm_diagnostic.go
Normal file
294
internal/app/methods_jvm_diagnostic.go
Normal 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, ";")
|
||||
}
|
||||
255
internal/app/methods_jvm_diagnostic_test.go
Normal file
255
internal/app/methods_jvm_diagnostic_test.go
Normal 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)
|
||||
}
|
||||
77
internal/app/methods_jvm_monitoring.go
Normal file
77
internal/app/methods_jvm_monitoring.go
Normal 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
|
||||
}
|
||||
147
internal/app/methods_jvm_monitoring_test.go
Normal file
147
internal/app/methods_jvm_monitoring_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
770
internal/app/methods_jvm_test.go
Normal file
770
internal/app/methods_jvm_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
|
||||
78
internal/app/methods_sync_test.go
Normal file
78
internal/app/methods_sync_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
67
internal/app/version_test.go
Normal file
67
internal/app/version_test.go
Normal 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
Reference in New Issue
Block a user