mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 12:19:47 +08:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9792278fa3 | ||
|
|
5f7578c5ea | ||
|
|
56eaca9081 | ||
|
|
51675f9d05 | ||
|
|
f5f87189df | ||
|
|
ef634075ab | ||
|
|
a07eea7815 | ||
|
|
5886b1ded8 | ||
|
|
299a80dd5a | ||
|
|
225e9e61ed | ||
|
|
fa4f2a938a | ||
|
|
ec2eefc9d2 | ||
|
|
58ee269855 | ||
|
|
ffc4f2c2d9 | ||
|
|
1b31c54917 | ||
|
|
bd608cac46 | ||
|
|
3665639300 | ||
|
|
3b9116e259 | ||
|
|
a06f45da28 | ||
|
|
21222cf9f4 | ||
|
|
30301cd637 | ||
|
|
55829bce86 | ||
|
|
2b340f3136 | ||
|
|
9eb06f6f96 | ||
|
|
01dd62f4e2 | ||
|
|
09ecc841ab | ||
|
|
3a0c5201a0 | ||
|
|
5f6acc25da | ||
|
|
5bbeb7f373 | ||
|
|
df4fcab90b | ||
|
|
f16e2f15c2 | ||
|
|
38e71119a4 | ||
|
|
ff2b86819d | ||
|
|
9d08b185d0 | ||
|
|
a43c84f968 | ||
|
|
14c6510835 | ||
|
|
6f14e827ab | ||
|
|
d9b4c6a21b | ||
|
|
d2c3e3e779 | ||
|
|
3cb2d494cc | ||
|
|
9a61622568 | ||
|
|
21f2b29d1d | ||
|
|
7ddb49a81d | ||
|
|
9bb7ece2dd | ||
|
|
177dafacc9 | ||
|
|
03a1506686 | ||
|
|
15b1ad24d1 | ||
|
|
f584270209 | ||
|
|
fe9d02734f | ||
|
|
b9ac1ab9b7 | ||
|
|
65a9f4352e | ||
|
|
f3b78f9763 | ||
|
|
0bccdeed8c | ||
|
|
39f6fbbe1f | ||
|
|
8a1a9a8fb8 | ||
|
|
dca5f629b2 | ||
|
|
8eae39c2c2 | ||
|
|
9613b2a8eb | ||
|
|
4fd679ce42 | ||
|
|
e56a72eb9f | ||
|
|
0fda09a19f | ||
|
|
33b78fb583 | ||
|
|
40416fb4df | ||
|
|
651eec1617 | ||
|
|
9dc58acb39 | ||
|
|
f3193f0933 | ||
|
|
7cb46f9f69 | ||
|
|
04c4613e4d | ||
|
|
8a10519f9b | ||
|
|
d57081ecfb | ||
|
|
035f536e8d | ||
|
|
22e4299d3e | ||
|
|
384aea132c | ||
|
|
890478eb7b | ||
|
|
8c79f2af0c | ||
|
|
a2cad9f7ce | ||
|
|
af90936fcc | ||
|
|
d3a1c017da | ||
|
|
a90423c04c | ||
|
|
6e23053ac6 | ||
|
|
9b50e9c9c8 | ||
|
|
4c76202d2c | ||
|
|
9c5b1a033a | ||
|
|
c631feef91 | ||
|
|
737896627a | ||
|
|
47235e1390 | ||
|
|
b6121fe1f8 | ||
|
|
f78b132c7c | ||
|
|
1adef17366 | ||
|
|
ada9bbf03e | ||
|
|
266f217bfd | ||
|
|
797db8cd36 | ||
|
|
54d46453df | ||
|
|
c7cf9526de | ||
|
|
d849cd49af | ||
|
|
604aaad69d | ||
|
|
605e266eab |
14
.github/workflows/dev-build.yml
vendored
14
.github/workflows/dev-build.yml
vendored
@@ -320,6 +320,9 @@ jobs:
|
||||
echo "ℹ️ macOS 产物不执行 UPX 压缩,保留原始主程序。"
|
||||
|
||||
echo "🔏 正在进行 Ad-hoc 签名..."
|
||||
if command -v xattr >/dev/null 2>&1; then
|
||||
xattr -cr "$APP_NAME" || true
|
||||
fi
|
||||
codesign --force --deep --sign - "$APP_NAME"
|
||||
|
||||
DMG_NAME="${{ matrix.build_name }}.dmg"
|
||||
@@ -336,6 +339,17 @@ jobs:
|
||||
--app-drop-link 600 185 \
|
||||
"$DMG_NAME" \
|
||||
"$APP_NAME"
|
||||
|
||||
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-dev-verify.XXXXXX")
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
|
||||
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
|
||||
if [ -z "$PACKAGED_APP" ]; then
|
||||
echo "❌ DMG 内未找到 .app 应用包!"
|
||||
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
|
||||
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||
|
||||
mv "$DMG_NAME" "../../$FINAL_NAME"
|
||||
|
||||
|
||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@@ -314,6 +314,9 @@ jobs:
|
||||
echo "🔏 正在进行 Ad-hoc 签名..."
|
||||
# 注意:Ad-hoc + hardened runtime(--options runtime)在未配置 entitlements 时,
|
||||
# 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。
|
||||
if command -v xattr >/dev/null 2>&1; then
|
||||
xattr -cr "$APP_NAME" || true
|
||||
fi
|
||||
codesign --force --deep --sign - "$APP_NAME"
|
||||
|
||||
DMG_NAME="${{ matrix.build_name }}.dmg"
|
||||
@@ -330,6 +333,17 @@ jobs:
|
||||
--app-drop-link 600 185 \
|
||||
"$DMG_NAME" \
|
||||
"$APP_NAME"
|
||||
|
||||
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-release-verify.XXXXXX")
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
|
||||
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
|
||||
if [ -z "$PACKAGED_APP" ]; then
|
||||
echo "❌ DMG 内未找到 .app 应用包!"
|
||||
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
|
||||
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||
|
||||
mv "$DMG_NAME" "../../$FINAL_NAME"
|
||||
|
||||
|
||||
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
|
||||
|
||||
328
build-release.sh
328
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"
|
||||
|
||||
@@ -84,225 +110,89 @@ try_compress_binary_with_upx() {
|
||||
fi
|
||||
}
|
||||
|
||||
MAC_VOLICON_PATH="build/darwin/icon.icns"
|
||||
if [ ! -f "$MAC_VOLICON_PATH" ]; then
|
||||
MAC_VOLICON_PATH=""
|
||||
fi
|
||||
clear_macos_bundle_xattrs() {
|
||||
local bundle_path="$1"
|
||||
if [ -z "$bundle_path" ] || [ ! -e "$bundle_path" ]; then
|
||||
return
|
||||
fi
|
||||
if command -v xattr >/dev/null 2>&1; then
|
||||
xattr -cr "$bundle_path" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
package_macos_bundle_zip() {
|
||||
local app_path="$1"
|
||||
local archive_path="$2"
|
||||
local archive_abs
|
||||
|
||||
if [ ! -d "$app_path" ]; then
|
||||
echo -e "${RED} ❌ 未找到 macOS 应用包:$app_path${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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 [ ! -f "$archive_abs" ]; then
|
||||
echo -e "${RED} ❌ macOS 应用归档失败:$archive_abs${NC}"
|
||||
exit 1
|
||||
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)..."
|
||||
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 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口(CI/本地静默打包更友好)。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
|
||||
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}"
|
||||
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)..."
|
||||
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 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口(CI/本地静默打包更友好)。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
|
||||
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}"
|
||||
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}"
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
# 2026-04-11 Issue Backlog Tracking
|
||||
|
||||
## Scope
|
||||
|
||||
- 分支:`codex/issue-242-data-root`
|
||||
- 策略:按 GitHub issue 创建时间从早到晚逐条处理
|
||||
- 提交要求:每条 issue 单独本地提交,提交信息使用 `Fixes #<issue>`
|
||||
|
||||
## Progress
|
||||
|
||||
| Issue | Title | Status | Commit |
|
||||
| --- | --- | --- | --- |
|
||||
| #242 | 希望有自定义数据存储位置功能 | Fixed | `1f617f9` |
|
||||
| #287 | 建议补充 Sql Server 数据库图标 | Fixed | `60b63d7` |
|
||||
| #305 | 金仓数据库设计表新增字段保存失败 | Fixed | `f696f52` |
|
||||
| #306 | 驱动下载 | Fixed | `8297829` |
|
||||
| #308 | clickhouse 获取数据库列表失败 | Fixed | `5d86ee7` |
|
||||
| #310 | 选择库后,右侧行显示各个表 | Fixed | `808c773` |
|
||||
| #311 | WIN 系统的执行 500 多条 insert 语句要几分钟 | Fixed | `83fe3d4` |
|
||||
| #315 | 窗体内缩放异常 | Fixed | `5038ae5` |
|
||||
| #316 | 人大金仓数据库驱动版本过低 | Fixed | `aa1bb5b` |
|
||||
| #317 | 驱动管理增加导入 jar 功能 | Blocked | - |
|
||||
| #318 | mysql,bit 列,修改成 1 失败 | Fixed | `89d79ff` |
|
||||
| #319 | 关于运行外部 sql 文件的一些建议 | Deferred | - |
|
||||
| #320 | 无法连接达梦数据库 | Fixed | `1c2377b` |
|
||||
| #322 | 【拖选复制】希望添加 查询结果表格可以拖选复制,效果就如操作excel表格的选择复制一样 | Fixed | Pending |
|
||||
| #325 | 有没有考虑对数据库的驱动版本进行选择或者自定义? | Fixed | `af5e842` |
|
||||
| #327 | SHOW DATABASES 报错 | Fixed | `fb500ee` |
|
||||
| #328 | [Bug] 安装更新失败 | Fixed | `426ef3b` |
|
||||
| #329 | 如果调整了左侧导航栏的宽度后,建议左侧导航栏内增加横向滚动查看 | Fixed | `fcade0f` |
|
||||
| #330 | 建议在查询结果表格中增加自适应内容列宽的功能 | Fixed | `632e57e` |
|
||||
| #331 | 重复连接 DB,一分钟重试了 60 多次 | Fixed | `ca76440` |
|
||||
| #351 | 为什么没有截断和清空表的功能呀? | Fixed | Pending |
|
||||
|
||||
## Notes
|
||||
|
||||
### #317
|
||||
|
||||
- 当前驱动管理只支持内置 Go 驱动和可选 Go 驱动代理包。
|
||||
- 仓库内不存在 JDBC/JAR 装载、Java 运行时探测、classpath 管理或桥接执行链路。
|
||||
- 在现有架构下直接增加 “导入 jar” 入口会形成假功能,因此暂记为架构阻塞,不做伪实现。
|
||||
|
||||
### #318
|
||||
|
||||
- 根因:MySQL 写入归一化只覆盖时间列,`bit` 列提交时会把前端传来的 `"1"`/`"0"` 原样透传给驱动。
|
||||
- 处理:为 MySQL `bit` 列补充写入值归一化,将常见文本/布尔/数值输入转换为驱动可接受的 `[]byte`。
|
||||
- 验证:补充 `internal/db/mysql_value_test.go` 回归测试,覆盖 `bit(1)` 的 insert/update 写入路径。
|
||||
|
||||
### #319
|
||||
|
||||
- 现有应用已支持“运行外部 SQL 文件”,但 issue 诉求包含目录树、目录加载、双击文件打开等整组工作区能力。
|
||||
- 该项已超出单点缺陷修复范围,暂按功能增强项顺延,避免在逐条修 bug 流程中引入大范围 UI/状态管理重构。
|
||||
|
||||
### #320
|
||||
|
||||
- 达梦当前走可选 Go 驱动代理安装链路,不支持 JAR 导入属于既有架构边界。
|
||||
- 根因:驱动 release 资产缓存把 `GoNavi-DriverAgents.zip` 里的 bundle 条目也混进了“顶层已发布 asset”集合,导致安装链路误以为存在单独的 `dameng-driver-agent-*.exe` 下载地址。
|
||||
- 处理:缓存层区分真实 release 顶层 asset 与 bundle index 条目,安装 URL 解析仅在真实顶层 asset 存在时才走直链;bundle-only 驱动改为直接进入总包提取回退,不再先卡在 20% 试无效 URL。
|
||||
- 验证:补充 `internal/app/methods_driver_version_test.go` 回归测试,覆盖 bundle-only 达梦驱动跳过伪直链,并回归 Mongo 历史版本与本地导入链路。
|
||||
|
||||
### #327
|
||||
|
||||
- 根因:低权限 MySQL 账号执行 `SHOW DATABASES` 会直接报错,当前实现没有回退路径。
|
||||
- 处理:为数据库列表查询增加 `SELECT DATABASE()` 回退,仅保留当前连接库时也能正常展示。
|
||||
- 验证:补充 `internal/db/mysql_metadata_test.go` 回归测试,覆盖有权限、多库和低权限回退场景。
|
||||
|
||||
### #328
|
||||
|
||||
- 根因:Windows 更新脚本在批处理执行、错误码读取和重启命令上不够稳,`cmd /C start`、LF 行尾和块内 `%ERRORLEVEL%` 在实际环境下容易引发安装失败。
|
||||
- 处理:更新脚本统一输出为 CRLF,块内错误码改为延迟展开,旧文件回退路径统一为 `TARGET_OLD`,并将脚本启动方式收敛为 `cmd.exe /D /C call <script>`。
|
||||
- 验证:补充 `internal/app/methods_update_windows_script_test.go`,覆盖批处理语法、Win10 回退路径、CRLF 行尾、延迟展开和启动命令构造。
|
||||
|
||||
### #325
|
||||
|
||||
- 根因:TDengine 的版本列表虽然支持下拉选择,但后端在抓取与缓存 Go 模块版本时只保留最近 5 个版本,导致 `3.5.x / 3.3.x / 3.0.x` 这类旧版根本不会进入选择列表。
|
||||
- 处理:放宽 TDengine 的历史版本窗口,并补充离线 fallback 版本矩阵;同时扩大模块版本缓存上限,确保旧版不会在抓取阶段就被截断。
|
||||
- 验证:补充 `internal/app/methods_driver_version_test.go` 回归测试,覆盖缓存命中与 fallback 两条路径,并回归 Mongo 版本约束逻辑。
|
||||
|
||||
### #329
|
||||
|
||||
- 根因:侧边栏连接树被全局 Tree 样式固定为 `width: 100%`,标题同时启用了省略截断,导致缩窄侧栏后长节点无法形成横向溢出。
|
||||
- 处理:为 Sidebar 树增加专用横向滚动容器,并在 Sidebar 作用域内覆写 Tree 宽度与标题截断规则,让节点宽度随内容扩展且保留最小占满。
|
||||
- 验证:执行 `frontend` 下 `npm run build`,确认 TS/CSS 改动编译通过且仅作用于 Sidebar 树。
|
||||
|
||||
### #331
|
||||
|
||||
- 根因:连接失败时存在双层重试叠加。`DBGetDatabases / DBGetTables / DBQuery` 在缓存失效后本来就会主动重建连接一次,而 `connectDatabaseWithStartupRetry` 在稳定期仍会额外放行一次瞬时错误自动重试,导致一次后台探测会被放大成多次真实建连。
|
||||
- 处理:将连接自动重试范围收敛到应用启动保护窗口内;稳定期下所有连接探测与重建都只执行一次,避免后台挂起场景持续放大失败流量。
|
||||
- 验证:补充并更新 `internal/app/app_startup_connect_retry_test.go`,覆盖稳定期瞬时失败不重试、不再输出重试提示,以及启动期仍保留完整重试预算。
|
||||
|
||||
### #330
|
||||
|
||||
- 根因:查询结果表格已经支持拖拽调整列宽,但 resize handle 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。
|
||||
- 处理:为 `DataGrid` 的列宽拖拽手柄增加双击入口,按当前表头与已加载结果集内容估算目标宽度,并直接复用现有 `columnWidths` 状态更新布局。
|
||||
- 验证:新增 `frontend/src/components/dataGridAutoWidth.test.ts` 覆盖列宽估算规则,并执行 `frontend` 下 `npm run build` 确认 TS 与打包通过。
|
||||
|
||||
### #322
|
||||
|
||||
- 根因:`DataGrid` 已经具备拖选单元格和选区状态维护能力,但当前复制能力只支持把同一行选中的列值暂存为内部 patch,用于“粘贴到选中行”,没有把矩形选区真正导出到系统剪贴板。
|
||||
- 处理:新增选区复制 helper,将矩形选区按当前可见行列顺序导出为制表符文本;同时补上工具栏“复制选区”按钮和 `Ctrl/Cmd+C` 快捷键,让拖选后的复制行为更接近 Excel。
|
||||
- 验证:新增 `frontend/src/components/dataGridSelectionCopy.test.ts` 覆盖选区排序与剪贴板文本规整规则,并执行 `frontend` 下 `npm run build` 确认功能接线通过。
|
||||
|
||||
### #351
|
||||
|
||||
- 根因:后端已有批量清空表能力,但前端单表危险操作菜单只暴露了“删除表”,没有把“截断表 / 清空表”作为显式入口提供给用户;同时批量“清空”动作底层语义也混用了 `TRUNCATE/DELETE`。
|
||||
- 处理:后端将“截断表”和“清空表”拆分为显式能力,统一通过 helper 生成多数据库 SQL;前端为 Sidebar 和 TableOverview 的表菜单补上两个危险操作入口,并仅在明确支持 `TRUNCATE TABLE` 的数据库类型上显示“截断表”。
|
||||
- 验证:新增 `internal/app/methods_file_clear_test.go` 与 `frontend/src/components/tableDataDangerActions.test.ts`,并执行 `go test ./...`、`frontend` 下 `npm run build` 确认全量通过。
|
||||
|
||||
## Next
|
||||
|
||||
- 继续处理下一个最早且可直接落地的开放 issue。
|
||||
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 运行时缓存治理能力。
|
||||
73
docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md
Normal file
73
docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 需求进度追踪 - AI聊天发送快捷键
|
||||
|
||||
## 1. 需求摘要
|
||||
- 需求名称:AI 聊天发送快捷键
|
||||
- 提出日期:2026-04-28
|
||||
- 负责人:Claude Code
|
||||
- 目标:将 AI 聊天发送快捷键纳入工具中心快捷键管理,支持录制自定义 Enter 相关组合键,降低输入法 Enter 上屏时误发送的风险。
|
||||
- 非目标:不调整后端 AI 服务配置,不改发送按钮行为,不把 AI 发送快捷键放在 AI 设置弹窗的独立入口。
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:工具中心快捷键管理、AI 聊天输入框、本地前端偏好持久化。
|
||||
- 验收标准:工具中心出现“AI 聊天发送”快捷键;默认 Enter 发送;可录制 Enter / Cmd+Enter / Ctrl+Enter / Alt+Enter 等 Enter 相关组合;普通字符键不可录制为 AI 发送;Shift+Enter 始终换行;输入法 composing 状态不发送;刷新后快捷键保持;AI 设置弹窗不再出现独立“聊天输入”快捷键入口。
|
||||
- 依赖与约束:沿用 Zustand `lite-db-storage` 中的 `shortcutOptions` 持久化;保持现有 AI 后端接口不变。
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):确认输入法 Enter 上屏导致误发送,需要支持录制自定义快捷键,并复用工具中心快捷键体系。
|
||||
- [x] 阶段 2(影响分析):影响工具中心快捷键配置、AIChatPanel、AIChatInput、store 和相关测试。
|
||||
- [x] 阶段 3(方案设计):采用共享 `shortcutOptions` action,AI 输入框局部消费,不走全局快捷键执行器。
|
||||
- [x] 阶段 4(实施计划):计划已按用户反馈调整为工具中心统一方案。
|
||||
- [x] 阶段 5(实现与自检):目标红灯测试已补充,新方案核心实现已完成。
|
||||
- [x] 阶段 6(评审与交付):已完成代码审查反馈修复、目标测试、全量测试、构建、diff 检查和浏览器手工验证。
|
||||
- [ ] 阶段 7(发布与观察):发布后观察用户输入法场景反馈。
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:新增工具中心 AI 发送 action 目标测试;实现 Enter 默认快捷键、Enter 组合录制规则、AI 输入框按 `shortcutOptions` 判定发送;移除 AI 设置独立入口;修复刷新后录制值被启动配置刷新覆盖的问题;限制 AI 发送快捷键只能录制 0 或 1 个修饰键的 Enter 组合;消费 AI 发送快捷键后阻止事件继续冒泡;更新 store、工具函数和输入框提示测试。
|
||||
- 进行中:无。
|
||||
- 待处理:发布后观察输入法场景反馈。
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:默认 Enter 发送在少数未标记 composing 的输入法中仍可能误发。
|
||||
- 阻塞:无。
|
||||
- 缓解措施:用户可在工具中心录制 Cmd+Enter / Ctrl+Enter / Alt+Enter,普通 Enter 不再触发发送;AI 发送录制限制为 Enter 相关组合并保留 Shift+Enter 换行;输入法 composing 状态始终不发送。
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:AI 发送快捷键作为工具中心快捷键 action 持久化,不写入后端 AI provider 配置。
|
||||
- 决策 2:`sendAIChatMessage` 仅由 AI 输入框处理,全局快捷键执行器跳过该局部 action。
|
||||
- 决策 3:AI 发送快捷键允许默认无修饰键 Enter,但录制时只接受 Enter 相关组合,拒绝普通字符键和含 Shift 的组合。
|
||||
- 决策 4:输入法 composing 状态始终不发送。
|
||||
- 决策 5:AI 发送快捷键仅允许 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter,拒绝 Ctrl+Alt+Enter 等多修饰键组合,避免扩大局部快捷键冲突面。
|
||||
- 决策 6:AI 输入框命中发送快捷键后同时执行 `preventDefault` 和 `stopPropagation`,避免事件继续冒泡到全局快捷键处理器。
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:初版两档下拉方案红灯测试。
|
||||
- 结果:已确认旧实现失败。
|
||||
- 证据:`aiChatSendShortcut.test.ts` 缺模块失败;`store.test.ts` 新增字段缺失失败;`AIChatInput.notice.test.tsx` placeholder 仍为 Enter 失败。
|
||||
- 验证项:工具中心统一方案红灯测试。
|
||||
- 结果:已确认旧实现失败。
|
||||
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts` 显示缺少 `sendAIChatMessage` action、`canRecordShortcutForAction` 和自定义 binding 判定失败;`src/store.test.ts` 显示 `shortcutOptions.sendAIChatMessage` 缺失;`src/components/ai/AIChatInput.notice.test.tsx` 显示 placeholder 未渲染 `Meta+Enter 发送`。
|
||||
- 验证项:工具中心统一方案目标绿灯测试。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts`(6 passed)、`src/components/ai/AIChatInput.notice.test.tsx`(2 passed)、`src/store.test.ts`(10 passed)。
|
||||
- 验证项:代码审查反馈红灯测试。
|
||||
- 结果:已确认旧实现失败。
|
||||
- 证据:多修饰键 Enter 组合被误放行、缺少 `consumeAIChatSendShortcutOnKeyDown`、脏持久化 `sendAIChatMessage: A` 未回退到 Enter。
|
||||
- 验证项:代码审查反馈修复后目标测试。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts src/components/ai/AIChatInput.notice.test.tsx src/store.test.ts`(3 files passed,22 tests passed)。
|
||||
- 验证项:浏览器手工验证。
|
||||
- 结果:已通过。
|
||||
- 证据:工具中心录制 `Meta+Enter` 后刷新仍保持;AI 输入框 placeholder 显示 `输入消息... (Meta+Enter 发送,Shift+Enter 换行,/ 快捷命令)`;普通 Enter 和 Shift+Enter 不触发发送;Meta+Enter 触发发送、调用 `preventDefault` 且事件不冒泡。
|
||||
- 验证项:前端全量测试。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend test -- --run`(88 files passed,421 tests passed)。
|
||||
- 验证项:diff 空白检查。
|
||||
- 结果:已通过。
|
||||
- 证据:`git diff --check` 无输出。
|
||||
- 验证项:生产构建。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend run build` 通过,仅有既有 dynamic import / chunk size 警告。
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:提交并推送本次改动,发布后观察用户输入法场景反馈。
|
||||
- 负责人:Claude Code
|
||||
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
|
||||
41
frontend/package-lock.json
generated
41
frontend/package-lock.json
generated
@@ -33,8 +33,10 @@
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@types/react-test-renderer": "^18.0.7",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^3.2.4"
|
||||
@@ -2037,6 +2039,16 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-test-renderer": {
|
||||
"version": "18.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.0.7.tgz",
|
||||
"integrity": "sha512-1+ANPOWc6rB3IkSnElhjv6VLlKg2dSv/OWClUyZimbLsQyBn8Js9Vtdsi3UICJ2rIQ3k2la06dkB+C92QfhKmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -5645,6 +5657,20 @@
|
||||
"react-dom": ">= 16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-shallow-renderer": {
|
||||
"version": "16.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz",
|
||||
"integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-syntax-highlighter": {
|
||||
"version": "16.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
|
||||
@@ -5665,6 +5691,21 @@
|
||||
"react": ">= 0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-test-renderer": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.2.0.tgz",
|
||||
"integrity": "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-is": "^18.2.0",
|
||||
"react-shallow-renderer": "^16.15.0",
|
||||
"scheduler": "^0.23.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||
|
||||
@@ -35,8 +35,10 @@
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@types/react-test-renderer": "^18.0.7",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^3.2.4"
|
||||
|
||||
@@ -1 +1 @@
|
||||
8cc5d6401a6ce7dd0f500c66ce8bb4a9
|
||||
0295a42fd931778d85157816d79d29e5
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
@@ -18,10 +18,11 @@ import SecurityUpdateProgressModal from './components/SecurityUpdateProgressModa
|
||||
import SecurityUpdateSettingsModal from './components/SecurityUpdateSettingsModal';
|
||||
import { DEFAULT_APPEARANCE, useStore } from './store';
|
||||
import { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from './types';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
|
||||
import { blurToFilter, isMacLikePlatform, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
|
||||
import { DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS, sanitizeDataTableColumnWidthMode } from './utils/dataGridDisplay';
|
||||
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
|
||||
import { shouldEnableMacWindowDiagnostics } from './utils/macWindowDiagnostics';
|
||||
import { resolveAboutDisplayVersion } from './utils/appVersionDisplay';
|
||||
import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme';
|
||||
import { getConnectionWorkbenchState } from './utils/startupReadiness';
|
||||
import { toSaveGlobalProxyInput } from './utils/globalProxyDraft';
|
||||
@@ -61,13 +62,15 @@ import {
|
||||
SHORTCUT_ACTION_META,
|
||||
SHORTCUT_ACTION_ORDER,
|
||||
ShortcutAction,
|
||||
canRecordShortcutForAction,
|
||||
eventToShortcut,
|
||||
getShortcutDisplay,
|
||||
hasModifierKey,
|
||||
isEditableElement,
|
||||
isShortcutMatch,
|
||||
normalizeShortcutCombo,
|
||||
} from './utils/shortcuts';
|
||||
import { resolveTitleBarToggleIconKey, shouldToggleMaximisedWindowForScaleFix } from './utils/windowStateUi';
|
||||
import { resolveVisibleStartupWindowBounds } from './utils/windowRestoreBounds';
|
||||
import {
|
||||
SIDEBAR_UTILITY_ITEM_KEYS,
|
||||
resolveAIEntryPlacement,
|
||||
@@ -167,6 +170,9 @@ function App() {
|
||||
const effectiveUiScale = Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Number(uiScale) || DEFAULT_UI_SCALE));
|
||||
const effectiveFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(Number(fontSize) || DEFAULT_FONT_SIZE)));
|
||||
const tokenFontSize = Math.round(effectiveFontSize * effectiveUiScale);
|
||||
const titleBarToggleIconKey = resolveTitleBarToggleIconKey(
|
||||
windowState === 'fullscreen' ? 'fullscreen' : (windowState === 'maximized' ? 'maximized' : 'normal')
|
||||
);
|
||||
const tokenFontSizeSM = Math.max(10, Math.round(tokenFontSize * 0.86));
|
||||
const tokenFontSizeLG = Math.max(tokenFontSize + 1, Math.round(tokenFontSize * 1.14));
|
||||
const tokenControlHeight = Math.max(24, Math.round(32 * effectiveUiScale));
|
||||
@@ -181,6 +187,7 @@ function App() {
|
||||
const effectiveBlur = normalizeBlurForPlatform(resolvedAppearance.blur);
|
||||
const blurFilter = blurToFilter(effectiveBlur);
|
||||
const [runtimePlatform, setRuntimePlatform] = useState('');
|
||||
const [runtimeBuildType, setRuntimeBuildType] = useState('');
|
||||
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
|
||||
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
|
||||
const [hasLoadedSecureConfig, setHasLoadedSecureConfig] = useState(false);
|
||||
@@ -219,13 +226,16 @@ function App() {
|
||||
|
||||
const windowCornerRadius = 14;
|
||||
useEffect(()=>{
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
return;
|
||||
}
|
||||
switch(windowState){
|
||||
case 'fullscreen':
|
||||
case 'maximized':
|
||||
document.body.setAttribute('--gonavi-border-radius', '0px');
|
||||
document.body.style.setProperty('--gonavi-border-radius', '0px');
|
||||
break;
|
||||
default:
|
||||
document.body.setAttribute('--gonavi-border-radius', `${windowCornerRadius}px`);
|
||||
document.body.style.setProperty('--gonavi-border-radius', `${windowCornerRadius}px`);
|
||||
break;
|
||||
}
|
||||
}, [windowState]);
|
||||
@@ -246,6 +256,7 @@ function App() {
|
||||
if (cancelled) return;
|
||||
const platform = String(env?.platform || '').toLowerCase();
|
||||
setRuntimePlatform(platform);
|
||||
setRuntimeBuildType(String(env?.buildType || '').toLowerCase());
|
||||
setIsLinuxRuntime(platform === 'linux');
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -512,8 +523,26 @@ function App() {
|
||||
const bounds = state.windowBounds;
|
||||
if (!bounds || bounds.width < 400 || bounds.height < 300) return;
|
||||
try {
|
||||
WindowSetSize(bounds.width, bounds.height);
|
||||
WindowSetPosition(bounds.x, bounds.y);
|
||||
const nextBounds = resolveVisibleStartupWindowBounds(bounds, {
|
||||
availWidth: window.screen?.availWidth || 0,
|
||||
availHeight: window.screen?.availHeight || 0,
|
||||
availLeft: (window.screen as Screen & { availLeft?: number })?.availLeft || 0,
|
||||
availTop: (window.screen as Screen & { availTop?: number })?.availTop || 0,
|
||||
});
|
||||
if (
|
||||
nextBounds.x !== bounds.x ||
|
||||
nextBounds.y !== bounds.y ||
|
||||
nextBounds.width !== bounds.width ||
|
||||
nextBounds.height !== bounds.height
|
||||
) {
|
||||
void emitWindowDiagnostic('adjust:startup-window-bounds', {
|
||||
from: bounds,
|
||||
to: nextBounds,
|
||||
});
|
||||
state.setWindowBounds(nextBounds);
|
||||
}
|
||||
WindowSetSize(nextBounds.width, nextBounds.height);
|
||||
WindowSetPosition(nextBounds.x, nextBounds.y);
|
||||
} catch (e) {
|
||||
console.warn('Failed to restore window bounds', e);
|
||||
}
|
||||
@@ -633,7 +662,7 @@ function App() {
|
||||
});
|
||||
|
||||
if (isMaximised) {
|
||||
if (reason !== 'ratio-change' && !hasViewportScaleDrift) {
|
||||
if (!shouldToggleMaximisedWindowForScaleFix(reason, hasViewportScaleDrift)) {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
lastFixAt = Date.now();
|
||||
return;
|
||||
@@ -806,7 +835,11 @@ function App() {
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: isSidebarCompact ? 13 : 14,
|
||||
}), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, isSidebarCompact, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]);
|
||||
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
|
||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||
const overlayTheme = useMemo(
|
||||
() => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }),
|
||||
[darkMode, disableLocalBackdropFilter],
|
||||
);
|
||||
|
||||
const sidebarQuickActionBaseStyle = useMemo(() => ({
|
||||
height: Math.max(34, Math.round(36 * effectiveUiScale)),
|
||||
@@ -836,8 +869,8 @@ function App() {
|
||||
...sidebarQuickActionBaseStyle,
|
||||
flex: '1 1 0',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, rgba(255,214,102,0.96) 0%, rgba(240,183,39,0.92) 100%)',
|
||||
color: '#2a1f00',
|
||||
background: 'linear-gradient(135deg, rgba(34,197,94,0.96) 0%, rgba(22,163,74,0.92) 100%)',
|
||||
color: '#f3fff7',
|
||||
}), [sidebarQuickActionBaseStyle]);
|
||||
|
||||
const utilityModalShellStyle = useMemo(() => ({
|
||||
@@ -1089,6 +1122,7 @@ function App() {
|
||||
const isAboutOpenRef = React.useRef(false);
|
||||
const [aboutLoading, setAboutLoading] = useState(false);
|
||||
const [aboutInfo, setAboutInfo] = useState<{ version: string; author: string; buildTime?: string; repoUrl?: string; issueUrl?: string; releaseUrl?: string; communityUrl?: string } | null>(null);
|
||||
const aboutDisplayVersion = resolveAboutDisplayVersion(runtimeBuildType, aboutInfo?.version);
|
||||
const [aboutUpdateStatus, setAboutUpdateStatus] = useState<string>('');
|
||||
const [lastUpdateInfo, setLastUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<{
|
||||
@@ -1145,7 +1179,11 @@ function App() {
|
||||
const isWindowsRuntime = runtimePlatform === 'windows'
|
||||
|| (runtimePlatform === '' && isWindowsPlatform());
|
||||
const useNativeMacWindowControls = isMacRuntime && appearance.useNativeMacWindowControls === true;
|
||||
const macWindowDiagnosticsEnabled = shouldEnableMacWindowDiagnostics(isMacRuntime, import.meta.env.DEV);
|
||||
const macWindowDiagnosticsEnabled = shouldEnableMacWindowDiagnostics(
|
||||
isMacRuntime,
|
||||
import.meta.env.DEV,
|
||||
import.meta.env.VITE_GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS,
|
||||
);
|
||||
|
||||
const emitWindowDiagnostic = useCallback(async (stage: string, extra: Record<string, unknown> = {}) => {
|
||||
if (!macWindowDiagnosticsEnabled) {
|
||||
@@ -1691,14 +1729,18 @@ function App() {
|
||||
const importKind = detectConnectionImportKind(raw);
|
||||
|
||||
if (importKind === 'invalid') {
|
||||
void message.error('文件格式错误:仅支持 GoNavi 恢复包或历史 JSON 连接数组');
|
||||
void message.error('文件格式错误:仅支持 GoNavi 恢复包、历史 JSON 连接数组或 MySQL Workbench XML');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPendingConnectionImportPayload(null);
|
||||
const importedViews = await importConnectionsPayload(raw, '');
|
||||
void message.success(`成功导入 ${importedViews.length} 个连接`);
|
||||
if (importKind === 'mysql-workbench-xml' && importedViews.some(v => !v.hasPrimaryPassword)) {
|
||||
void message.warning(`成功导入 ${importedViews.length} 个连接,部分连接未包含密码,请编辑对应连接并输入密码后保存`);
|
||||
} else {
|
||||
void message.success(`成功导入 ${importedViews.length} 个连接`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (isConnectionPackagePasswordRequiredError(e)) {
|
||||
setPendingConnectionImportPayload(raw);
|
||||
@@ -1813,6 +1855,7 @@ function App() {
|
||||
};
|
||||
|
||||
const [isToolsModalOpen, setIsToolsModalOpen] = useState(false);
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
||||
const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme');
|
||||
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
|
||||
@@ -1846,26 +1889,11 @@ function App() {
|
||||
icon: <ToolOutlined />,
|
||||
onClick: () => setIsToolsModalOpen(true),
|
||||
},
|
||||
proxy: {
|
||||
key: 'proxy',
|
||||
title: '代理',
|
||||
icon: <GlobalOutlined />,
|
||||
onClick: () => {
|
||||
setSecurityUpdateRepairSource(null);
|
||||
setIsProxyModalOpen(true);
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
key: 'theme',
|
||||
title: '主题',
|
||||
icon: <SkinOutlined />,
|
||||
onClick: () => setIsThemeModalOpen(true),
|
||||
},
|
||||
about: {
|
||||
key: 'about',
|
||||
title: '关于',
|
||||
icon: <InfoCircleOutlined />,
|
||||
onClick: () => setIsAboutOpen(true),
|
||||
settings: {
|
||||
key: 'settings',
|
||||
title: '设置',
|
||||
icon: <SettingOutlined />,
|
||||
onClick: () => setIsSettingsModalOpen(true),
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -2148,19 +2176,34 @@ function App() {
|
||||
}, [securityUpdateRepairSource]);
|
||||
|
||||
const handleTitleBarWindowToggle = async () => {
|
||||
const syncWindowStateFromRuntime = async () => {
|
||||
try {
|
||||
const [isFullscreen, isMaximised] = await Promise.all([
|
||||
WindowIsFullscreen().catch(() => false),
|
||||
WindowIsMaximised().catch(() => false),
|
||||
]);
|
||||
useStore.getState().setWindowState(isFullscreen ? 'fullscreen' : (isMaximised ? 'maximized' : 'normal'));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:before');
|
||||
if (await WindowIsFullscreen()) {
|
||||
await WindowUnfullscreen();
|
||||
await syncWindowStateFromRuntime();
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:after-unfullscreen');
|
||||
return;
|
||||
}
|
||||
if (useNativeMacWindowControls && isMacRuntime) {
|
||||
await WindowFullscreen();
|
||||
await syncWindowStateFromRuntime();
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:after-fullscreen');
|
||||
return;
|
||||
}
|
||||
await WindowToggleMaximise();
|
||||
await syncWindowStateFromRuntime();
|
||||
void emitWindowDiagnostic('action:titlebar-toggle:after-toggle-maximise');
|
||||
} catch (_) {
|
||||
// ignore
|
||||
@@ -2344,11 +2387,15 @@ function App() {
|
||||
useEffect(() => {
|
||||
const handleGlobalShortcut = (event: KeyboardEvent) => {
|
||||
const matchedAction = SHORTCUT_ACTION_ORDER.find((action) => {
|
||||
const meta = SHORTCUT_ACTION_META[action];
|
||||
if (meta.scope && meta.scope !== 'global') {
|
||||
return false;
|
||||
}
|
||||
const binding = shortcutOptions[action];
|
||||
if (!binding?.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (isEditableElement(event.target) && !SHORTCUT_ACTION_META[action].allowInEditable) {
|
||||
if (isEditableElement(event.target) && !meta.allowInEditable) {
|
||||
return false;
|
||||
}
|
||||
return isShortcutMatch(event, binding.combo);
|
||||
@@ -2412,12 +2459,15 @@ function App() {
|
||||
if (!combo) {
|
||||
return;
|
||||
}
|
||||
if (!hasModifierKey(combo)) {
|
||||
void message.warning('快捷键至少包含 Ctrl / Alt / Shift / Meta 之一');
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedCombo = normalizeShortcutCombo(combo);
|
||||
if (!canRecordShortcutForAction(capturingShortcutAction, normalizedCombo)) {
|
||||
const meta = SHORTCUT_ACTION_META[capturingShortcutAction];
|
||||
void message.warning(meta.scope === 'aiComposer'
|
||||
? 'AI 聊天发送快捷键仅支持 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter,Shift+Enter 保留换行'
|
||||
: '快捷键至少包含 Ctrl / Alt / Shift / Meta 之一');
|
||||
return;
|
||||
}
|
||||
const conflictAction = SHORTCUT_ACTION_ORDER.find((action) => {
|
||||
if (action === capturingShortcutAction) {
|
||||
return false;
|
||||
@@ -2564,7 +2614,7 @@ function App() {
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BorderOutlined />}
|
||||
icon={titleBarToggleIconKey === 'restore' ? <SwitcherOutlined /> : <BorderOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={() => { void handleTitleBarWindowToggle(); }}
|
||||
/>
|
||||
@@ -2591,7 +2641,7 @@ function App() {
|
||||
>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: `12px ${sidebarHorizontalPadding}px 8px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${sidebarUtilityItems.length}, minmax(0, 1fr))`, gap: 8, width: '100%' }}>
|
||||
{sidebarUtilityItems.map((item) => (
|
||||
<Tooltip key={item.key} title={item.title}>
|
||||
<Button type="text" icon={item.icon} style={utilityButtonStyle} onClick={item.onClick} />
|
||||
@@ -2601,12 +2651,12 @@ function App() {
|
||||
</div>
|
||||
<div style={{ padding: `0 ${sidebarHorizontalPadding}px 10px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isSidebarCompact ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) minmax(0, 1fr)', gap: 8, width: '100%' }}>
|
||||
<Button icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" style={sidebarQueryActionStyle}>
|
||||
新建查询
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={handleCreateConnection} title="新建连接" style={sidebarCreateConnectionActionStyle}>
|
||||
新建连接
|
||||
</Button>
|
||||
<Button icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" style={sidebarQueryActionStyle}>
|
||||
新建查询
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2853,6 +2903,71 @@ function App() {
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={renderUtilityModalTitle(<SettingOutlined />, '设置中心', '集中处理代理、主题、AI 与关于等通用配置入口。')}
|
||||
open={isSettingsModalOpen}
|
||||
onCancel={() => setIsSettingsModalOpen(false)}
|
||||
footer={null}
|
||||
width={560}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12, padding: '12px 0' }}>
|
||||
{[
|
||||
{
|
||||
key: 'theme',
|
||||
icon: <SkinOutlined />,
|
||||
title: '主题与外观',
|
||||
description: '切换亮暗主题并调整界面观感。',
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
setThemeModalSection('theme');
|
||||
setIsThemeModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'proxy',
|
||||
icon: <GlobalOutlined />,
|
||||
title: '全局代理',
|
||||
description: '统一配置更新检查、驱动管理和公共网络出口。',
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
setSecurityUpdateRepairSource(null);
|
||||
setIsProxyModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
icon: <RobotOutlined />,
|
||||
title: 'AI 设置',
|
||||
description: '管理模型供应商、密钥和默认行为。',
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
handleOpenAISettings();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
icon: <InfoCircleOutlined />,
|
||||
title: '关于 GoNavi',
|
||||
description: '查看版本信息、仓库地址和更新状态。',
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
setIsAboutOpen(true);
|
||||
},
|
||||
},
|
||||
].map((item) => (
|
||||
<Button key={item.key} type="text" style={utilityActionCardStyle} onClick={item.onClick}>
|
||||
<span style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span style={{ display: 'grid', gap: 4, textAlign: 'left', minWidth: 0 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: overlayTheme.titleText }}>{item.title}</span>
|
||||
<span style={{ fontSize: 12, color: overlayTheme.mutedText, whiteSpace: 'normal' }}>{item.description}</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={renderUtilityModalTitle(<HddOutlined />, '数据存储位置', '统一管理连接、代理、AI 配置与驱动等文件型数据的根目录。')}
|
||||
open={isDataRootModalOpen}
|
||||
@@ -3040,7 +3155,7 @@ function App() {
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontWeight: 600 }}>版本</div>
|
||||
<div style={utilityMutedTextStyle}>{aboutInfo?.version || '未知'}</div>
|
||||
<div style={utilityMutedTextStyle}>{aboutDisplayVersion}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontWeight: 600 }}>作者</div>
|
||||
@@ -3374,7 +3489,7 @@ function App() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
|
||||
点击“录制”后按下快捷键。按 Esc 可取消录制。建议至少包含一个修饰键(Ctrl/Alt/Shift/Meta)。
|
||||
点击“录制”后按下快捷键。按 Esc 可取消录制。全局快捷键建议包含修饰键;AI 聊天发送仅支持 Enter 相关组合,Shift+Enter 保留换行。
|
||||
</div>
|
||||
</div>
|
||||
{SHORTCUT_ACTION_ORDER.map((action) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -20,6 +25,9 @@ import {
|
||||
buildMissingProviderNotice,
|
||||
buildModelFetchFailedNotice,
|
||||
} from '../utils/aiComposerNotice';
|
||||
import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit';
|
||||
import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool';
|
||||
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
|
||||
|
||||
interface AIChatPanelProps {
|
||||
width?: number;
|
||||
@@ -231,6 +239,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);
|
||||
@@ -247,6 +257,51 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const aiPanelVisible = useStore(state => state.aiPanelVisible);
|
||||
const aiChatSendShortcutBinding = useStore(state => state.shortcutOptions.sendAIChatMessage);
|
||||
|
||||
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(() => {
|
||||
@@ -306,10 +361,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 +553,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 +574,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 +592,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 +613,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 +682,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 +786,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 +807,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 +824,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 +835,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 +1027,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 +1043,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;
|
||||
@@ -917,12 +1149,15 @@ SELECT * FROM users WHERE status = 1;
|
||||
try {
|
||||
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
|
||||
const safeTable = args.tableName ? String(args.tableName).trim() : '';
|
||||
const { DBShowCreateTable } = await import('../../wailsjs/go/app/App');
|
||||
const ddlRes = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
|
||||
if (ddlRes?.success) {
|
||||
resStr = typeof ddlRes.data === 'string' ? ddlRes.data : JSON.stringify(ddlRes.data);
|
||||
success = true;
|
||||
} else { resStr = ddlRes?.message || 'Failed to fetch DDL'; }
|
||||
const { DBShowCreateTable, DBGetColumns } = await import('../../wailsjs/go/app/App');
|
||||
const rpcConfig = buildRpcConnectionConfig(conn.config) as any;
|
||||
const toolResult = await resolveAITableSchemaToolResult({
|
||||
tableName: safeTable,
|
||||
fetchDDL: () => DBShowCreateTable(rpcConfig, safeDbName, safeTable),
|
||||
fetchColumns: () => DBGetColumns(rpcConfig, safeDbName, safeTable),
|
||||
});
|
||||
resStr = toolResult.content;
|
||||
success = toolResult.success;
|
||||
} catch (e: any) {
|
||||
resStr = `获取建表语句失败: ${e?.message || e}`;
|
||||
}
|
||||
@@ -945,14 +1180,8 @@ SELECT * FROM users WHERE status = 1;
|
||||
}
|
||||
}
|
||||
const { DBQuery } = await import('../../wailsjs/go/app/App');
|
||||
// 只对只读查询自动追加 LIMIT,写操作(UPDATE/DELETE/INSERT等)不追加
|
||||
const sqlTrimmed = safeSql.replace(/;\s*$/, ''); // 去掉末尾分号防止拼接出 "; LIMIT 50"
|
||||
const sqlFirstWord = sqlTrimmed.trimStart().split(/\s/)[0]?.toLowerCase() || '';
|
||||
const isReadQuery = ['select', 'show', 'describe', 'desc', 'explain', 'with'].includes(sqlFirstWord);
|
||||
const finalSql = (isReadQuery && !sqlTrimmed.toLowerCase().includes('limit'))
|
||||
? sqlTrimmed + ' LIMIT 50'
|
||||
: sqlTrimmed;
|
||||
const qRes = await DBQuery(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeSql + (safeSql.toLowerCase().includes('limit') ? '' : ' LIMIT 50'));
|
||||
const finalSql = buildAIReadonlyPreviewSQL(conn.config?.type || '', safeSql, 50, conn.config?.driver || '');
|
||||
const qRes = await DBQuery(buildRpcConnectionConfig(conn.config) as any, safeDbName, finalSql);
|
||||
if (qRes?.success) {
|
||||
const rows = Array.isArray(qRes.data) ? qRes.data : [];
|
||||
const limitedRows = rows.slice(0, 50);
|
||||
@@ -1001,6 +1230,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 +1245,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 +1274,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 +1315,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 +1344,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 +1366,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 +1423,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,23 +1434,46 @@ 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) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}, [handleSend]);
|
||||
consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, e, handleSend);
|
||||
}, [aiChatSendShortcutBinding, handleSend]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
try {
|
||||
@@ -1432,6 +1704,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
activeProvider={activeProvider}
|
||||
dynamicModels={dynamicModels}
|
||||
loadingModels={loadingModels}
|
||||
sendShortcutBinding={aiChatSendShortcutBinding}
|
||||
composerNotice={composerNotice}
|
||||
onModelChange={handleModelChange}
|
||||
onFetchModels={fetchDynamicModels}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from '../utils/aiSettingsPresetLayout';
|
||||
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
|
||||
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
|
||||
interface AISettingsModalProps {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
312
frontend/src/components/DataGrid.ddl.test.tsx
Normal file
312
frontend/src/components/DataGrid.ddl.test.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DataGrid from './DataGrid';
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'local',
|
||||
config: {
|
||||
type: 'mysql',
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
password: '',
|
||||
database: 'main',
|
||||
},
|
||||
},
|
||||
],
|
||||
addSqlLog: vi.fn(),
|
||||
theme: 'light',
|
||||
appearance: {
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
showDataTableVerticalBorders: false,
|
||||
dataTableColumnWidthMode: 'standard',
|
||||
},
|
||||
queryOptions: {
|
||||
showColumnComment: false,
|
||||
showColumnType: false,
|
||||
},
|
||||
setQueryOptions: vi.fn(),
|
||||
tableColumnOrders: {},
|
||||
enableColumnOrderMemory: false,
|
||||
setTableColumnOrder: vi.fn(),
|
||||
setEnableColumnOrderMemory: vi.fn(),
|
||||
clearTableColumnOrder: vi.fn(),
|
||||
tableHiddenColumns: {},
|
||||
enableHiddenColumnMemory: false,
|
||||
setTableHiddenColumns: vi.fn(),
|
||||
setEnableHiddenColumnMemory: vi.fn(),
|
||||
clearTableHiddenColumns: vi.fn(),
|
||||
aiPanelVisible: false,
|
||||
setAIPanelVisible: vi.fn(),
|
||||
}));
|
||||
|
||||
const backendApp = vi.hoisted(() => ({
|
||||
ImportData: vi.fn(),
|
||||
ExportTable: vi.fn(),
|
||||
ExportData: vi.fn(),
|
||||
ExportQuery: vi.fn(),
|
||||
ApplyChanges: vi.fn(),
|
||||
DBGetColumns: vi.fn(),
|
||||
DBGetIndexes: vi.fn(),
|
||||
DBShowCreateTable: vi.fn(),
|
||||
}));
|
||||
|
||||
const messageApi = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
loading: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: typeof storeState) => any) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => backendApp);
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
|
||||
}));
|
||||
|
||||
vi.mock('./ImportPreviewModal', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@ant-design/icons', () => {
|
||||
const Icon = () => <span />;
|
||||
return {
|
||||
ReloadOutlined: Icon,
|
||||
ImportOutlined: Icon,
|
||||
ExportOutlined: Icon,
|
||||
DownOutlined: Icon,
|
||||
PlusOutlined: Icon,
|
||||
DeleteOutlined: Icon,
|
||||
SaveOutlined: Icon,
|
||||
UndoOutlined: Icon,
|
||||
FilterOutlined: Icon,
|
||||
CloseOutlined: Icon,
|
||||
ConsoleSqlOutlined: Icon,
|
||||
FileTextOutlined: Icon,
|
||||
CopyOutlined: Icon,
|
||||
ClearOutlined: Icon,
|
||||
EditOutlined: Icon,
|
||||
VerticalAlignBottomOutlined: Icon,
|
||||
LeftOutlined: Icon,
|
||||
RightOutlined: Icon,
|
||||
RobotOutlined: Icon,
|
||||
SearchOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@dnd-kit/core', () => ({
|
||||
DndContext: ({ children }: any) => <>{children}</>,
|
||||
PointerSensor: vi.fn(),
|
||||
MouseSensor: vi.fn(),
|
||||
TouchSensor: vi.fn(),
|
||||
useSensor: vi.fn(() => ({})),
|
||||
useSensors: vi.fn(() => []),
|
||||
closestCenter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@dnd-kit/sortable', () => ({
|
||||
SortableContext: ({ children }: any) => <>{children}</>,
|
||||
useSortable: vi.fn(() => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
transform: null,
|
||||
transition: undefined,
|
||||
isDragging: false,
|
||||
})),
|
||||
horizontalListSortingStrategy: vi.fn(),
|
||||
arrayMove: (items: any[], from: number, to: number) => {
|
||||
const next = [...items];
|
||||
const [item] = next.splice(from, 1);
|
||||
next.splice(to, 0, item);
|
||||
return next;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@dnd-kit/utilities', () => ({
|
||||
CSS: {
|
||||
Transform: {
|
||||
toString: () => '',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => {
|
||||
const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
|
||||
<button type="button" disabled={disabled || loading} data-button-type={type} onClick={onClick} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
const Input: any = ({ value, onChange, placeholder, ...rest }: any) => (
|
||||
<input value={value} onChange={onChange} placeholder={placeholder} {...rest} />
|
||||
);
|
||||
Input.TextArea = ({ value, onChange, placeholder }: any) => (
|
||||
<textarea value={value} onChange={onChange} placeholder={placeholder} />
|
||||
);
|
||||
|
||||
const createForm = () => ({
|
||||
resetFields: vi.fn(),
|
||||
setFieldsValue: vi.fn(),
|
||||
getFieldsValue: vi.fn(() => ({})),
|
||||
getFieldValue: vi.fn(),
|
||||
validateFields: vi.fn(() => Promise.resolve({})),
|
||||
});
|
||||
|
||||
const Form: any = ({ children }: any) => <form>{children}</form>;
|
||||
Form.Item = ({ children }: any) => <>{children}</>;
|
||||
Form.useForm = () => [createForm()];
|
||||
|
||||
const Modal: any = ({ children, footer, open, title }: any) => (
|
||||
open ? (
|
||||
<section data-modal-title={title}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
<div>{footer}</div>
|
||||
</section>
|
||||
) : null
|
||||
);
|
||||
Modal.useModal = () => [{ info: vi.fn(() => ({ destroy: vi.fn() })) }, null];
|
||||
|
||||
const passthrough = ({ children }: any) => <>{children}</>;
|
||||
|
||||
return {
|
||||
Table: () => <table />,
|
||||
message: messageApi,
|
||||
Input,
|
||||
Button,
|
||||
Dropdown: passthrough,
|
||||
Form,
|
||||
Pagination: () => null,
|
||||
Select: () => null,
|
||||
Modal,
|
||||
Checkbox: ({ checked, onChange }: any) => <input type="checkbox" checked={checked} onChange={onChange} />,
|
||||
Segmented: () => null,
|
||||
Tooltip: passthrough,
|
||||
Popover: passthrough,
|
||||
DatePicker: () => null,
|
||||
TimePicker: () => null,
|
||||
AutoComplete: ({ children }: any) => <>{children}</>,
|
||||
};
|
||||
});
|
||||
|
||||
const textContent = (node: any): string =>
|
||||
(node.children || [])
|
||||
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
|
||||
.join('');
|
||||
|
||||
const findButton = (renderer: ReactTestRenderer, text: string) =>
|
||||
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
|
||||
|
||||
const waitForEffects = async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
describe('DataGrid DDL interactions', () => {
|
||||
beforeEach(() => {
|
||||
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
|
||||
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
|
||||
backendApp.DBShowCreateTable.mockResolvedValue({ success: true, data: 'CREATE TABLE users' });
|
||||
|
||||
vi.stubGlobal('document', {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
activeElement: null,
|
||||
elementFromPoint: vi.fn(() => null),
|
||||
createElement: vi.fn(() => ({
|
||||
style: {},
|
||||
getContext: vi.fn(() => ({ measureText: vi.fn(() => ({ width: 0 })) })),
|
||||
})),
|
||||
body: { style: {} },
|
||||
});
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
innerHeight: 768,
|
||||
innerWidth: 1024,
|
||||
getComputedStyle: vi.fn(() => ({ font: '12px sans-serif' })),
|
||||
});
|
||||
vi.stubGlobal('navigator', {
|
||||
platform: 'MacIntel',
|
||||
userAgent: '',
|
||||
clipboard: { writeText: vi.fn(() => Promise.resolve()) },
|
||||
});
|
||||
vi.stubGlobal('HTMLElement', class {});
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
backendApp.ImportData.mockReset();
|
||||
backendApp.ExportTable.mockReset();
|
||||
backendApp.ExportData.mockReset();
|
||||
backendApp.ExportQuery.mockReset();
|
||||
backendApp.ApplyChanges.mockReset();
|
||||
backendApp.DBGetColumns.mockReset();
|
||||
backendApp.DBGetIndexes.mockReset();
|
||||
backendApp.DBShowCreateTable.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('ignores stale DDL responses after the table context changes', async () => {
|
||||
let resolveFirstRequest: (value: any) => void = () => {};
|
||||
backendApp.DBShowCreateTable.mockReturnValueOnce(new Promise((resolve) => {
|
||||
resolveFirstRequest = resolve;
|
||||
}));
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DataGrid
|
||||
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
|
||||
columnNames={['id']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, '查看 DDL').props.onClick();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
<DataGrid
|
||||
data={[{ __gonavi_row_key__: 'row-2', id: 2 }]}
|
||||
columnNames={['id']}
|
||||
loading={false}
|
||||
tableName="orders"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
/>,
|
||||
);
|
||||
resolveFirstRequest({ success: true, data: 'CREATE TABLE users' });
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(textContent(renderer!.root)).not.toContain('CREATE TABLE users');
|
||||
expect(renderer!.root.findAll((node) => node.props['data-modal-title'] === 'DDL - orders')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
194
frontend/src/components/DataGrid.layout.test.tsx
Normal file
194
frontend/src/components/DataGrid.layout.test.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DataGrid from './DataGrid';
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
connections: [],
|
||||
addSqlLog: vi.fn(),
|
||||
theme: 'light',
|
||||
appearance: {
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
showDataTableVerticalBorders: false,
|
||||
dataTableColumnWidthMode: 'standard',
|
||||
},
|
||||
queryOptions: {
|
||||
showColumnComment: false,
|
||||
showColumnType: false,
|
||||
},
|
||||
setQueryOptions: vi.fn(),
|
||||
tableColumnOrders: {},
|
||||
enableColumnOrderMemory: false,
|
||||
setTableColumnOrder: vi.fn(),
|
||||
setEnableColumnOrderMemory: vi.fn(),
|
||||
clearTableColumnOrder: vi.fn(),
|
||||
tableHiddenColumns: {},
|
||||
enableHiddenColumnMemory: false,
|
||||
setTableHiddenColumns: vi.fn(),
|
||||
setEnableHiddenColumnMemory: vi.fn(),
|
||||
clearTableHiddenColumns: vi.fn(),
|
||||
aiPanelVisible: false,
|
||||
setAIPanelVisible: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => ({
|
||||
ImportData: vi.fn(),
|
||||
ExportTable: vi.fn(),
|
||||
ExportData: vi.fn(),
|
||||
ExportQuery: vi.fn(),
|
||||
ApplyChanges: vi.fn(),
|
||||
DBGetColumns: vi.fn(),
|
||||
DBGetIndexes: vi.fn(),
|
||||
DBShowCreateTable: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
describe('DataGrid layout', () => {
|
||||
it('renders a secondary action strip for view switching and auxiliary actions', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
id: 1,
|
||||
name: 'alpha',
|
||||
},
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
readOnly
|
||||
pagination={{
|
||||
current: 1,
|
||||
pageSize: 100,
|
||||
total: 1,
|
||||
}}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-grid-secondary-actions="true"');
|
||||
expect(markup).toContain('data-grid-view-switcher="true"');
|
||||
expect(markup).toContain('data-grid-page-find="true"');
|
||||
expect(markup).toContain('data-grid-page-find-prev="true"');
|
||||
expect(markup).toContain('data-grid-page-find-next="true"');
|
||||
expect(markup).toContain('当前页查找...');
|
||||
});
|
||||
|
||||
it('renders a DDL action for table data pages only', () => {
|
||||
const tableMarkup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
id: 1,
|
||||
name: 'alpha',
|
||||
},
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(tableMarkup).toContain('data-grid-ddl-action="true"');
|
||||
expect(tableMarkup).toContain('查看 DDL');
|
||||
|
||||
const schemaTableMarkup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
id: 1,
|
||||
name: 'alpha',
|
||||
},
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="public.users"
|
||||
dbName=""
|
||||
connectionId="conn-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(schemaTableMarkup).toContain('data-grid-ddl-action="true"');
|
||||
expect(schemaTableMarkup).toContain('查看 DDL');
|
||||
expect(schemaTableMarkup).toContain('data-grid-page-find="true"');
|
||||
|
||||
const queryMarkup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
id: 1,
|
||||
name: 'alpha',
|
||||
},
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
exportScope="queryResult"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(queryMarkup).not.toContain('data-grid-ddl-action="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 后面的条件');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,13 +5,15 @@ import { useStore } from '../store';
|
||||
import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App';
|
||||
import { SavedConnection } from '../types';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from '../utils/appearance';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
|
||||
import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection } from './dataSyncRequest';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
type SyncLogEvent = { jobId: string; level?: string; message?: string; ts?: number };
|
||||
type SyncProgressEvent = { jobId: string; percent?: number; current?: number; total?: number; table?: string; stage?: string };
|
||||
@@ -24,6 +26,7 @@ type TableDiffSummary = {
|
||||
updates?: number;
|
||||
deletes?: number;
|
||||
same?: number;
|
||||
schemaDiffCount?: number;
|
||||
message?: string;
|
||||
targetTableExists?: boolean;
|
||||
plannedAction?: string;
|
||||
@@ -123,6 +126,15 @@ const buildSqlPreview = (
|
||||
? previewData.columnTypes as Record<string, string>
|
||||
: {};
|
||||
const statements: string[] = [];
|
||||
const schemaStatements = Array.isArray(previewData.schemaStatements)
|
||||
? previewData.schemaStatements
|
||||
.map((item: any) => String(item || '').trim())
|
||||
.filter((item: string) => item.length > 0)
|
||||
: [];
|
||||
|
||||
schemaStatements.forEach((statement: string) => {
|
||||
statements.push(statement.endsWith(';') ? statement : `${statement};`);
|
||||
});
|
||||
|
||||
const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : [];
|
||||
const updateRows = Array.isArray(previewData.updates) ? previewData.updates : [];
|
||||
@@ -190,6 +202,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const darkMode = themeMode === 'dark';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||
|
||||
// Step 1: Config
|
||||
const [sourceConnId, setSourceConnId] = useState<string>('');
|
||||
@@ -203,6 +216,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
// Step 2: Tables
|
||||
const [allTables, setAllTables] = useState<string[]>([]);
|
||||
const [selectedTables, setSelectedTables] = useState<string[]>([]);
|
||||
const [sourceDatasetMode, setSourceDatasetMode] = useState<SourceDatasetMode>('table');
|
||||
const [sourceQuery, setSourceQuery] = useState<string>('');
|
||||
|
||||
// Options
|
||||
const [workflowType, setWorkflowType] = useState<WorkflowType>('sync');
|
||||
@@ -283,7 +298,10 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setTargetConnId('');
|
||||
setSourceDb('');
|
||||
setTargetDb('');
|
||||
setAllTables([]);
|
||||
setSelectedTables([]);
|
||||
setSourceDatasetMode('table');
|
||||
setSourceQuery('');
|
||||
setWorkflowType('sync');
|
||||
setSyncContent('data');
|
||||
setSyncMode('insert_update');
|
||||
@@ -331,6 +349,28 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
}
|
||||
}, [workflowType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceDatasetMode !== 'query') return;
|
||||
if (workflowType !== 'sync') {
|
||||
setWorkflowType('sync');
|
||||
}
|
||||
if (syncContent !== 'data') {
|
||||
setSyncContent('data');
|
||||
}
|
||||
if (targetTableStrategy !== 'existing_only') {
|
||||
setTargetTableStrategy('existing_only');
|
||||
}
|
||||
if (createIndexes) {
|
||||
setCreateIndexes(false);
|
||||
}
|
||||
if (autoAddColumns) {
|
||||
setAutoAddColumns(false);
|
||||
}
|
||||
if (selectedTables.length > 1) {
|
||||
setSelectedTables(selectedTables.slice(0, 1));
|
||||
}
|
||||
}, [sourceDatasetMode, workflowType, syncContent, targetTableStrategy, createIndexes, autoAddColumns, selectedTables]);
|
||||
|
||||
const handleSourceConnChange = async (connId: string) => {
|
||||
setSourceConnId(connId);
|
||||
setSourceDb('');
|
||||
@@ -376,10 +416,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const conn = connections.find(c => c.id === sourceConnId);
|
||||
const connId = isSourceQueryMode ? targetConnId : sourceConnId;
|
||||
const dbName = isSourceQueryMode ? targetDb : sourceDb;
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
const config = normalizeConnConfig(conn, sourceDb);
|
||||
const res = await DBGetTables(config as any, sourceDb);
|
||||
const config = normalizeConnConfig(conn, dbName);
|
||||
const res = await DBGetTables(config as any, dbName);
|
||||
if (res.success) {
|
||||
// DBGetTables returns [{Table: "name"}, ...]
|
||||
const tableRows = Array.isArray(res.data) ? res.data : [];
|
||||
@@ -387,6 +429,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
|
||||
setAllTables(tables as string[]);
|
||||
setSelectedTables(prev => {
|
||||
const existing = prev.filter((name) => tables.includes(name));
|
||||
if (isSourceQueryMode) {
|
||||
return existing.slice(0, 1);
|
||||
}
|
||||
return existing;
|
||||
});
|
||||
setCurrentStep(1);
|
||||
} else {
|
||||
message.error(res.message);
|
||||
@@ -404,7 +453,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
};
|
||||
|
||||
const analyzeDiff = async () => {
|
||||
if (selectedTables.length === 0) return;
|
||||
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
|
||||
if (selectionError) return message.error(selectionError);
|
||||
if (!sourceConnId || !targetConnId) return message.error("Select connections first");
|
||||
if (!sourceDb || !targetDb) return message.error("Select databases first");
|
||||
|
||||
@@ -421,18 +471,20 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
autoScrollRef.current = true;
|
||||
setSyncProgress({ percent: 0, current: 0, total: selectedTables.length, table: '', stage: '差异分析' });
|
||||
|
||||
const config = {
|
||||
const config = buildDataSyncRequest({
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
tables: selectedTables,
|
||||
content: syncContent,
|
||||
mode: "insert_update",
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode: "insert_update",
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
mongoCollectionName,
|
||||
jobId,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await DataSyncAnalyze(config as any);
|
||||
@@ -474,17 +526,19 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setPreviewLoading(true);
|
||||
setPreviewData(null);
|
||||
|
||||
const config = {
|
||||
const config = buildDataSyncRequest({
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
tables: selectedTables,
|
||||
content: "data",
|
||||
mode: "insert_update",
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode: "insert_update",
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
};
|
||||
mongoCollectionName,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await DataSyncPreview(config as any, table, 200);
|
||||
@@ -501,6 +555,11 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
};
|
||||
|
||||
const runSync = async () => {
|
||||
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
|
||||
if (selectionError) {
|
||||
message.error(selectionError);
|
||||
return;
|
||||
}
|
||||
if (syncContent !== 'schema' && diffTables.length === 0) {
|
||||
message.error("请先对比差异,再开始同步");
|
||||
return;
|
||||
@@ -539,19 +598,21 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
stage: '准备开始',
|
||||
});
|
||||
|
||||
const config = {
|
||||
const config = buildDataSyncRequest({
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
tables: selectedTables,
|
||||
content: syncContent,
|
||||
mode: syncMode,
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode,
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
mongoCollectionName,
|
||||
tableOptions,
|
||||
jobId,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await DataSync(config as any);
|
||||
@@ -595,6 +656,18 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const ops = tableOptions[previewTable] || { insert: true, update: true, delete: false };
|
||||
return buildSqlPreview(previewData, previewTable, targetType, ops);
|
||||
}, [previewData, previewTable, targetConnId, connections, tableOptions]);
|
||||
const previewHasSchemaStatements = useMemo(
|
||||
() => Array.isArray(previewData?.schemaStatements) && previewData.schemaStatements.length > 0,
|
||||
[previewData],
|
||||
);
|
||||
const previewSchemaWarnings = useMemo(
|
||||
() => Array.isArray(previewData?.schemaWarnings) ? previewData.schemaWarnings as string[] : [],
|
||||
[previewData],
|
||||
);
|
||||
const previewHasDataDiff = useMemo(
|
||||
() => Number(previewData?.totalInserts || 0) + Number(previewData?.totalUpdates || 0) + Number(previewData?.totalDeletes || 0) > 0,
|
||||
[previewData],
|
||||
);
|
||||
|
||||
const analysisWarnings = useMemo(() => {
|
||||
const items: string[] = [];
|
||||
@@ -605,6 +678,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
return Array.from(new Set(items));
|
||||
}, [diffTables]);
|
||||
|
||||
const isSourceQueryMode = sourceDatasetMode === 'query';
|
||||
const isMigrationWorkflow = workflowType === 'migration';
|
||||
const sourceConn = useMemo(() => connections.find(c => c.id === sourceConnId), [connections, sourceConnId]);
|
||||
const targetConn = useMemo(() => connections.find(c => c.id === targetConnId), [connections, targetConnId]);
|
||||
@@ -630,8 +704,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.36)' : '0 18px 44px rgba(15,23,42,0.14)',
|
||||
backdropFilter: darkMode ? 'blur(18px)' : 'none',
|
||||
}), [darkMode]);
|
||||
backdropFilter: resolveTextInputSafeBackdropFilter(darkMode ? 'blur(18px)' : 'none', disableLocalBackdropFilter),
|
||||
}), [darkMode, disableLocalBackdropFilter]);
|
||||
|
||||
const shellCardStyle = useMemo<React.CSSProperties>(() => ({
|
||||
borderRadius: 18,
|
||||
@@ -837,7 +911,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Form.Item label="功能类型">
|
||||
<Select value={workflowType} onChange={setWorkflowType}>
|
||||
<Option value="sync">数据同步(基于已有目标表做差异同步)</Option>
|
||||
<Option value="migration">跨库迁移(可自动建表后导入)</Option>
|
||||
<Option value="migration" disabled={isSourceQueryMode}>跨库迁移(可自动建表后导入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="源数据方式">
|
||||
<Select value={sourceDatasetMode} onChange={setSourceDatasetMode}>
|
||||
<Option value="table">按表同步</Option>
|
||||
<Option value="query">按 SQL 结果集同步</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Alert
|
||||
@@ -848,11 +928,19 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
? '当前为“跨库迁移”模式:适合将表迁移到另一数据源,可自动建表并导入数据。'
|
||||
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
|
||||
/>
|
||||
{isSourceQueryMode && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="SQL 结果集同步当前只支持:源端自定义 SQL -> 单个已存在目标表;查询结果需包含目标表主键列。"
|
||||
/>
|
||||
)}
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
|
||||
<Select value={syncContent} onChange={setSyncContent}>
|
||||
<Option value="data">仅同步数据</Option>
|
||||
<Option value="schema">仅同步结构</Option>
|
||||
<Option value="both">同步结构 + 数据</Option>
|
||||
<Option value="schema" disabled={isSourceQueryMode}>仅同步结构</Option>
|
||||
<Option value="both" disabled={isSourceQueryMode}>同步结构 + 数据</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
|
||||
@@ -863,7 +951,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
|
||||
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow}>
|
||||
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow || isSourceQueryMode}>
|
||||
<Option value="existing_only">仅使用已有目标表</Option>
|
||||
<Option value="auto_create_if_missing">目标表不存在时自动建表后导入</Option>
|
||||
<Option value="smart">智能模式(存在则直接导入,不存在则自动建表)</Option>
|
||||
@@ -886,12 +974,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item>
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)}>
|
||||
自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase)
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
|
||||
自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase;SQL 结果集模式暂不支持)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only'}>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
|
||||
自动迁移可兼容的普通索引/唯一索引(仅自动建表模式生效)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
@@ -927,21 +1015,56 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
{currentStep === 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={quietPanelStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Text type="secondary">请选择需要同步的表:</Text>
|
||||
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
|
||||
显示相同表
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Transfer
|
||||
dataSource={allTables.map(t => ({ key: t, title: t }))}
|
||||
titles={['源表', '已选表']}
|
||||
targetKeys={selectedTables}
|
||||
onChange={(keys) => setSelectedTables(keys as string[])}
|
||||
render={item => item.title}
|
||||
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
|
||||
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
|
||||
/>
|
||||
{!isSourceQueryMode && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Text type="secondary">请选择需要同步的表:</Text>
|
||||
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
|
||||
显示相同表
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Transfer
|
||||
dataSource={allTables.map(t => ({ key: t, title: t }))}
|
||||
titles={['源表', '已选表']}
|
||||
targetKeys={selectedTables}
|
||||
onChange={(keys) => setSelectedTables(keys as string[])}
|
||||
render={item => item.title}
|
||||
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
|
||||
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isSourceQueryMode && (
|
||||
<Form layout="vertical">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="请输入源查询 SQL,并选择一个目标表。差异分析会直接基于该结果集与目标表对比。"
|
||||
/>
|
||||
<Form.Item label="源查询 SQL">
|
||||
<TextArea
|
||||
value={sourceQuery}
|
||||
onChange={(e) => setSourceQuery(e.target.value)}
|
||||
rows={8}
|
||||
placeholder="例如:SELECT id, name, email FROM users WHERE status = 'active'"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="目标表">
|
||||
<Select
|
||||
value={selectedTables[0]}
|
||||
onChange={(value) => setSelectedTables(value ? [value] : [])}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="请选择一个目标表"
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{allTables.map((table) => <Option key={table} value={table}>{table}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{diffTables.length > 0 && (
|
||||
@@ -1060,8 +1183,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
render: (_: any, r: any) => {
|
||||
const can = !!r.canSync;
|
||||
const hasDiff = Number(r.inserts || 0) + Number(r.updates || 0) + Number(r.deletes || 0) > 0;
|
||||
const hasSchemaDiff = Number(r.schemaDiffCount || 0) > 0;
|
||||
return (
|
||||
<Button size="small" disabled={!can || !hasDiff || analyzing} onClick={() => openPreview(r.table)}>
|
||||
<Button size="small" disabled={!can || !(hasDiff || hasSchemaDiff) || analyzing} onClick={() => openPreview(r.table)}>
|
||||
查看
|
||||
</Button>
|
||||
);
|
||||
@@ -1133,14 +1257,14 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
{currentStep === 1 && (
|
||||
<>
|
||||
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}>上一步</Button>
|
||||
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing} style={{ marginRight: 8 }}>
|
||||
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing || (isSourceQueryMode && !sourceQuery.trim())} style={{ marginRight: 8 }}>
|
||||
对比差异
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={runSync}
|
||||
loading={loading}
|
||||
disabled={selectedTables.length === 0 || (syncContent !== 'schema' && diffTables.length === 0)}
|
||||
disabled={selectedTables.length === 0 || (isSourceQueryMode && !sourceQuery.trim()) || (syncContent !== 'schema' && diffTables.length === 0)}
|
||||
>
|
||||
开始同步
|
||||
</Button>
|
||||
@@ -1168,12 +1292,59 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`}
|
||||
message={
|
||||
previewHasDataDiff
|
||||
? `插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`
|
||||
: (previewData.schemaSummary || `检测到 ${previewSql.statementCount} 条结构变更语句`)
|
||||
}
|
||||
/>
|
||||
{previewSchemaWarnings.length > 0 && (
|
||||
<Alert
|
||||
style={{ marginTop: 12 }}
|
||||
type="warning"
|
||||
showIcon
|
||||
message="结构预览包含风险或降级项"
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{previewSchemaWarnings.slice(0, 8).map((item) => <li key={item}>{item}</li>)}
|
||||
{previewSchemaWarnings.length > 8 && <li>还有 {previewSchemaWarnings.length - 8} 项未展开</li>}
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
...(previewHasSchemaStatements ? [{
|
||||
key: 'schema',
|
||||
label: `结构(${Array.isArray(previewData.schemaStatements) ? previewData.schemaStatements.length : 0})`,
|
||||
children: (
|
||||
<div>
|
||||
<Text type="secondary">
|
||||
{previewData.schemaSummary || '以下为本次结构同步计划执行的语句。'}
|
||||
</Text>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: 8,
|
||||
marginBottom: 0,
|
||||
padding: 10,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
background: '#fafafa',
|
||||
maxHeight: 420,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{Array.isArray(previewData.schemaStatements) && previewData.schemaStatements.length > 0
|
||||
? previewData.schemaStatements.join('\n')
|
||||
: '-- 当前表结构无可执行变更'}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}] : []),
|
||||
...(previewHasDataDiff ? [{
|
||||
key: 'insert',
|
||||
label: `插入(${previewData.totalInserts || 0})`,
|
||||
children: (
|
||||
@@ -1273,7 +1444,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
key: 'sql',
|
||||
label: `SQL(${previewSql.statementCount})`,
|
||||
@@ -1282,10 +1453,18 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
|
||||
message={
|
||||
previewHasDataDiff
|
||||
? "SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
|
||||
: "SQL 预览展示将执行的结构变更语句,用于审核确认。"
|
||||
}
|
||||
/>
|
||||
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary">共 {previewSql.statementCount} 条语句(预览数据最多 200 条/类型)</Text>
|
||||
<Text type="secondary">
|
||||
{previewHasDataDiff
|
||||
? `共 ${previewSql.statementCount} 条语句(预览数据最多 200 条/类型)`
|
||||
: `共 ${previewSql.statementCount} 条结构变更语句`}
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!previewSql.sqlText}
|
||||
@@ -1314,7 +1493,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{previewSql.sqlText || '-- 当前勾选范围下无 SQL 可预览'}
|
||||
{previewSql.sqlText || (previewHasDataDiff ? '-- 当前勾选范围下无 SQL 可预览' : '-- 当前表结构无可执行变更')}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,9 +7,14 @@ import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
|
||||
import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveApproximateTableCountStrategy } from '../utils/approximateTableCount';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
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 () => {
|
||||
@@ -396,9 +406,17 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const dbType = config.type || '';
|
||||
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 = String(currentConnConfig?.type || '').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, 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',
|
||||
|
||||
@@ -10,6 +10,23 @@ interface DefinitionViewerProps {
|
||||
tab: TabData;
|
||||
}
|
||||
|
||||
const normalizeMySQLViewDDL = (rawDefinition: unknown): string => {
|
||||
const text = String(rawDefinition || '').trim();
|
||||
if (!text) return '';
|
||||
|
||||
const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, '');
|
||||
const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i;
|
||||
if (createViewPrefixPattern.test(normalized)) {
|
||||
return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`;
|
||||
}
|
||||
|
||||
if (/^\s*(select|with)\b/i.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return `${normalized};`;
|
||||
};
|
||||
|
||||
const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -257,15 +274,15 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
case 'mysql': {
|
||||
const keys = Object.keys(row);
|
||||
const textDefinition = row.view_definition || row.VIEW_DEFINITION;
|
||||
if (textDefinition) return String(textDefinition);
|
||||
if (textDefinition) return normalizeMySQLViewDDL(textDefinition);
|
||||
const sqlKey = keys.find(k => k.toLowerCase().includes('create view') || k.toLowerCase() === 'create view');
|
||||
if (sqlKey) return row[sqlKey];
|
||||
if (sqlKey) return normalizeMySQLViewDDL(row[sqlKey]);
|
||||
const tableSqlKey = keys.find(k => k.toLowerCase().includes('create table'));
|
||||
if (tableSqlKey) return row[tableSqlKey];
|
||||
if (tableSqlKey) return normalizeMySQLViewDDL(row[tableSqlKey]);
|
||||
for (const key of keys) {
|
||||
const val = String(row[key] || '');
|
||||
if (val.toUpperCase().includes('CREATE') && (val.toUpperCase().includes('VIEW') || val.toUpperCase().includes('TABLE'))) {
|
||||
return val;
|
||||
return normalizeMySQLViewDDL(val);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(row, null, 2);
|
||||
|
||||
@@ -4,6 +4,11 @@ import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutline
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { useStore } from '../store';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import {
|
||||
DRIVER_LOCAL_IMPORT_BUTTON_LABEL,
|
||||
DRIVER_LOCAL_IMPORT_DIRECTORY_HELP,
|
||||
DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP,
|
||||
} from '../utils/driverImportGuidance';
|
||||
import {
|
||||
CheckDriverNetworkStatus,
|
||||
DownloadDriverPackage,
|
||||
@@ -1171,7 +1176,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
loading={loadingLocal}
|
||||
onClick={() => installDriverFromLocalFile(row)}
|
||||
>
|
||||
本地导入
|
||||
{DRIVER_LOCAL_IMPORT_BUTTON_LABEL}
|
||||
</Button>
|
||||
<Button
|
||||
type={hasLogs ? 'default' : 'text'}
|
||||
@@ -1373,8 +1378,8 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
children: (
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary">自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。</Text>
|
||||
<Text type="secondary">如果应用内下载链路失败,可先手动下载驱动包到该目录,再使用“本地导入”或“导入驱动目录”完成安装。</Text>
|
||||
<Text type="secondary">行内“本地导入”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`);批量导入请使用上方“导入驱动目录”。</Text>
|
||||
<Text type="secondary">{DRIVER_LOCAL_IMPORT_DIRECTORY_HELP}</Text>
|
||||
<Text type="secondary">{DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP}</Text>
|
||||
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
|
||||
驱动根目录:{downloadDir || '-'}
|
||||
</Paragraph>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { quoteIdentPart, escapeLiteral } from '../utils/sql';
|
||||
import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { isMacLikePlatform } from '../utils/appearance';
|
||||
|
||||
interface FindInDatabaseModalProps {
|
||||
open: boolean;
|
||||
@@ -67,14 +68,15 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||
|
||||
const conn = useMemo(() => connections.find(c => c.id === connectionId), [connections, connectionId]);
|
||||
const dbType = useMemo(() => (conn?.config?.type || 'mysql').toLowerCase(), [conn]);
|
||||
|
||||
const wt = useMemo(() => {
|
||||
const isDark = theme === 'dark';
|
||||
return buildOverlayWorkbenchTheme(isDark);
|
||||
}, [theme]);
|
||||
return buildOverlayWorkbenchTheme(isDark, { disableBackdropFilter: disableLocalBackdropFilter });
|
||||
}, [disableLocalBackdropFilter, theme]);
|
||||
|
||||
const buildConfig = useCallback(() => {
|
||||
if (!conn) return null;
|
||||
|
||||
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;
|
||||
946
frontend/src/components/JVMDiagnosticConsole.test.tsx
Normal file
946
frontend/src/components/JVMDiagnosticConsole.test.tsx
Normal file
@@ -0,0 +1,946 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { act, create } from "react-test-renderer";
|
||||
import { message } from "antd";
|
||||
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;
|
||||
let registeredDiagnosticChunkHandler: any = null;
|
||||
const mockBackendApp = {
|
||||
JVMListDiagnosticAuditRecords: vi.fn(),
|
||||
JVMExecuteDiagnosticCommand: vi.fn(),
|
||||
};
|
||||
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("../../wailsjs/runtime", () => ({
|
||||
EventsOn: vi.fn((_eventName: string, handler: any) => {
|
||||
registeredDiagnosticChunkHandler = handler;
|
||||
return vi.fn();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@ant-design/icons", () => {
|
||||
const Icon = () => <span />;
|
||||
return {
|
||||
ClearOutlined: Icon,
|
||||
HistoryOutlined: Icon,
|
||||
PauseCircleOutlined: Icon,
|
||||
PlayCircleOutlined: Icon,
|
||||
ReloadOutlined: Icon,
|
||||
RocketOutlined: Icon,
|
||||
ToolOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("antd", () => {
|
||||
const passthrough = ({ children, style }: any) => <div style={style}>{children}</div>;
|
||||
const Text = ({ children, style }: any) => <span style={style}>{children}</span>;
|
||||
const Paragraph = ({ children, style }: any) => <p style={style}>{children}</p>;
|
||||
const Title = ({ children, style }: any) => <h3 style={style}>{children}</h3>;
|
||||
const Empty = ({ description }: any) => <div>{description}</div>;
|
||||
Empty.PRESENTED_IMAGE_SIMPLE = "simple";
|
||||
const List = ({ dataSource = [], renderItem }: any) => (
|
||||
<div>{dataSource.map((item: any, index: number) => renderItem(item, index))}</div>
|
||||
);
|
||||
List.Item = ({ children, style }: any) => <div style={style}>{children}</div>;
|
||||
const Typography = { Text, Paragraph, Title };
|
||||
return {
|
||||
Alert: ({ message: alertMessage, description, style }: any) => (
|
||||
<div style={style}>{alertMessage}{description}</div>
|
||||
),
|
||||
Button: ({ children, onClick, disabled, style }: any) => <button onClick={onClick} disabled={disabled} style={style}>{children}</button>,
|
||||
Card: ({ children, title, style }: any) => <section style={style}>{title}{children}</section>,
|
||||
Empty,
|
||||
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
|
||||
List,
|
||||
Space: passthrough,
|
||||
Tag: ({ children, style }: any) => <span style={style}>{children}</span>,
|
||||
Typography,
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../store", () => ({
|
||||
useStore: (selector: (state: any) => any) => selector(mockState),
|
||||
}));
|
||||
|
||||
describe("JVMDiagnosticConsole", () => {
|
||||
beforeEach(() => {
|
||||
registeredCompletionProvider = null;
|
||||
registeredDiagnosticChunkHandler = null;
|
||||
mockState = {
|
||||
...baseState,
|
||||
setJVMDiagnosticDraft: vi.fn(),
|
||||
appendJVMDiagnosticOutput: vi.fn(),
|
||||
clearJVMDiagnosticOutput: vi.fn(),
|
||||
};
|
||||
mockBackendApp.JVMListDiagnosticAuditRecords.mockResolvedValue({
|
||||
success: true,
|
||||
data: [],
|
||||
});
|
||||
mockBackendApp.JVMExecuteDiagnosticCommand.mockReset();
|
||||
vi.mocked(message.success).mockClear();
|
||||
vi.mocked(message.warning).mockClear();
|
||||
vi.mocked(message.info).mockClear();
|
||||
(globalThis as any).window = {
|
||||
...(globalThis as any).window,
|
||||
go: { app: { App: mockBackendApp } },
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
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("redacts sensitive diagnostic output in the rendered console", () => {
|
||||
mockState = {
|
||||
...baseState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "watch com.foo.SecretService read '{returnObj}'",
|
||||
},
|
||||
},
|
||||
jvmDiagnosticOutputs: {
|
||||
"tab-1": [
|
||||
{
|
||||
sessionId: "session-1",
|
||||
commandId: "cmd-1",
|
||||
event: "diagnostic",
|
||||
phase: "running",
|
||||
content: "password=secret-token\napiKey: api-key-secret",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMDiagnosticConsole
|
||||
tab={{
|
||||
id: "tab-1",
|
||||
title: "诊断增强",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("password=********");
|
||||
expect(markup).toContain("apiKey: ********");
|
||||
expect(markup).not.toContain("secret-token");
|
||||
expect(markup).not.toContain("api-key-secret");
|
||||
});
|
||||
|
||||
it("uses the same styled editor shell and registers command completion before mount", () => {
|
||||
mockState = {
|
||||
...mockState,
|
||||
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 ",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts failed diagnostic event content before storing and alerting", async () => {
|
||||
mockState = {
|
||||
...mockState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let renderer: any;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<JVMDiagnosticConsole
|
||||
tab={{
|
||||
id: "tab-1",
|
||||
title: "诊断增强",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: "session-1",
|
||||
commandId: "cmd-1",
|
||||
event: "diagnostic",
|
||||
phase: "running",
|
||||
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
|
||||
},
|
||||
});
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: "session-1",
|
||||
commandId: "cmd-1",
|
||||
event: "diagnostic",
|
||||
phase: "failed",
|
||||
content: "def456\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
|
||||
(call: any[]) => call[1],
|
||||
);
|
||||
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
|
||||
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
|
||||
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
|
||||
});
|
||||
|
||||
it("redacts successful diagnostic warning messages", async () => {
|
||||
mockState = {
|
||||
...mockState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockBackendApp.JVMExecuteDiagnosticCommand.mockResolvedValue({
|
||||
success: true,
|
||||
message: "api_key=query-secret",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
create(
|
||||
<JVMDiagnosticConsole
|
||||
tab={{
|
||||
id: "tab-1",
|
||||
title: "诊断增强",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
mockEditor.addCommand.mock.calls[0][1]();
|
||||
});
|
||||
|
||||
expect(message.warning).toHaveBeenCalledWith("api_key=********");
|
||||
expect(message.warning).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("query-secret"),
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts successful diagnostic warning messages with the active diagnostic stream state", async () => {
|
||||
mockState = {
|
||||
...mockState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
};
|
||||
let resolveCommand: (value: any) => void = () => {};
|
||||
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveCommand = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
create(
|
||||
<JVMDiagnosticConsole
|
||||
tab={{
|
||||
id: "tab-1",
|
||||
title: "诊断增强",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
mockEditor.addCommand.mock.calls[0][1]();
|
||||
});
|
||||
|
||||
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
|
||||
await act(async () => {
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: executeRequest.sessionId,
|
||||
commandId: executeRequest.commandId,
|
||||
event: "diagnostic",
|
||||
phase: "running",
|
||||
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveCommand({
|
||||
success: true,
|
||||
message: "def456\n-----END PRIVATE KEY-----",
|
||||
});
|
||||
});
|
||||
|
||||
expect(JSON.stringify((message.warning as any).mock.calls)).not.toContain(
|
||||
"def456",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps diagnostic redaction state after clearing visible output", async () => {
|
||||
mockState = {
|
||||
...mockState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let renderer: any;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<JVMDiagnosticConsole
|
||||
tab={{
|
||||
id: "tab-1",
|
||||
title: "诊断增强",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: "session-1",
|
||||
commandId: "cmd-1",
|
||||
event: "diagnostic",
|
||||
phase: "running",
|
||||
content: "PRIVATE_KEY=-----BEGIN PRIV",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const clearButton = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((button: any) => button.children.includes("清空输出"));
|
||||
await act(async () => {
|
||||
clearButton.props.onClick();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: "session-1",
|
||||
commandId: "cmd-1",
|
||||
event: "diagnostic",
|
||||
phase: "failed",
|
||||
content: "ATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
|
||||
(call: any[]) => call[1],
|
||||
);
|
||||
expect(mockState.clearJVMDiagnosticOutput).toHaveBeenCalledWith("tab-1");
|
||||
expect(JSON.stringify(appendedChunks)).not.toContain("ATE KEY");
|
||||
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
|
||||
});
|
||||
|
||||
it("redacts frontend fallback errors with the active diagnostic stream state", async () => {
|
||||
mockState = {
|
||||
...mockState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
};
|
||||
let rejectCommand: (error: Error) => void = () => {};
|
||||
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectCommand = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
create(
|
||||
<JVMDiagnosticConsole
|
||||
tab={{
|
||||
id: "tab-1",
|
||||
title: "诊断增强",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
mockEditor.addCommand.mock.calls[0][1]();
|
||||
});
|
||||
|
||||
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
|
||||
await act(async () => {
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: executeRequest.sessionId,
|
||||
commandId: executeRequest.commandId,
|
||||
event: "diagnostic",
|
||||
phase: "running",
|
||||
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
|
||||
});
|
||||
|
||||
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
|
||||
(call: any[]) => call[1],
|
||||
);
|
||||
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
|
||||
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
|
||||
});
|
||||
|
||||
it("keeps diagnostic redaction state after local completion fallback", async () => {
|
||||
mockState = {
|
||||
...mockState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
};
|
||||
let resolveCommand: (value: any) => void = () => {};
|
||||
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveCommand = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
create(
|
||||
<JVMDiagnosticConsole
|
||||
tab={{
|
||||
id: "tab-1",
|
||||
title: "诊断增强",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
mockEditor.addCommand.mock.calls[0][1]();
|
||||
});
|
||||
|
||||
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
|
||||
await act(async () => {
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: executeRequest.sessionId,
|
||||
commandId: executeRequest.commandId,
|
||||
event: "diagnostic",
|
||||
phase: "running",
|
||||
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveCommand({ success: true });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: executeRequest.sessionId,
|
||||
commandId: executeRequest.commandId,
|
||||
event: "diagnostic",
|
||||
phase: "completed",
|
||||
content: "def456\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
|
||||
(call: any[]) => call[1],
|
||||
);
|
||||
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
|
||||
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
|
||||
});
|
||||
|
||||
it("redacts terminal-seen execute errors with the active diagnostic stream state", async () => {
|
||||
mockState = {
|
||||
...mockState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
};
|
||||
let rejectCommand: (error: Error) => void = () => {};
|
||||
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectCommand = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
let renderer: any;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<JVMDiagnosticConsole
|
||||
tab={{
|
||||
id: "tab-1",
|
||||
title: "诊断增强",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
mockEditor.addCommand.mock.calls[0][1]();
|
||||
});
|
||||
|
||||
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
|
||||
await act(async () => {
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: executeRequest.sessionId,
|
||||
commandId: executeRequest.commandId,
|
||||
event: "diagnostic",
|
||||
phase: "running",
|
||||
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
|
||||
},
|
||||
});
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: executeRequest.sessionId,
|
||||
commandId: executeRequest.commandId,
|
||||
event: "diagnostic",
|
||||
phase: "completed",
|
||||
content: "still waiting for execute call",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
|
||||
});
|
||||
|
||||
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
|
||||
});
|
||||
|
||||
it("redacts execute errors after a real failed terminal event closes the active PEM stream", async () => {
|
||||
mockState = {
|
||||
...mockState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
};
|
||||
let rejectCommand: (error: Error) => void = () => {};
|
||||
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectCommand = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
let renderer: any;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<JVMDiagnosticConsole
|
||||
tab={{
|
||||
id: "tab-1",
|
||||
title: "诊断增强",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
mockEditor.addCommand.mock.calls[0][1]();
|
||||
});
|
||||
|
||||
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
|
||||
await act(async () => {
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: executeRequest.sessionId,
|
||||
commandId: executeRequest.commandId,
|
||||
event: "diagnostic",
|
||||
phase: "running",
|
||||
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
|
||||
},
|
||||
});
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: executeRequest.sessionId,
|
||||
commandId: executeRequest.commandId,
|
||||
event: "diagnostic",
|
||||
phase: "failed",
|
||||
content: "def456\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
|
||||
});
|
||||
|
||||
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
|
||||
});
|
||||
|
||||
it("redacts delayed failed terminal events after frontend fallback closes the active PEM stream", async () => {
|
||||
mockState = {
|
||||
...mockState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
};
|
||||
let rejectCommand: (error: Error) => void = () => {};
|
||||
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectCommand = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
create(
|
||||
<JVMDiagnosticConsole
|
||||
tab={{
|
||||
id: "tab-1",
|
||||
title: "诊断增强",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
mockEditor.addCommand.mock.calls[0][1]();
|
||||
});
|
||||
|
||||
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
|
||||
await act(async () => {
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: executeRequest.sessionId,
|
||||
commandId: executeRequest.commandId,
|
||||
event: "diagnostic",
|
||||
phase: "running",
|
||||
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
registeredDiagnosticChunkHandler({
|
||||
tabId: "tab-1",
|
||||
chunk: {
|
||||
sessionId: executeRequest.sessionId,
|
||||
commandId: executeRequest.commandId,
|
||||
event: "diagnostic",
|
||||
phase: "failed",
|
||||
content: "def456\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
|
||||
(call: any[]) => call[1],
|
||||
);
|
||||
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
|
||||
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
|
||||
});
|
||||
});
|
||||
1146
frontend/src/components/JVMDiagnosticConsole.tsx
Normal file
1146
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;
|
||||
563
frontend/src/components/JVMResourceBrowser.interaction.test.tsx
Normal file
563
frontend/src/components/JVMResourceBrowser.interaction.test.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import JVMResourceBrowser from "./JVMResourceBrowser";
|
||||
import type { JVMValueSnapshot } from "../types";
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
connections: [
|
||||
{
|
||||
id: "conn-jvm-writable",
|
||||
name: "orders-jvm",
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
user: "jmx-user",
|
||||
port: 9010,
|
||||
type: "jvm",
|
||||
jvm: {
|
||||
preferredMode: "jmx",
|
||||
readOnly: false,
|
||||
jmx: {
|
||||
password: "initial-jmx-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
addTab: vi.fn(),
|
||||
aiPanelVisible: false,
|
||||
setAIPanelVisible: vi.fn(),
|
||||
theme: "light",
|
||||
}));
|
||||
|
||||
const backendApp = vi.hoisted(() => ({
|
||||
JVMGetValue: vi.fn(),
|
||||
JVMPreviewChange: vi.fn(),
|
||||
JVMApplyChange: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@monaco-editor/react", () => ({
|
||||
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
|
||||
}));
|
||||
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
FileSearchOutlined: () => <span />,
|
||||
ReloadOutlined: () => <span />,
|
||||
RobotOutlined: () => <span />,
|
||||
}));
|
||||
|
||||
vi.mock("antd", () => {
|
||||
const Text = ({ children }: any) => <span>{children}</span>;
|
||||
const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
|
||||
<button
|
||||
type="button"
|
||||
data-button-type={type}
|
||||
disabled={disabled || loading}
|
||||
onClick={onClick}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
const Card = ({ children, title }: any) => (
|
||||
<section>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
const Descriptions: any = ({ children }: any) => <dl>{children}</dl>;
|
||||
Descriptions.Item = ({ children, label }: any) => (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
const Input: any = ({ value, onChange, placeholder }: any) => (
|
||||
<input value={value} onChange={onChange} placeholder={placeholder} />
|
||||
);
|
||||
Input.TextArea = ({ value, onChange }: any) => (
|
||||
<textarea value={value} onChange={onChange} />
|
||||
);
|
||||
|
||||
return {
|
||||
Alert: ({ message }: any) => <div role="alert">{message}</div>,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Empty: ({ description }: any) => <div>{description}</div>,
|
||||
Input,
|
||||
Skeleton: () => <div>loading</div>,
|
||||
Space: ({ children }: any) => <div>{children}</div>,
|
||||
Tag: ({ children }: any) => <span>{children}</span>,
|
||||
Typography: { Text },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../store", () => {
|
||||
const useStore = (selector: (state: typeof storeState) => any) => selector(storeState);
|
||||
useStore.getState = () => storeState;
|
||||
return { useStore };
|
||||
});
|
||||
|
||||
vi.mock("./jvm/JVMModeBadge", () => ({
|
||||
default: ({ mode }: { mode: string }) => <span>{mode}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("./jvm/JVMWorkspaceLayout", () => ({
|
||||
getJVMWorkspaceCardStyle: () => ({}),
|
||||
JVMWorkspaceHero: ({ actions, badges, description, title }: any) => (
|
||||
<header>
|
||||
<h1>{title}</h1>
|
||||
{description}
|
||||
{badges}
|
||||
{actions}
|
||||
</header>
|
||||
),
|
||||
JVMWorkspaceShell: ({ children }: any) => <main>{children}</main>,
|
||||
}));
|
||||
|
||||
vi.mock("./jvm/JVMChangePreviewModal", () => ({
|
||||
default: ({ open, onConfirm }: any) =>
|
||||
open ? <button type="button" onClick={onConfirm}>确认执行</button> : null,
|
||||
}));
|
||||
|
||||
const writableTab = {
|
||||
id: "tab-jvm-resource",
|
||||
type: "jvm-resource",
|
||||
title: "[orders-jvm] JVM 资源",
|
||||
connectionId: "conn-jvm-writable",
|
||||
providerMode: "jmx",
|
||||
resourcePath: "jmx:/attribute/app/Mode",
|
||||
resourceKind: "attribute",
|
||||
} as any;
|
||||
|
||||
const textContent = (node: any): string =>
|
||||
(node.children || [])
|
||||
.map((item: any) => (typeof item === "string" ? item : textContent(item)))
|
||||
.join("");
|
||||
|
||||
const findButton = (renderer: ReactTestRenderer, text: string) =>
|
||||
renderer.root.findAll((node) => node.type === "button" && textContent(node).includes(text))[0];
|
||||
|
||||
const waitForEffects = async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
describe("JVMResourceBrowser interactions", () => {
|
||||
beforeEach(() => {
|
||||
storeState.connections = [
|
||||
{
|
||||
id: "conn-jvm-writable",
|
||||
name: "orders-jvm",
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
user: "jmx-user",
|
||||
port: 9010,
|
||||
type: "jvm",
|
||||
jvm: {
|
||||
preferredMode: "jmx",
|
||||
readOnly: false,
|
||||
jmx: {
|
||||
password: "initial-jmx-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const snapshot: JVMValueSnapshot = {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
version: "v1",
|
||||
value: "cold",
|
||||
supportedActions: [
|
||||
{
|
||||
action: "set",
|
||||
label: "设置属性",
|
||||
payloadExample: { value: "warm" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
backendApp.JVMGetValue.mockResolvedValue({ success: true, data: snapshot });
|
||||
backendApp.JVMPreviewChange.mockResolvedValue({
|
||||
allowed: true,
|
||||
requiresConfirmation: true,
|
||||
confirmationToken: "token-from-preview",
|
||||
summary: "设置 Mode",
|
||||
riskLevel: "high",
|
||||
before: snapshot,
|
||||
after: { ...snapshot, value: "warm", version: "v2" },
|
||||
});
|
||||
backendApp.JVMApplyChange.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
status: "applied",
|
||||
updatedValue: { ...snapshot, value: "warm", version: "v2" },
|
||||
},
|
||||
});
|
||||
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
go: {
|
||||
app: {
|
||||
App: backendApp,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
backendApp.JVMGetValue.mockReset();
|
||||
backendApp.JVMPreviewChange.mockReset();
|
||||
backendApp.JVMApplyChange.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("applies the latest successful preview request even when the draft is edited afterward", async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const reasonInput = renderer!.root
|
||||
.findAllByType("input")
|
||||
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
|
||||
await act(async () => {
|
||||
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
|
||||
});
|
||||
|
||||
const payloadEditor = () => renderer!.root.findByType("textarea");
|
||||
await act(async () => {
|
||||
payloadEditor().props.onChange({ target: { value: '{"value":"previewed"}' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "预览变更").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
payloadEditor().props.onChange({ target: { value: '{"value":"edited-after-preview"}' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "确认执行").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(backendApp.JVMApplyChange).toHaveBeenCalledTimes(1);
|
||||
expect(backendApp.JVMApplyChange.mock.calls[0][0]).toBe(
|
||||
backendApp.JVMPreviewChange.mock.calls[0][0],
|
||||
);
|
||||
expect(backendApp.JVMApplyChange.mock.calls[0][1]).toMatchObject({
|
||||
action: "set",
|
||||
confirmationToken: "token-from-preview",
|
||||
payload: { value: "previewed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a stale snapshot resource id override the current resource preview", async () => {
|
||||
backendApp.JVMGetValue.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
version: "v1",
|
||||
value: "cold",
|
||||
supportedActions: [
|
||||
{
|
||||
action: "set",
|
||||
label: "设置属性",
|
||||
payloadExample: { value: "warm" },
|
||||
},
|
||||
],
|
||||
} as JVMValueSnapshot,
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
<JVMResourceBrowser
|
||||
tab={{
|
||||
...writableTab,
|
||||
resourcePath: "jmx:/attribute/app/OtherMode",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const reasonInput = renderer!.root
|
||||
.findAllByType("input")
|
||||
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
|
||||
await act(async () => {
|
||||
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
|
||||
renderer!.root.findByType("textarea").props.onChange({
|
||||
target: { value: '{"value":"previewed"}' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "预览变更").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(backendApp.JVMPreviewChange.mock.calls[backendApp.JVMPreviewChange.mock.calls.length - 1]?.[1]).toMatchObject({
|
||||
resourceId: "jmx:/attribute/app/OtherMode",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale preview responses after the resource context changes", async () => {
|
||||
let resolvePreview: (value: any) => void = () => {};
|
||||
backendApp.JVMPreviewChange.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolvePreview = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const reasonInput = renderer!.root
|
||||
.findAllByType("input")
|
||||
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
|
||||
await act(async () => {
|
||||
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
|
||||
renderer!.root.findByType("textarea").props.onChange({
|
||||
target: { value: '{"value":"previewed"}' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "预览变更").props.onClick();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
<JVMResourceBrowser
|
||||
tab={{
|
||||
...writableTab,
|
||||
resourcePath: "jmx:/attribute/app/OtherMode",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
resolvePreview({
|
||||
allowed: true,
|
||||
requiresConfirmation: true,
|
||||
confirmationToken: "stale-token",
|
||||
summary: "旧预览",
|
||||
riskLevel: "high",
|
||||
before: {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
value: "cold",
|
||||
},
|
||||
after: {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
value: "warm",
|
||||
},
|
||||
});
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(findButton(renderer!, "确认执行")).toBeUndefined();
|
||||
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects confirming a preview after the resource context changes", async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const reasonInput = renderer!.root
|
||||
.findAllByType("input")
|
||||
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
|
||||
await act(async () => {
|
||||
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
|
||||
renderer!.root.findByType("textarea").props.onChange({
|
||||
target: { value: '{"value":"previewed"}' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "预览变更").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
<JVMResourceBrowser
|
||||
tab={{
|
||||
...writableTab,
|
||||
resourcePath: "jmx:/attribute/app/OtherMode",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
findButton(renderer!, "确认执行").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects confirming a preview after the connection config changes", async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const reasonInput = renderer!.root
|
||||
.findAllByType("input")
|
||||
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
|
||||
await act(async () => {
|
||||
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
|
||||
renderer!.root.findByType("textarea").props.onChange({
|
||||
target: { value: '{"value":"previewed"}' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "预览变更").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
storeState.connections = [
|
||||
{
|
||||
...storeState.connections[0],
|
||||
config: {
|
||||
...storeState.connections[0].config,
|
||||
jvm: {
|
||||
...storeState.connections[0].config.jvm,
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
|
||||
const confirmButton = findButton(renderer!, "确认执行");
|
||||
if (confirmButton) {
|
||||
await act(async () => {
|
||||
confirmButton.props.onClick();
|
||||
});
|
||||
}
|
||||
await waitForEffects();
|
||||
|
||||
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects confirming a preview after JVM credentials change", async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const reasonInput = renderer!.root
|
||||
.findAllByType("input")
|
||||
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
|
||||
await act(async () => {
|
||||
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
|
||||
renderer!.root.findByType("textarea").props.onChange({
|
||||
target: { value: '{"value":"previewed"}' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "预览变更").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
storeState.connections = [
|
||||
{
|
||||
...storeState.connections[0],
|
||||
config: {
|
||||
...storeState.connections[0].config,
|
||||
jvm: {
|
||||
...storeState.connections[0].config.jvm,
|
||||
jmx: {
|
||||
...storeState.connections[0].config.jvm.jmx,
|
||||
password: "rotated-jmx-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
|
||||
const confirmButton = findButton(renderer!, "确认执行");
|
||||
if (confirmButton) {
|
||||
await act(async () => {
|
||||
confirmButton.props.onClick();
|
||||
});
|
||||
}
|
||||
await waitForEffects();
|
||||
|
||||
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not seed sensitive payload examples into the draft editor", async () => {
|
||||
backendApp.JVMGetValue.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
resourceId: "jmx:/attribute/app/Password",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
version: "v1",
|
||||
value: "secret-token",
|
||||
sensitive: true,
|
||||
supportedActions: [
|
||||
{
|
||||
action: "set",
|
||||
label: "设置属性",
|
||||
payloadExample: { value: "secret-token" },
|
||||
},
|
||||
],
|
||||
} as JVMValueSnapshot,
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<JVMResourceBrowser
|
||||
tab={{
|
||||
...writableTab,
|
||||
resourcePath: "jmx:/attribute/app/Password",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(renderer!.root.findByType("textarea").props.value).not.toContain("secret-token");
|
||||
});
|
||||
});
|
||||
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)');
|
||||
});
|
||||
});
|
||||
1039
frontend/src/components/JVMResourceBrowser.tsx
Normal file
1039
frontend/src/components/JVMResourceBrowser.tsx
Normal file
File diff suppressed because it is too large
Load Diff
279
frontend/src/components/QueryEditor.external-sql-save.test.tsx
Normal file
279
frontend/src/components/QueryEditor.external-sql-save.test.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SavedQuery, TabData } from '../types';
|
||||
import QueryEditor from './QueryEditor';
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'local',
|
||||
config: {
|
||||
type: 'mysql',
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
password: '',
|
||||
database: 'main',
|
||||
},
|
||||
},
|
||||
],
|
||||
addSqlLog: vi.fn(),
|
||||
addTab: vi.fn(),
|
||||
savedQueries: [] as SavedQuery[],
|
||||
saveQuery: vi.fn(),
|
||||
theme: 'light',
|
||||
sqlFormatOptions: { keywordCase: 'upper' as const },
|
||||
setSqlFormatOptions: vi.fn(),
|
||||
queryOptions: { maxRows: 5000 },
|
||||
setQueryOptions: vi.fn(),
|
||||
shortcutOptions: {
|
||||
runQuery: { enabled: false, combo: '' },
|
||||
},
|
||||
activeTabId: 'tab-1',
|
||||
aiPanelVisible: false,
|
||||
setAIPanelVisible: vi.fn(),
|
||||
}));
|
||||
|
||||
const backendApp = vi.hoisted(() => ({
|
||||
DBQueryWithCancel: vi.fn(),
|
||||
DBQueryMulti: vi.fn(),
|
||||
DBGetTables: vi.fn(),
|
||||
DBGetAllColumns: vi.fn(),
|
||||
DBGetDatabases: vi.fn(),
|
||||
DBGetColumns: vi.fn(),
|
||||
CancelQuery: vi.fn(),
|
||||
GenerateQueryID: vi.fn(),
|
||||
WriteSQLFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const messageApi = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}));
|
||||
|
||||
const editorState = vi.hoisted(() => {
|
||||
const state = {
|
||||
value: '',
|
||||
editor: null as any,
|
||||
};
|
||||
state.editor = {
|
||||
getValue: vi.fn(() => state.value),
|
||||
setValue: vi.fn((value: string) => {
|
||||
state.value = value;
|
||||
}),
|
||||
getModel: vi.fn(() => ({
|
||||
getValue: () => state.value,
|
||||
setValue: (value: string) => {
|
||||
state.value = value;
|
||||
},
|
||||
getValueInRange: () => '',
|
||||
getLineContent: () => '',
|
||||
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1 }),
|
||||
})),
|
||||
getSelection: vi.fn(() => null),
|
||||
addAction: vi.fn(),
|
||||
onDidChangeModelContent: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
hasTextFocus: vi.fn(() => true),
|
||||
};
|
||||
return state;
|
||||
});
|
||||
|
||||
vi.mock('../store', () => {
|
||||
const useStore = Object.assign(
|
||||
(selector: (state: typeof storeState) => any) => selector(storeState),
|
||||
{ getState: () => storeState },
|
||||
);
|
||||
return { useStore };
|
||||
});
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => backendApp);
|
||||
|
||||
vi.mock('../utils/autoFetchVisibility', () => ({
|
||||
useAutoFetchVisibility: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: ({ defaultValue, onMount }: any) => {
|
||||
React.useEffect(() => {
|
||||
editorState.value = String(defaultValue || '');
|
||||
onMount?.(editorState.editor, {
|
||||
editor: { setTheme: vi.fn() },
|
||||
languages: {
|
||||
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
|
||||
registerCompletionItemProvider: vi.fn(),
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
return <textarea data-editor value={editorState.value} readOnly />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./DataGrid', () => ({
|
||||
default: () => null,
|
||||
GONAVI_ROW_KEY: '__gonavi_row_key__',
|
||||
}));
|
||||
|
||||
vi.mock('@ant-design/icons', () => {
|
||||
const Icon = () => <span />;
|
||||
return {
|
||||
PlayCircleOutlined: Icon,
|
||||
SaveOutlined: Icon,
|
||||
FormatPainterOutlined: Icon,
|
||||
SettingOutlined: Icon,
|
||||
CloseOutlined: Icon,
|
||||
StopOutlined: Icon,
|
||||
RobotOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('antd', () => {
|
||||
const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => (
|
||||
<button type="button" disabled={disabled || loading} onClick={onClick} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
Button.Group = ({ children }: any) => <div>{children}</div>;
|
||||
|
||||
const Form: any = ({ children }: any) => <form>{children}</form>;
|
||||
Form.Item = ({ children }: any) => <>{children}</>;
|
||||
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
|
||||
|
||||
return {
|
||||
Button,
|
||||
message: messageApi,
|
||||
Modal: ({ children, open }: any) => (open ? <section>{children}</section> : null),
|
||||
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
|
||||
Form,
|
||||
Dropdown: ({ children }: any) => <>{children}</>,
|
||||
Tooltip: ({ children }: any) => <>{children}</>,
|
||||
Select: () => null,
|
||||
Tabs: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
const textContent = (node: any): string =>
|
||||
(node.children || [])
|
||||
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
|
||||
.join('');
|
||||
|
||||
const findButton = (renderer: ReactTestRenderer, text: string) =>
|
||||
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
|
||||
|
||||
const createTab = (overrides: Partial<TabData> = {}): TabData => ({
|
||||
id: 'tab-1',
|
||||
title: 'query.sql',
|
||||
type: 'query',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
query: 'select 1;',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('QueryEditor external SQL save', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
storeState.addTab.mockReset();
|
||||
storeState.saveQuery.mockReset();
|
||||
storeState.savedQueries = [];
|
||||
storeState.activeTabId = 'tab-1';
|
||||
messageApi.success.mockReset();
|
||||
messageApi.error.mockReset();
|
||||
backendApp.WriteSQLFile.mockResolvedValue({ success: true });
|
||||
editorState.value = '';
|
||||
editorState.editor.getValue.mockClear();
|
||||
editorState.editor.setValue.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('writes external SQL file tabs back to disk without creating saved queries', async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
|
||||
});
|
||||
|
||||
editorState.value = 'select 2;';
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '保存').props.onClick();
|
||||
});
|
||||
|
||||
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 2;');
|
||||
expect(storeState.saveQuery).not.toHaveBeenCalled();
|
||||
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
|
||||
filePath,
|
||||
query: 'select 2;',
|
||||
savedQueryId: undefined,
|
||||
}));
|
||||
expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已保存!');
|
||||
});
|
||||
|
||||
it('does not create saved queries when external SQL file writes fail', async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
|
||||
backendApp.WriteSQLFile.mockResolvedValueOnce({ success: false, message: '磁盘只读' });
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
|
||||
});
|
||||
|
||||
editorState.value = 'select 4;';
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '保存').props.onClick();
|
||||
});
|
||||
|
||||
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 4;');
|
||||
expect(storeState.saveQuery).not.toHaveBeenCalled();
|
||||
expect(storeState.addTab).not.toHaveBeenCalled();
|
||||
expect(messageApi.error).toHaveBeenCalledWith('保存 SQL 文件失败: 磁盘只读');
|
||||
});
|
||||
|
||||
it('keeps saved query quick-save behavior for non-file tabs', async () => {
|
||||
storeState.savedQueries = [
|
||||
{
|
||||
id: 'saved-1',
|
||||
name: '常用查询',
|
||||
sql: 'select 1;',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
createdAt: 100,
|
||||
},
|
||||
];
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
|
||||
});
|
||||
|
||||
editorState.value = 'select 3;';
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, '保存').props.onClick();
|
||||
});
|
||||
|
||||
expect(backendApp.WriteSQLFile).not.toHaveBeenCalled();
|
||||
expect(storeState.saveQuery).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'saved-1',
|
||||
name: '常用查询',
|
||||
sql: 'select 3;',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
createdAt: 100,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -6,13 +6,14 @@ import { format } from 'sql-formatter';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { TabData, ColumnDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App';
|
||||
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
|
||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
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,
|
||||
@@ -2196,7 +2204,31 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return saved;
|
||||
};
|
||||
|
||||
const handleQuickSave = () => {
|
||||
const handleQuickSave = async () => {
|
||||
const filePath = String(tab.filePath || '').trim();
|
||||
if (filePath) {
|
||||
const sql = getCurrentQuery();
|
||||
try {
|
||||
const res = await WriteSQLFile(filePath, sql);
|
||||
if (!res.success) {
|
||||
message.error('保存 SQL 文件失败: ' + (res.message || '未知错误'));
|
||||
return;
|
||||
}
|
||||
addTab({
|
||||
...tab,
|
||||
query: sql,
|
||||
connectionId: currentConnectionId,
|
||||
dbName: currentDb || tab.dbName || '',
|
||||
filePath,
|
||||
savedQueryId: undefined,
|
||||
});
|
||||
message.success('SQL 文件已保存!');
|
||||
} catch (error) {
|
||||
message.error('保存 SQL 文件失败: ' + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const existed = currentSavedQuery || null;
|
||||
const fallbackSavedId = String(tab.savedQueryId || '').trim();
|
||||
const saveId = existed?.id || fallbackSavedId || '';
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
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';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import {
|
||||
blurToFilter,
|
||||
isMacLikePlatform,
|
||||
normalizeBlurForPlatform,
|
||||
normalizeOpacityForPlatform,
|
||||
resolveAppearanceValues,
|
||||
resolveTextInputSafeBackdropFilter,
|
||||
} from '../utils/appearance';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
applyRenamedRedisKeyState,
|
||||
@@ -19,6 +27,9 @@ import {
|
||||
type RedisTreeDataNode,
|
||||
} from './redisViewerTree';
|
||||
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput, type RedisSearchMode } from '../utils/redisSearchPattern';
|
||||
import { decodeRedisUtf8Value, formatRedisStringValue, toHexDisplay } from '../utils/redisValueDisplay';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
@@ -39,148 +50,6 @@ interface RedisViewerProps {
|
||||
redisDB: number;
|
||||
}
|
||||
|
||||
// 尝试多种方式解码二进制数据
|
||||
const tryDecodeValue = (value: string): { displayValue: string; encoding: string; needsHex: boolean } => {
|
||||
if (!value || value.length === 0) {
|
||||
return { displayValue: '', encoding: 'UTF-8', needsHex: false };
|
||||
}
|
||||
|
||||
// 统计字节分布
|
||||
let nullCount = 0;
|
||||
let printableCount = 0;
|
||||
let highByteCount = 0;
|
||||
const sampleSize = Math.min(value.length, 200);
|
||||
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
if (code === 0) {
|
||||
nullCount++;
|
||||
} else if (code >= 32 && code < 127) {
|
||||
printableCount++;
|
||||
} else if (code >= 128) {
|
||||
highByteCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果超过30%是null字节,很可能是二进制数据,显示十六进制
|
||||
if (nullCount / sampleSize > 0.3) {
|
||||
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
||||
}
|
||||
|
||||
// 如果超过70%是可打印ASCII字符,直接显示
|
||||
if (printableCount / sampleSize > 0.7) {
|
||||
return { displayValue: value, encoding: 'UTF-8', needsHex: false };
|
||||
}
|
||||
|
||||
// 尝试UTF-8解码
|
||||
if (highByteCount > 0) {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
|
||||
// 检查解码质量
|
||||
let validChars = 0;
|
||||
let replacementChars = 0;
|
||||
let controlChars = 0;
|
||||
|
||||
for (let i = 0; i < Math.min(decoded.length, 200); i++) {
|
||||
const code = decoded.charCodeAt(i);
|
||||
if (code === 0xFFFD) {
|
||||
replacementChars++;
|
||||
} else if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
|
||||
controlChars++;
|
||||
} else if ((code >= 32 && code < 127) || (code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3000 && code <= 0x303F)) {
|
||||
// ASCII可打印字符、中文字符、中文标点
|
||||
validChars++;
|
||||
}
|
||||
}
|
||||
|
||||
const totalChecked = Math.min(decoded.length, 200);
|
||||
|
||||
// 如果替换字符超过10%或控制字符超过20%,说明不是有效的UTF-8文本
|
||||
if (replacementChars / totalChecked > 0.1 || controlChars / totalChecked > 0.2) {
|
||||
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
||||
}
|
||||
|
||||
// 如果有效字符超过50%,使用UTF-8解码
|
||||
if (validChars / totalChecked > 0.5) {
|
||||
return { displayValue: decoded, encoding: 'UTF-8', needsHex: false };
|
||||
}
|
||||
} catch (e) {
|
||||
// UTF-8解码失败
|
||||
}
|
||||
}
|
||||
|
||||
// 默认显示十六进制
|
||||
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
||||
};
|
||||
|
||||
// 检测是否为二进制数据(包含大量不可打印字符)
|
||||
const isBinaryData = (value: string): boolean => {
|
||||
if (!value || value.length === 0) return false;
|
||||
// 检查前 100 个字符中不可打印字符的比例
|
||||
const sampleSize = Math.min(value.length, 100);
|
||||
let nonPrintableCount = 0;
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
// 不可打印字符:控制字符(0-31,除了 9, 10, 13)和 DEL(127)
|
||||
if ((code < 32 && code !== 9 && code !== 10 && code !== 13) || code === 127 || code > 255) {
|
||||
nonPrintableCount++;
|
||||
}
|
||||
}
|
||||
// 如果超过 10% 是不可打印字符,认为是二进制数据
|
||||
return nonPrintableCount / sampleSize > 0.1;
|
||||
};
|
||||
|
||||
// 将字符串转换为十六进制显示
|
||||
const toHexDisplay = (value: string): string => {
|
||||
const bytes: string[] = [];
|
||||
const ascii: string[] = [];
|
||||
let result = '';
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
bytes.push(code.toString(16).padStart(2, '0').toUpperCase());
|
||||
// 可打印 ASCII 字符显示原字符,否则显示点
|
||||
ascii.push(code >= 32 && code < 127 ? value[i] : '.');
|
||||
|
||||
if (bytes.length === 16 || i === value.length - 1) {
|
||||
const offset = (Math.floor(i / 16) * 16).toString(16).padStart(8, '0').toUpperCase();
|
||||
const hexPart = bytes.join(' ').padEnd(47, ' ');
|
||||
const asciiPart = ascii.join('');
|
||||
result += `${offset} ${hexPart} |${asciiPart}|\n`;
|
||||
bytes.length = 0;
|
||||
ascii.length = 0;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// 尝试解析并格式化 JSON
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化字符串值 - 支持 JSON、二进制数据检测和智能解码
|
||||
const formatStringValue = (value: string): { displayValue: string; isBinary: boolean; isJson: boolean; encoding?: string } => {
|
||||
// 先检测是否为二进制数据
|
||||
if (isBinaryData(value)) {
|
||||
const { displayValue, encoding, needsHex } = tryDecodeValue(value);
|
||||
return { displayValue, isBinary: needsHex, isJson: false, encoding };
|
||||
}
|
||||
// 尝试 JSON 格式化
|
||||
const { isJson, formatted } = tryFormatJson(value);
|
||||
return { displayValue: formatted, isBinary: false, isJson, encoding: 'UTF-8' };
|
||||
};
|
||||
|
||||
// 可拖拽分隔条组件 - 使用直接 DOM 操作避免卡顿
|
||||
const ResizableDivider: React.FC<{
|
||||
onResizeEnd: (newWidth: number) => void;
|
||||
@@ -283,8 +152,16 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const blur = normalizeBlurForPlatform(resolvedAppearance.blur);
|
||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||
const connection = connections.find(c => c.id === connectionId);
|
||||
const workbenchTheme = useMemo(() => buildRedisWorkbenchTheme({ darkMode, opacity, blur }), [blur, darkMode, opacity]);
|
||||
const workbenchTheme = useMemo(
|
||||
() => buildRedisWorkbenchTheme({ darkMode, opacity, blur, disableBackdropFilter: disableLocalBackdropFilter }),
|
||||
[blur, darkMode, disableLocalBackdropFilter, opacity],
|
||||
);
|
||||
const workbenchBackdropFilter = useMemo(
|
||||
() => resolveTextInputSafeBackdropFilter(blurToFilter(blur), disableLocalBackdropFilter),
|
||||
[blur, disableLocalBackdropFilter],
|
||||
);
|
||||
const keyAccentColor = workbenchTheme.accent;
|
||||
const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff';
|
||||
const valueToolbarBg = workbenchTheme.panelBgStrong;
|
||||
@@ -293,7 +170,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
|
||||
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);
|
||||
@@ -467,15 +346,37 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
|
||||
}, [redisDB]);
|
||||
}, [loadKeys, redisDB]);
|
||||
|
||||
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, searchMode]);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
const pattern = value.trim() || '*';
|
||||
setSearchPattern(pattern);
|
||||
setCursor('0');
|
||||
loadKeys(pattern, '0', false, getRedisScanLoadCount(pattern, false));
|
||||
executeSearch(value);
|
||||
};
|
||||
|
||||
const handleSearchInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const normalized = normalizeRedisSearchDraftChange(event.target.value, searchMode);
|
||||
setSearchInput(normalized.keyword);
|
||||
if (!normalized.shouldSearchImmediately) {
|
||||
return;
|
||||
}
|
||||
setSearchPattern(normalized.pattern);
|
||||
setCursor('0');
|
||||
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;
|
||||
@@ -1040,6 +941,22 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderValueEditor = () => {
|
||||
const processValueForCurrentView = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
}
|
||||
|
||||
if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
}
|
||||
|
||||
if (viewMode === 'utf8') {
|
||||
return { displayValue: decodeRedisUtf8Value(value), isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
}
|
||||
|
||||
return formatRedisStringValue(value);
|
||||
};
|
||||
|
||||
if (!keyValue || !selectedKey) {
|
||||
return (
|
||||
<div
|
||||
@@ -1061,33 +978,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
const renderStringValue = () => {
|
||||
const strValue = String(keyValue.value);
|
||||
|
||||
// 根据查看模式生成显示内容
|
||||
const getDisplayContent = () => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(strValue), isBinary: true, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: strValue, isBinary: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(strValue.length);
|
||||
for (let i = 0; i < strValue.length; i++) {
|
||||
bytes[i] = strValue.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: strValue, isBinary: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
const { displayValue, isBinary, isJson, encoding } = formatStringValue(strValue);
|
||||
return { displayValue, isBinary, encoding };
|
||||
}
|
||||
};
|
||||
|
||||
const { displayValue, isBinary, encoding } = getDisplayContent();
|
||||
const isJson = viewMode === 'auto' && formatStringValue(strValue).isJson;
|
||||
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(strValue);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
@@ -1146,31 +1037,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderHashValue = () => {
|
||||
// 根据查看模式处理值
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = Object.entries(keyValue.value as Record<string, string>).map(([field, value]) => {
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(value);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value);
|
||||
return { field, value, displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
@@ -1194,7 +1062,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, field);
|
||||
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, [field]);
|
||||
if (res.success) {
|
||||
message.success('删除成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1214,9 +1082,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
title: '添加字段',
|
||||
content: (
|
||||
<Form id="add-hash-field-form" layout="vertical">
|
||||
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
|
||||
<Input id="new-hash-field" />
|
||||
</Form.Item>
|
||||
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
|
||||
<Input id="new-hash-field" {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
<Form.Item label="值" name="value" rules={[{ required: true }]}>
|
||||
<Input.TextArea id="new-hash-value" rows={4} />
|
||||
</Form.Item>
|
||||
@@ -1307,31 +1175,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderListValue = () => {
|
||||
// 根据查看模式处理值
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = (keyValue.value as string[]).map((value, index) => {
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(value);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value);
|
||||
return { index, value, displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
@@ -1477,31 +1322,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderSetValue = () => {
|
||||
// 根据查看模式处理值
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = (keyValue.value as string[]).map((member, index) => {
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(member);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(member);
|
||||
return { index, member, displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
@@ -1614,31 +1436,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderZSetValue = () => {
|
||||
// 根据查看模式处理值
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = (keyValue.value as Array<{ member: string; score: number }>).map((item, index) => {
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(item.member);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(item.member);
|
||||
return { ...item, index, displayMember: displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
@@ -1779,30 +1578,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderStreamValue = () => {
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = (keyValue.value as StreamEntry[]).map((item, index) => {
|
||||
const rawFieldsText = JSON.stringify(item.fields ?? {}, null, 2);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(rawFieldsText);
|
||||
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(rawFieldsText);
|
||||
return {
|
||||
index,
|
||||
id: item.id,
|
||||
@@ -1888,7 +1666,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label>ID(可选,默认 *):</label>
|
||||
<Input id="new-stream-id" placeholder="例如: * 或 1723110000000-0" />
|
||||
<Input id="new-stream-id" {...noAutoCapInputProps} placeholder="例如: * 或 1723110000000-0" />
|
||||
</div>
|
||||
<div>
|
||||
<label>字段 JSON:</label>
|
||||
@@ -2050,7 +1828,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: blurToFilter(blur), WebkitBackdropFilter: blurToFilter(blur) }}>
|
||||
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: workbenchBackdropFilter, WebkitBackdropFilter: workbenchBackdropFilter }}>
|
||||
{/* Left: Key List */}
|
||||
<div ref={leftPanelRef} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
|
||||
<div style={{ ...workbenchCardStyle, padding: 12 }}>
|
||||
@@ -2062,10 +1840,23 @@ 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
|
||||
placeholder="搜索 Key (支持 * 通配符)"
|
||||
defaultValue="*"
|
||||
{...noAutoCapInputProps}
|
||||
style={{ flex: 1 }}
|
||||
placeholder={searchMode === 'exact' ? '输入完整 Key / 命名空间精确搜索' : '搜索 Key(模糊匹配)'}
|
||||
value={searchInput}
|
||||
onChange={handleSearchInputChange}
|
||||
onSearch={handleSearch}
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
/>
|
||||
</Space.Compact>
|
||||
@@ -2152,7 +1943,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
>
|
||||
<Editor
|
||||
height="450px"
|
||||
language={tryFormatJson(editValue).isJson ? 'json' : 'plaintext'}
|
||||
language={formatRedisStringValue(editValue).isJson ? 'json' : 'plaintext'}
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={editValue}
|
||||
onChange={(value) => setEditValue(value || '')}
|
||||
@@ -2177,7 +1968,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
>
|
||||
<Form form={newKeyForm} layout="vertical" initialValues={{ ttl: -1 }}>
|
||||
<Form.Item name="key" label="Key" rules={[{ required: true, message: '请输入 Key' }]}>
|
||||
<Input placeholder="key name" />
|
||||
<Input {...noAutoCapInputProps} placeholder="key name" />
|
||||
</Form.Item>
|
||||
<Form.Item name="value" label="值" rules={[{ required: true, message: '请输入值' }]}>
|
||||
<Input.TextArea rows={4} placeholder="value" />
|
||||
@@ -2207,7 +1998,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
rules={[{ required: true, message: '请输入新的 Key 名称' }]}
|
||||
extra={renameTargetKey ? `原始 Key:${renameTargetKey}` : undefined}
|
||||
>
|
||||
<Input placeholder="new:key:name" />
|
||||
<Input {...noAutoCapInputProps} placeholder="new:key:name" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -36,15 +36,24 @@ 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 } 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 } 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 { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import FindInDatabaseModal from './FindInDatabaseModal';
|
||||
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;
|
||||
|
||||
@@ -55,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' | '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';
|
||||
@@ -94,10 +103,30 @@ const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
|
||||
tag: <TagOutlined />,
|
||||
};
|
||||
|
||||
const normalizeMySQLViewDDLForEditing = (viewName: string, rawDefinition: unknown): string => {
|
||||
const text = String(rawDefinition || '').trim();
|
||||
if (!text) return '';
|
||||
|
||||
const normalized = text.replace(/\r\n/g, '\n').trim().replace(/;+\s*$/, '');
|
||||
const createViewPrefixPattern = /^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:`[^`]+`|\S+)\s*@\s*(?:`[^`]+`|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+/i;
|
||||
if (createViewPrefixPattern.test(normalized)) {
|
||||
return `${normalized.replace(createViewPrefixPattern, 'CREATE OR REPLACE VIEW ')};`;
|
||||
}
|
||||
|
||||
if (/^\s*(select|with)\b/i.test(normalized)) {
|
||||
return `CREATE OR REPLACE VIEW ${viewName} AS\n${normalized};`;
|
||||
}
|
||||
|
||||
return `${normalized};`;
|
||||
};
|
||||
|
||||
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const savedQueries = useStore(state => state.savedQueries);
|
||||
const externalSQLDirectories = useStore(state => state.externalSQLDirectories);
|
||||
const deleteQuery = useStore(state => state.deleteQuery);
|
||||
const saveExternalSQLDirectory = useStore(state => state.saveExternalSQLDirectory);
|
||||
const deleteExternalSQLDirectory = useStore(state => state.deleteExternalSQLDirectory);
|
||||
const addConnection = useStore(state => state.addConnection);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const setActiveContext = useStore(state => state.setActiveContext);
|
||||
@@ -120,6 +149,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const darkMode = theme === 'dark';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||||
|
||||
@@ -133,7 +163,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
};
|
||||
const bgMain = getBg('#141414');
|
||||
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
|
||||
const overlayTheme = useMemo(
|
||||
() => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }),
|
||||
[darkMode, disableLocalBackdropFilter],
|
||||
);
|
||||
const modalPanelStyle = useMemo(() => ({
|
||||
background: overlayTheme.shellBg,
|
||||
border: overlayTheme.shellBorder,
|
||||
@@ -299,25 +332,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh queries for expanded databases
|
||||
const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.key === k) return node;
|
||||
if (node.children) {
|
||||
const res = findNode(node.children, k);
|
||||
if (res) return res;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
expandedKeys.forEach(key => {
|
||||
const node = findNode(treeData, key);
|
||||
const node = findTreeNodeByKey(treeData, key);
|
||||
if (node && node.type === 'database') {
|
||||
loadTables(node);
|
||||
}
|
||||
});
|
||||
}, [autoFetchVisible, savedQueries]);
|
||||
}, [autoFetchVisible, externalSQLDirectories, savedQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
setTreeData((prev) => {
|
||||
@@ -338,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,
|
||||
@@ -406,6 +429,68 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
};
|
||||
|
||||
const findTreeNodeByKey = (nodes: TreeNode[], targetKey: React.Key): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.key === targetKey) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const child = findTreeNodeByKey(node.children, targetKey);
|
||||
if (child) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const decorateExternalSQLTreeNode = (node: ExternalSQLTreeNode): TreeNode => {
|
||||
const icon = (() => {
|
||||
switch (node.type) {
|
||||
case 'external-sql-root':
|
||||
return <FolderOpenOutlined />;
|
||||
case 'external-sql-directory':
|
||||
return <HddOutlined />;
|
||||
case 'external-sql-folder':
|
||||
return <FolderOutlined />;
|
||||
default:
|
||||
return <FileTextOutlined />;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
...node,
|
||||
icon,
|
||||
children: node.children?.map((child) => decorateExternalSQLTreeNode(child)),
|
||||
};
|
||||
};
|
||||
|
||||
const getNodeDatabaseContext = (node: any): { connectionId: string; dbName: string; dbNodeKey: string } | null => {
|
||||
if (!node) return null;
|
||||
if (node.type === 'database') {
|
||||
return {
|
||||
connectionId: String(node?.dataRef?.id || '').trim(),
|
||||
dbName: String(node?.dataRef?.dbName || '').trim(),
|
||||
dbNodeKey: String(node.key || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
node.type === 'external-sql-root'
|
||||
|| node.type === 'external-sql-directory'
|
||||
|| node.type === 'external-sql-folder'
|
||||
|| node.type === 'external-sql-file'
|
||||
) {
|
||||
return {
|
||||
connectionId: String(node?.dataRef?.connectionId || '').trim(),
|
||||
dbName: String(node?.dataRef?.dbName || '').trim(),
|
||||
dbNodeKey: String(node?.dataRef?.dbNodeKey || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const SIDEBAR_SCHEMA_DB_TYPES = new Set([
|
||||
'postgres',
|
||||
'kingbase',
|
||||
@@ -794,7 +879,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name'])
|
||||
|| getMySQLShowTablesName(row)
|
||||
|| getFirstRowValue(row);
|
||||
const fullName = buildQualifiedName(schemaName, viewName);
|
||||
const fullName = normalizeSidebarViewName(dialect, dbName, schemaName, viewName);
|
||||
if (!fullName || seen.has(fullName)) return;
|
||||
seen.add(fullName);
|
||||
views.push(fullName);
|
||||
@@ -889,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 {
|
||||
@@ -963,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;
|
||||
@@ -972,6 +1165,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
loadingNodesRef.current.add(loadKey);
|
||||
|
||||
const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName);
|
||||
const dbExternalSQLDirectories = useStore.getState().externalSQLDirectories.filter(directory => directory.connectionId === conn.id && directory.dbName === dbName);
|
||||
|
||||
const queriesNode: TreeNode = {
|
||||
title: '已存查询',
|
||||
@@ -1013,11 +1207,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
});
|
||||
|
||||
const [viewsResult, triggersResult, routinesResult] = await Promise.all([
|
||||
loadViews(conn, conn.dbName),
|
||||
loadDatabaseTriggers(conn, conn.dbName),
|
||||
loadFunctions(conn, conn.dbName),
|
||||
]);
|
||||
const [viewsResult, triggersResult, routinesResult] = await Promise.all([
|
||||
loadViews(conn, conn.dbName),
|
||||
loadDatabaseTriggers(conn, conn.dbName),
|
||||
loadFunctions(conn, conn.dbName),
|
||||
]);
|
||||
const externalSQLDirectoryResults = await Promise.all(
|
||||
dbExternalSQLDirectories.map(async (directory) => {
|
||||
const directoryRes = await ListSQLDirectory(directory.path);
|
||||
if (!directoryRes.success) {
|
||||
message.warning({
|
||||
key: `external-sql-${directory.id}`,
|
||||
content: `SQL 目录读取失败: ${directory.name} (${directoryRes.message})`,
|
||||
});
|
||||
return { id: directory.id, entries: [] as ExternalSQLTreeEntry[] };
|
||||
}
|
||||
return {
|
||||
id: directory.id,
|
||||
entries: Array.isArray(directoryRes.data) ? directoryRes.data as ExternalSQLTreeEntry[] : [],
|
||||
};
|
||||
}),
|
||||
);
|
||||
const externalSQLTrees = externalSQLDirectoryResults.reduce<Record<string, ExternalSQLTreeEntry[]>>((accumulator, item) => {
|
||||
accumulator[item.id] = item.entries;
|
||||
return accumulator;
|
||||
}, {});
|
||||
const externalSQLRootNode = decorateExternalSQLTreeNode(buildExternalSQLRootNode({
|
||||
dbNodeKey: String(key),
|
||||
connectionId: String(conn.id),
|
||||
dbName: String(conn.dbName),
|
||||
directories: dbExternalSQLDirectories,
|
||||
directoryTrees: externalSQLTrees,
|
||||
}));
|
||||
|
||||
const viewRows: string[] = Array.isArray(viewsResult.views) ? viewsResult.views : [];
|
||||
const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : [];
|
||||
@@ -1201,7 +1422,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
routineEntries.forEach((entry) => getSchemaBucket(entry.schemaName).routines.push(buildRoutineNode(entry)));
|
||||
triggerEntries.forEach((entry) => getSchemaBucket(entry.schemaName).triggers.push(buildTriggerNode(entry)));
|
||||
|
||||
const dialect = getMetadataDialect(conn as SavedConnection);
|
||||
const isOracleLike = (dialect === 'oracle' || dialect === 'dm');
|
||||
|
||||
const schemaNodes: TreeNode[] = Array.from(schemaMap.values())
|
||||
.filter((bucket) => !(isOracleLike && !bucket.schemaName))
|
||||
.sort((a, b) => {
|
||||
if (!a.schemaName && !b.schemaName) return 0;
|
||||
if (!a.schemaName) return -1;
|
||||
@@ -1209,8 +1434,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return a.schemaName.toLowerCase().localeCompare(b.schemaName.toLowerCase());
|
||||
})
|
||||
.map((bucket) => {
|
||||
const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`;
|
||||
const schemaTitle = bucket.schemaName || '默认模式';
|
||||
const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`;
|
||||
const schemaTitle = bucket.schemaName || '默认模式';
|
||||
const groupedNodes: TreeNode[] = [
|
||||
buildObjectGroup(schemaNodeKey, 'tables', '表', <TableOutlined />, bucket.tables, { schemaName: bucket.schemaName }),
|
||||
buildObjectGroup(schemaNodeKey, 'views', '视图', <EyeOutlined />, bucket.views, { schemaName: bucket.schemaName }),
|
||||
@@ -1229,7 +1454,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
});
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...schemaNodes]));
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...schemaNodes]));
|
||||
} else {
|
||||
const groupedNodes: TreeNode[] = [
|
||||
buildObjectGroup(key as string, 'tables', '表', <TableOutlined />, tableEntries.map(buildTableNode)),
|
||||
@@ -1238,7 +1463,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
buildObjectGroup(key as string, 'triggers', '触发器', <FunctionOutlined />, triggerEntries.map(buildTriggerNode)),
|
||||
];
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes]));
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...groupedNodes]));
|
||||
}
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
|
||||
@@ -1258,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') {
|
||||
@@ -1350,10 +1577,14 @@ 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') {
|
||||
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 });
|
||||
} else if (type === 'redis-db') {
|
||||
setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
|
||||
}
|
||||
@@ -1394,8 +1625,10 @@ 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 });
|
||||
else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
|
||||
|
||||
if (node.type === 'table') {
|
||||
@@ -1434,6 +1667,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
savedQueryId: q.id,
|
||||
});
|
||||
return;
|
||||
} else if (node.type === 'external-sql-file') {
|
||||
void openExternalSQLFile(node);
|
||||
return;
|
||||
} else if (node.type === 'redis-db') {
|
||||
const { id, redisDB } = node.dataRef;
|
||||
addTab({
|
||||
@@ -1468,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;
|
||||
@@ -2110,6 +2365,120 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
};
|
||||
|
||||
const refreshDatabaseNode = async (dbNodeKey: string) => {
|
||||
if (!dbNodeKey) {
|
||||
return;
|
||||
}
|
||||
const dbNode = findTreeNodeByKey(treeData, dbNodeKey);
|
||||
if (dbNode && dbNode.type === 'database') {
|
||||
await loadTables(dbNode);
|
||||
}
|
||||
};
|
||||
|
||||
const openExternalSQLFile = async (fileNode: any) => {
|
||||
const connectionId = String(fileNode?.dataRef?.connectionId || '').trim();
|
||||
const dbName = String(fileNode?.dataRef?.dbName || '').trim();
|
||||
const filePath = String(fileNode?.dataRef?.path || '').trim();
|
||||
const fileName = String(fileNode?.dataRef?.name || fileNode?.title || 'SQL文件').trim() || 'SQL文件';
|
||||
if (!connectionId || !dbName || !filePath) {
|
||||
message.error('SQL 文件上下文不完整,无法打开');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await ReadSQLFile(filePath);
|
||||
if (!res.success) {
|
||||
if (res.message !== '已取消') {
|
||||
message.error('读取 SQL 文件失败: ' + res.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
if (data && typeof data === 'object' && data.isLargeFile) {
|
||||
const conn = connections.find((item) => item.id === connectionId);
|
||||
if (!conn) {
|
||||
message.error('未找到对应的连接配置');
|
||||
return;
|
||||
}
|
||||
startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB);
|
||||
return;
|
||||
}
|
||||
|
||||
addTab({
|
||||
id: buildExternalSQLTabId(connectionId, dbName, filePath),
|
||||
title: fileName,
|
||||
type: 'query',
|
||||
connectionId,
|
||||
dbName,
|
||||
query: String(data || ''),
|
||||
filePath,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddExternalSQLDirectory = async (node: any) => {
|
||||
const context = getNodeDatabaseContext(node);
|
||||
if (!context?.connectionId || !context?.dbName || !context?.dbNodeKey) {
|
||||
message.warning('请在具体数据库下添加外部 SQL 目录');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDirectory = externalSQLDirectories.find((item) =>
|
||||
item.connectionId === context.connectionId && item.dbName === context.dbName,
|
||||
)?.path || '';
|
||||
const selection = await SelectSQLDirectory(currentDirectory);
|
||||
if (!selection.success) {
|
||||
if (selection.message !== '已取消') {
|
||||
message.error('选择 SQL 目录失败: ' + selection.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (selection.data && typeof selection.data === 'object') ? selection.data as Record<string, unknown> : {};
|
||||
const path = String(payload.path || '').trim();
|
||||
const name = String(payload.name || '').trim();
|
||||
if (!path) {
|
||||
message.error('未获取到有效的 SQL 目录路径');
|
||||
return;
|
||||
}
|
||||
|
||||
const directoryId = buildExternalSQLDirectoryId(context.connectionId, context.dbName, path);
|
||||
saveExternalSQLDirectory({
|
||||
id: directoryId,
|
||||
name: name || path.split(/[\\/]/).filter(Boolean).pop() || 'SQL目录',
|
||||
path,
|
||||
connectionId: context.connectionId,
|
||||
dbName: context.dbName,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
setExpandedKeys((prev) => Array.from(new Set([...prev, context.dbNodeKey, `${context.dbNodeKey}-external-sql`])));
|
||||
setAutoExpandParent(false);
|
||||
await refreshDatabaseNode(context.dbNodeKey);
|
||||
message.success('外部 SQL 目录已添加');
|
||||
};
|
||||
|
||||
const handleRemoveExternalSQLDirectory = async (node: any) => {
|
||||
const directoryId = String(node?.dataRef?.id || '').trim();
|
||||
const dbNodeKey = String(node?.dataRef?.dbNodeKey || '').trim();
|
||||
if (!directoryId) {
|
||||
message.error('未找到可移除的 SQL 目录');
|
||||
return;
|
||||
}
|
||||
deleteExternalSQLDirectory(directoryId);
|
||||
await refreshDatabaseNode(dbNodeKey);
|
||||
message.success('外部 SQL 目录已移除');
|
||||
};
|
||||
|
||||
const handleRefreshExternalSQLDirectory = async (node: any) => {
|
||||
const dbNodeKey = String(node?.dataRef?.dbNodeKey || '').trim();
|
||||
if (!dbNodeKey) {
|
||||
message.warning('当前目录缺少数据库上下文,无法刷新');
|
||||
return;
|
||||
}
|
||||
await refreshDatabaseNode(dbNodeKey);
|
||||
message.success('外部 SQL 目录已刷新');
|
||||
};
|
||||
|
||||
const handleCreateDatabase = async () => {
|
||||
try {
|
||||
const values = await createDbForm.validateFields();
|
||||
@@ -2140,7 +2509,88 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => {
|
||||
return buildRpcConnectionConfig(conn.config, {
|
||||
database: clearDatabase ? '' : ((overrideDatabase ?? conn.config.database) || ''),
|
||||
database: resolveSidebarRuntimeDatabase(
|
||||
conn?.config?.type,
|
||||
conn?.config?.driver,
|
||||
conn?.config?.database,
|
||||
overrideDatabase,
|
||||
clearDatabase,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2408,7 +2858,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const row = result.data[0] as Record<string, any>;
|
||||
const def = row.view_definition || row.VIEW_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
|
||||
if (def) {
|
||||
template = `-- 编辑视图 ${viewName}\nCREATE OR REPLACE VIEW ${viewName} AS\n${def}`;
|
||||
if (dialect === 'mysql') {
|
||||
template = `-- 编辑视图 ${viewName}\n${normalizeMySQLViewDDLForEditing(viewName, def)}`;
|
||||
} else {
|
||||
template = `-- 编辑视图 ${viewName}\nCREATE OR REPLACE VIEW ${viewName} AS\n${def}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2826,51 +3280,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
);
|
||||
}, [darkMode, overlayTheme, searchScopes]);
|
||||
|
||||
const parseHostOnlyToken = (value: unknown): string[] => {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
let text = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
|
||||
if (text.includes('/')) {
|
||||
text = text.split('/')[0];
|
||||
}
|
||||
if (text.includes('?')) {
|
||||
text = text.split('?')[0];
|
||||
}
|
||||
if (text.includes('@')) {
|
||||
text = text.split('@').pop() || '';
|
||||
}
|
||||
return text
|
||||
.split(',')
|
||||
.map((entry) => {
|
||||
const token = entry.trim();
|
||||
if (!token) return '';
|
||||
if (token.startsWith('[')) {
|
||||
const rightBracketIndex = token.indexOf(']');
|
||||
if (rightBracketIndex > 0) {
|
||||
return token.slice(0, rightBracketIndex + 1).toLowerCase();
|
||||
}
|
||||
}
|
||||
const colonIndex = token.lastIndexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
return token.slice(0, colonIndex).toLowerCase();
|
||||
}
|
||||
return token.toLowerCase();
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const getConnectionHostSearchText = (node: TreeNode): string => {
|
||||
if (node.type !== 'connection') return '';
|
||||
const config = node.dataRef?.config || {};
|
||||
const hostTokens = [
|
||||
...parseHostOnlyToken(config.host),
|
||||
...(Array.isArray(config.hosts) ? config.hosts.flatMap((entry: string) => parseHostOnlyToken(entry)) : []),
|
||||
...parseHostOnlyToken(config.uri),
|
||||
];
|
||||
const uniqueHosts = Array.from(new Set(hostTokens));
|
||||
return uniqueHosts.join(' ');
|
||||
return resolveConnectionHostTokens(config).join(' ');
|
||||
};
|
||||
|
||||
const getConnectionNameSearchText = (node: TreeNode): string => {
|
||||
@@ -3078,7 +3491,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `redis-cmd-${node.key}-${Date.now()}`,
|
||||
title: `命令 - ${node.title}`,
|
||||
title: '命令 - db0',
|
||||
type: 'redis-command',
|
||||
connectionId: node.key,
|
||||
redisDB: 0
|
||||
@@ -3092,7 +3505,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `redis-monitor-${node.key}-${Date.now()}`,
|
||||
title: `监控: ${node.title}`,
|
||||
title: '监控 - db0',
|
||||
type: 'redis-monitor',
|
||||
connectionId: node.key,
|
||||
redisDB: 0
|
||||
@@ -3354,7 +3767,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
onClick: () => {
|
||||
addTab({
|
||||
id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`,
|
||||
title: `监控: ${connections.find(c => c.id === id)?.name || id}`,
|
||||
title: `监控 - db${redisDB}`,
|
||||
type: 'redis-monitor',
|
||||
connectionId: id,
|
||||
redisDB: redisDB
|
||||
@@ -3559,7 +3972,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => {
|
||||
const tableName = String(node.dataRef?.tableName || '').trim();
|
||||
const queryTemplate = tableName ? `SELECT * FROM ${tableName};` : 'SELECT * FROM ';
|
||||
const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName);
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: `新建查询`,
|
||||
@@ -3692,6 +4105,55 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
];
|
||||
}
|
||||
|
||||
if (node.type === 'external-sql-root') {
|
||||
return [
|
||||
{
|
||||
key: 'add-external-sql-directory',
|
||||
label: '添加 SQL 目录',
|
||||
icon: <PlusOutlined />,
|
||||
onClick: () => {
|
||||
void handleAddExternalSQLDirectory(node);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (node.type === 'external-sql-directory') {
|
||||
return [
|
||||
{
|
||||
key: 'refresh-external-sql-directory',
|
||||
label: '刷新目录',
|
||||
icon: <ReloadOutlined />,
|
||||
onClick: () => {
|
||||
void handleRefreshExternalSQLDirectory(node);
|
||||
}
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'remove-external-sql-directory',
|
||||
label: '移除目录',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
void handleRemoveExternalSQLDirectory(node);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (node.type === 'external-sql-file') {
|
||||
return [
|
||||
{
|
||||
key: 'open-external-sql-file',
|
||||
label: '打开 SQL 文件',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => {
|
||||
void openExternalSQLFile(node);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -3717,6 +4179,50 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
hoverTitle = rawTableName;
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'external-sql-directory' || node.type === 'external-sql-folder' || node.type === 'external-sql-file') {
|
||||
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
|
||||
title={hoverTitle}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, width: '100%' }}
|
||||
>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{statusBadge}
|
||||
{displayTitle}
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
title="添加外部 SQL 目录"
|
||||
aria-label="添加外部 SQL 目录"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleAddExternalSQLDirectory(node);
|
||||
}}
|
||||
style={{ paddingInline: 4, height: 20 }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span title={hoverTitle}>{statusBadge}{displayTitle}</span>;
|
||||
@@ -3803,6 +4309,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '8px 14px', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
ref={searchInputRef}
|
||||
placeholder="搜索..."
|
||||
onChange={onSearch}
|
||||
@@ -3994,7 +4501,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
>
|
||||
<Form form={createDbForm} layout="vertical">
|
||||
<Form.Item name="name" label="数据库名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input />
|
||||
<Input {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
{/* Charset option could be added here */}
|
||||
</Form>
|
||||
@@ -4012,7 +4519,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
>
|
||||
<Form form={renameDbForm} layout="vertical">
|
||||
<Form.Item name="newName" label="新数据库名称" rules={[{ required: true, message: '请输入新数据库名称' }]}>
|
||||
<Input />
|
||||
<Input {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -4029,7 +4536,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
>
|
||||
<Form form={renameTableForm} layout="vertical">
|
||||
<Form.Item name="newName" label="新表名" rules={[{ required: true, message: '请输入新表名' }]}>
|
||||
<Input />
|
||||
<Input {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -4046,7 +4553,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
>
|
||||
<Form form={renameViewForm} layout="vertical">
|
||||
<Form.Item name="newName" label="新视图名" rules={[{ required: true, message: '请输入新视图名' }]}>
|
||||
<Input />
|
||||
<Input {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -16,43 +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';
|
||||
|
||||
const detectConnectionEnvLabel = (connectionName: string): string | null => {
|
||||
const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
||||
if (tokens.includes('prod') || tokens.includes('production')) return 'PROD';
|
||||
if (tokens.includes('uat')) return 'UAT';
|
||||
if (tokens.includes('dev') || tokens.includes('development')) return 'DEV';
|
||||
if (tokens.includes('sit')) return 'SIT';
|
||||
if (tokens.includes('stg') || tokens.includes('stage') || tokens.includes('staging') || tokens.includes('pre')) return 'STG';
|
||||
if (tokens.includes('test') || tokens.includes('qa')) return 'TEST';
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
|
||||
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') return tab.title;
|
||||
if (!connectionName) return tab.title;
|
||||
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
|
||||
return `[${prefix}] ${tab.title}`;
|
||||
};
|
||||
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="拖拽调整标签顺序"
|
||||
title={displayTitle}
|
||||
style={labelStyle}
|
||||
>
|
||||
{displayTitle}
|
||||
{accentColor ? <span className="tab-connection-accent" aria-hidden="true" /> : null}
|
||||
<span className="tab-title-text">{displayTitle}</span>
|
||||
</span>
|
||||
</Dropdown>
|
||||
);
|
||||
@@ -198,8 +195,9 @@ const TabManager: React.FC = () => {
|
||||
);
|
||||
|
||||
const items = useMemo(() => tabs.map((tab, index) => {
|
||||
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
|
||||
const displayTitle = buildTabDisplayTitle(tab, connectionName);
|
||||
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') {
|
||||
@@ -220,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'] = [
|
||||
@@ -255,6 +263,7 @@ const TabManager: React.FC = () => {
|
||||
<SortableTabLabel
|
||||
displayTitle={displayTitle}
|
||||
menuItems={menuItems}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
),
|
||||
key: tab.id,
|
||||
@@ -319,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;
|
||||
@@ -337,6 +364,10 @@ const TabManager: React.FC = () => {
|
||||
box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32);
|
||||
background: rgba(9, 109, 217, 0.08);
|
||||
}
|
||||
body[data-theme='light'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||
background: rgba(24, 144, 255, 0.10) !important;
|
||||
border-color: rgba(24, 144, 255, 0.28) !important;
|
||||
}
|
||||
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||
background: rgba(255, 214, 102, 0.12) !important;
|
||||
border-color: rgba(255, 214, 102, 0.4) !important;
|
||||
|
||||
@@ -9,8 +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 } 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;
|
||||
@@ -539,6 +551,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
// Initial Columns Definition
|
||||
useEffect(() => {
|
||||
const columnTypeOptions = resolveColumnTypeOptions(getDbType());
|
||||
const initialCols = [
|
||||
{
|
||||
title: '名',
|
||||
@@ -546,7 +559,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
key: 'name',
|
||||
width: 180,
|
||||
render: (text: string, record: EditableColumn) => readOnly ? text : (
|
||||
<Input value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
|
||||
<Input {...noAutoCapInputProps} value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -555,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" />
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -635,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;
|
||||
@@ -846,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 => {
|
||||
@@ -864,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
|
||||
@@ -896,6 +904,7 @@ BEGIN
|
||||
-- 触发器逻辑
|
||||
END;`;
|
||||
case 'oracle':
|
||||
case 'dameng':
|
||||
case 'dm':
|
||||
return `CREATE OR REPLACE TRIGGER trigger_name
|
||||
BEFORE INSERT ON "${tblName}"
|
||||
@@ -921,6 +930,8 @@ END;`;
|
||||
|
||||
switch (dbType) {
|
||||
case 'mysql':
|
||||
case 'mariadb':
|
||||
case 'diros':
|
||||
return `DROP TRIGGER IF EXISTS \`${triggerName}\``;
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
@@ -930,6 +941,7 @@ END;`;
|
||||
case 'sqlserver':
|
||||
return `DROP TRIGGER IF EXISTS [${triggerName}]`;
|
||||
case 'oracle':
|
||||
case 'dameng':
|
||||
case 'dm':
|
||||
return `DROP TRIGGER "${triggerName}"`;
|
||||
case 'sqlite':
|
||||
@@ -1333,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 = () => {
|
||||
@@ -1395,6 +1391,19 @@ ${selectedTrigger.statement}`;
|
||||
};
|
||||
};
|
||||
|
||||
const hasUnsavedDraftChanges = useMemo(() => {
|
||||
if (isNewTable || readOnly) {
|
||||
return false;
|
||||
}
|
||||
const tableInfo = resolveTableInfo();
|
||||
return hasAlterTableDraftChanges({
|
||||
dbType: tableInfo.dbType,
|
||||
tableName: tableInfo.qualifiedName,
|
||||
originalColumns,
|
||||
columns,
|
||||
});
|
||||
}, [columns, connections, isNewTable, originalColumns, readOnly, tab.connectionId, tab.dbName, tab.tableName]);
|
||||
|
||||
const supportsIndexSchemaOps = (): boolean => {
|
||||
const dbType = getDbType();
|
||||
if (!dbType) return false;
|
||||
@@ -1467,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 = () => {
|
||||
@@ -2142,6 +2145,24 @@ END;`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshDesigner = () => {
|
||||
if (!hasUnsavedDraftChanges) {
|
||||
void fetchData();
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '存在未保存的字段变更',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '刷新后会丢失当前尚未保存的字段调整,是否仍要刷新并覆盖当前草稿?',
|
||||
okText: '仍然刷新',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await fetchData();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExecuteSave = async () => {
|
||||
const result = await executeSchemaStatements(previewSql);
|
||||
if (!result.ok) {
|
||||
@@ -2492,6 +2513,7 @@ END;`;
|
||||
{isNewTable && (
|
||||
<>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="请输入表名"
|
||||
value={newTableName}
|
||||
onChange={e => setNewTableName(e.target.value)}
|
||||
@@ -2517,7 +2539,7 @@ END;`;
|
||||
</>
|
||||
)}
|
||||
{!readOnly && <Button size="small" icon={<SaveOutlined />} type="primary" onClick={generateDDL}>保存</Button>}
|
||||
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={fetchData}>刷新</Button>}
|
||||
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={handleRefreshDesigner}>刷新</Button>}
|
||||
{!isNewTable && !readOnly && supportsTableCommentOps() && (
|
||||
<Button size="small" icon={<EditOutlined />} onClick={openTableCommentModal}>表备注</Button>
|
||||
)}
|
||||
@@ -2805,6 +2827,7 @@ END;`;
|
||||
已选择字段:{selectedColumns.length}
|
||||
</div>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="请输入目标表名"
|
||||
value={copyTableName}
|
||||
onChange={e => setCopyTableName(e.target.value)}
|
||||
@@ -2865,6 +2888,7 @@ END;`;
|
||||
>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={indexForm.kind === 'PRIMARY' ? '主键索引固定名称:PRIMARY' : '索引名(例如 idx_user_name)'}
|
||||
value={indexForm.name}
|
||||
onChange={(e) => setIndexForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
@@ -2934,6 +2958,7 @@ END;`;
|
||||
>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="外键约束名(例如 fk_order_user)"
|
||||
value={foreignKeyForm.constraintName}
|
||||
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, constraintName: e.target.value }))}
|
||||
@@ -2949,6 +2974,7 @@ END;`;
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="参考表(支持 db.table)"
|
||||
value={foreignKeyForm.refTableName}
|
||||
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, refTableName: e.target.value }))}
|
||||
@@ -2977,11 +3003,7 @@ END;`;
|
||||
okText="执行"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
<pre style={{ background: darkMode ? '#1e1e1e' : '#f5f5f5', color: darkMode ? '#d4d4d4' : 'inherit', padding: '10px', borderRadius: '4px', border: darkMode ? '1px solid #333' : '1px solid #eee', whiteSpace: 'pre-wrap' }}>
|
||||
{previewSql}
|
||||
</pre>
|
||||
</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,12 +1,22 @@
|
||||
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';
|
||||
import type { TabData } from '../types';
|
||||
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
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;
|
||||
@@ -23,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 => {
|
||||
@@ -55,10 +65,23 @@ const getMetadataDialect = (connType: string, driver?: string): string => {
|
||||
};
|
||||
|
||||
const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => {
|
||||
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
|
||||
switch (dialect) {
|
||||
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
|
||||
switch (dialect) {
|
||||
case 'mysql':
|
||||
return `SHOW TABLE STATUS FROM \`${dbName.replace(/`/g, '``')}\``;
|
||||
return `
|
||||
SELECT
|
||||
TABLE_NAME AS table_name,
|
||||
TABLE_COMMENT AS table_comment,
|
||||
TABLE_ROWS AS table_rows,
|
||||
DATA_LENGTH AS data_length,
|
||||
INDEX_LENGTH AS index_length,
|
||||
ENGINE AS engine,
|
||||
CREATE_TIME AS create_time,
|
||||
UPDATE_TIME AS update_time
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = '${escapeLiteral(dbName)}'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name`;
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'vastbase':
|
||||
@@ -151,8 +174,15 @@ 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(
|
||||
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver),
|
||||
[connection?.config?.driver, connection?.config?.type]
|
||||
);
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -167,11 +197,10 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
useSSH: connection.config.useSSH || false,
|
||||
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
|
||||
};
|
||||
const dialect = getMetadataDialect(connection.config.type, connection.config.driver);
|
||||
const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
|
||||
const sql = buildTableStatusSQL(metadataDialect, tab.dbName || '', (tab as any).schemaName);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
setTables(parseTableStats(dialect, res.data));
|
||||
setTables(parseTableStats(metadataDialect, res.data));
|
||||
} else {
|
||||
message.error('获取表信息失败: ' + (res.message || '未知错误'));
|
||||
}
|
||||
@@ -180,7 +209,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [connection, tab.dbName]);
|
||||
}, [connection, metadataDialect, tab.dbName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFetchVisible) {
|
||||
@@ -189,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;
|
||||
@@ -331,6 +360,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
title: '重命名表',
|
||||
content: (
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
defaultValue={tableName}
|
||||
onChange={e => { newName = e.target.value; }}
|
||||
placeholder="输入新表名"
|
||||
@@ -378,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) {
|
||||
@@ -404,6 +434,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="搜索表名或注释..."
|
||||
prefix={<SearchOutlined style={{ color: textMuted }} />}
|
||||
value={searchText}
|
||||
@@ -448,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' ? (
|
||||
@@ -457,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']}
|
||||
@@ -471,7 +527,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
query: `SELECT * FROM ${t.name};`,
|
||||
query: buildTableSelectQuery(metadataDialect, t.name),
|
||||
});
|
||||
}},
|
||||
{ type: 'divider' },
|
||||
@@ -536,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%';
|
||||
@@ -557,7 +613,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
query: `SELECT * FROM ${t.name};`,
|
||||
query: buildTableSelectQuery(metadataDialect, t.name),
|
||||
});
|
||||
}},
|
||||
{ type: 'divider' },
|
||||
@@ -675,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>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('AIChatInput notice layout', () => {
|
||||
activeProvider={{ model: '', models: [] }}
|
||||
dynamicModels={[]}
|
||||
loadingModels={false}
|
||||
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
|
||||
composerNotice={{
|
||||
tone: 'error',
|
||||
title: '模型列表加载失败',
|
||||
@@ -58,4 +59,35 @@ describe('AIChatInput notice layout', () => {
|
||||
expect(inputIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(noticeIndex).toBeLessThan(inputIndex);
|
||||
});
|
||||
|
||||
it('renders the selected send shortcut in the composer placeholder', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AIChatInput
|
||||
input=""
|
||||
setInput={() => {}}
|
||||
draftImages={[]}
|
||||
setDraftImages={() => {}}
|
||||
sending={false}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
handleKeyDown={() => {}}
|
||||
activeConnName=""
|
||||
activeContext={null}
|
||||
activeProvider={{ model: '', models: [] }}
|
||||
dynamicModels={[]}
|
||||
loadingModels={false}
|
||||
sendShortcutBinding={{ combo: 'Meta+Enter', enabled: true }}
|
||||
composerNotice={null}
|
||||
onModelChange={() => {}}
|
||||
onFetchModels={() => {}}
|
||||
textareaRef={React.createRef<HTMLTextAreaElement>()}
|
||||
darkMode={false}
|
||||
textColor="#162033"
|
||||
mutedColor="rgba(16,24,40,0.55)"
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(markup).toContain('Meta+Enter 发送');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,13 @@ import React from 'react';
|
||||
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
|
||||
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { useStore } from '../../store';
|
||||
import { DBGetTables, DBShowCreateTable, DBGetDatabases } from '../../../wailsjs/go/app/App';
|
||||
import { DBGetTables, DBShowCreateTable, DBGetDatabases, DBGetColumns } from '../../../wailsjs/go/app/App';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
|
||||
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
|
||||
import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
|
||||
import { getAIChatSendShortcutLabel } from '../../utils/aiChatSendShortcut';
|
||||
import type { ShortcutBinding } from '../../utils/shortcuts';
|
||||
|
||||
interface AIChatInputProps {
|
||||
input: string;
|
||||
@@ -21,6 +24,7 @@ interface AIChatInputProps {
|
||||
activeProvider: any;
|
||||
dynamicModels: string[];
|
||||
loadingModels: boolean;
|
||||
sendShortcutBinding: ShortcutBinding;
|
||||
composerNotice?: AIComposerNotice | null;
|
||||
onModelChange: (val: string) => void;
|
||||
onFetchModels: () => void;
|
||||
@@ -36,7 +40,7 @@ interface AIChatInputProps {
|
||||
export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
|
||||
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
|
||||
composerNotice,
|
||||
sendShortcutBinding, composerNotice,
|
||||
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
|
||||
contextUsageChars, maxContextChars
|
||||
}) => {
|
||||
@@ -202,24 +206,21 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
if (activeContextItems.find(c => c.dbName === dbName && c.tableName === tableName)) {
|
||||
continue;
|
||||
}
|
||||
const res = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, dbName, tableName);
|
||||
let createSql = '';
|
||||
if (res.success && res.data) {
|
||||
if (typeof res.data === 'string') {
|
||||
createSql = res.data;
|
||||
} else if (Array.isArray(res.data) && res.data.length > 0) {
|
||||
const row = res.data[0];
|
||||
createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string;
|
||||
}
|
||||
} else {
|
||||
message.error(`获取表 ${dbName}.${tableName} 结构失败: ` + (res.message || '未知错误'));
|
||||
const rpcConfig = buildRpcConnectionConfig(conn.config) as any;
|
||||
const schemaResult = await resolveAITableSchemaToolResult({
|
||||
tableName,
|
||||
fetchDDL: () => DBShowCreateTable(rpcConfig, dbName, tableName),
|
||||
fetchColumns: () => DBGetColumns(rpcConfig, dbName, tableName),
|
||||
});
|
||||
if (!schemaResult.success) {
|
||||
message.error(`获取表 ${dbName}.${tableName} 结构失败: ${schemaResult.content}`);
|
||||
}
|
||||
|
||||
if (createSql) {
|
||||
|
||||
if (schemaResult.success && schemaResult.content) {
|
||||
addAIContext(connectionKey, {
|
||||
dbName: dbName,
|
||||
tableName: tableName,
|
||||
ddl: createSql
|
||||
ddl: schemaResult.content
|
||||
});
|
||||
addedCount++;
|
||||
}
|
||||
@@ -381,7 +382,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown as any}
|
||||
placeholder="输入消息... (Enter 发送,Shift+Enter 换行,/ 快捷命令)"
|
||||
placeholder={`输入消息... (${getAIChatSendShortcutLabel(sendShortcutBinding)},Shift+Enter 换行,/ 快捷命令)`}
|
||||
variant="borderless"
|
||||
autoSize={{ minRows: 1, maxRows: 8 }}
|
||||
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
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';
|
||||
import { buildAIReadonlyPreviewSQL } from '../../utils/aiSqlLimit';
|
||||
|
||||
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
|
||||
const remarkPlugins = [remarkGfm];
|
||||
@@ -27,6 +35,7 @@ const MemoizedMarkdown = React.memo(({
|
||||
activeConnectionId?: string;
|
||||
activeDbName?: string;
|
||||
}) => {
|
||||
const normalizedContent = React.useMemo(() => normalizeAiMarkdown(content), [content]);
|
||||
// 缓存 components 对象,避免每次渲染都生成新的函数引用击穿内部子组件的 memo
|
||||
const components = React.useMemo(() => ({
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
@@ -46,7 +55,7 @@ const MemoizedMarkdown = React.memo(({
|
||||
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
|
||||
{content}
|
||||
{normalizedContent}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
@@ -252,7 +261,13 @@ const AIBlockHashRender = ({ match, darkMode, overlayTheme, children, activeConn
|
||||
setPreviewData(null);
|
||||
try {
|
||||
const { DBQuery } = await import('../../../wailsjs/go/app/App');
|
||||
const res = await DBQuery(activeConnectionConfig, activeDbName || '', displayText + ' LIMIT 50');
|
||||
const previewSql = buildAIReadonlyPreviewSQL(
|
||||
activeConnectionConfig?.type || '',
|
||||
displayText,
|
||||
50,
|
||||
activeConnectionConfig?.driver || '',
|
||||
);
|
||||
const res = await DBQuery(activeConnectionConfig, activeDbName || '', previewSql);
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
const rows = res.data as any[];
|
||||
const cols = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||||
@@ -566,6 +581,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;
|
||||
@@ -693,6 +720,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),
|
||||
}));
|
||||
10
frontend/src/components/dataGridTemporal.test.ts
Normal file
10
frontend/src/components/dataGridTemporal.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveTemporalEditorSaveValue } from './dataGridTemporal';
|
||||
|
||||
describe('dataGridTemporal helpers', () => {
|
||||
it('prefers the picker selected date when form store has not caught up yet', () => {
|
||||
expect(resolveTemporalEditorSaveValue(undefined, dayjs('2026-04-12'), 'date')).toBe('2026-04-12');
|
||||
});
|
||||
});
|
||||
59
frontend/src/components/dataGridTemporal.ts
Normal file
59
frontend/src/components/dataGridTemporal.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null;
|
||||
|
||||
export const TEMPORAL_FORMATS: Record<string, string> = {
|
||||
datetime: 'YYYY-MM-DD HH:mm:ss',
|
||||
date: 'YYYY-MM-DD',
|
||||
time: 'HH:mm:ss',
|
||||
year: 'YYYY',
|
||||
};
|
||||
|
||||
export const isTemporalColumnType = (columnType?: string): boolean => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return false;
|
||||
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
|
||||
const base = raw.split(/[ (]/)[0];
|
||||
return base === 'date' || base === 'time' || base === 'year';
|
||||
};
|
||||
|
||||
export const getTemporalPickerType = (columnType?: string): TemporalPickerType => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return null;
|
||||
if (raw.includes('datetime') || raw.includes('timestamp')) return 'datetime';
|
||||
const base = raw.split(/[ (]/)[0];
|
||||
if (base === 'date') return 'date';
|
||||
if (base === 'time') return 'time';
|
||||
if (base === 'year') return 'year';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const parseToDayjs = (val: any, pickerType: TemporalPickerType): dayjs.Dayjs | null => {
|
||||
if (val === null || val === undefined || val === '') return null;
|
||||
const str = String(val).trim();
|
||||
if (!str || /^0{4}-0{2}-0{2}/.test(str)) return null;
|
||||
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
|
||||
const d = dayjs(str, fmt);
|
||||
return d.isValid() ? d : dayjs(str).isValid() ? dayjs(str) : null;
|
||||
};
|
||||
|
||||
export const formatFromDayjs = (val: dayjs.Dayjs | null, pickerType: TemporalPickerType): string => {
|
||||
if (!val || !val.isValid()) return '';
|
||||
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
|
||||
return val.format(fmt);
|
||||
};
|
||||
|
||||
export const resolveTemporalEditorSaveValue = (
|
||||
formValue: any,
|
||||
pickerValue: dayjs.Dayjs | null | undefined,
|
||||
pickerType: TemporalPickerType,
|
||||
): string | null | any => {
|
||||
const value = pickerValue !== undefined ? pickerValue : formValue;
|
||||
if (value && dayjs.isDayjs(value)) {
|
||||
return formatFromDayjs(value as dayjs.Dayjs, pickerType);
|
||||
}
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
67
frontend/src/components/dataSyncRequest.test.ts
Normal file
67
frontend/src/components/dataSyncRequest.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDataSyncRequest, validateDataSyncSelection } from './dataSyncRequest';
|
||||
|
||||
describe('validateDataSyncSelection', () => {
|
||||
it('requires source query and single target table in query mode', () => {
|
||||
expect(validateDataSyncSelection({
|
||||
sourceDatasetMode: 'query',
|
||||
selectedTables: [],
|
||||
sourceQuery: '',
|
||||
syncContent: 'data',
|
||||
})).toBe('请输入源查询 SQL');
|
||||
|
||||
expect(validateDataSyncSelection({
|
||||
sourceDatasetMode: 'query',
|
||||
selectedTables: [],
|
||||
sourceQuery: 'select 1',
|
||||
syncContent: 'data',
|
||||
})).toBe('SQL 结果集同步需要选择一个目标表');
|
||||
|
||||
expect(validateDataSyncSelection({
|
||||
sourceDatasetMode: 'query',
|
||||
selectedTables: ['users', 'orders'],
|
||||
sourceQuery: 'select 1',
|
||||
syncContent: 'data',
|
||||
})).toBe('SQL 结果集同步需要选择一个目标表');
|
||||
});
|
||||
|
||||
it('forces data-only in query mode', () => {
|
||||
expect(validateDataSyncSelection({
|
||||
sourceDatasetMode: 'query',
|
||||
selectedTables: ['users'],
|
||||
sourceQuery: 'select 1',
|
||||
syncContent: 'both',
|
||||
})).toBe('SQL 结果集同步仅支持仅同步数据');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDataSyncRequest', () => {
|
||||
it('normalizes query mode payload for backend', () => {
|
||||
const payload = buildDataSyncRequest({
|
||||
sourceConfig: { type: 'mysql' },
|
||||
targetConfig: { type: 'mysql' },
|
||||
selectedTables: ['users'],
|
||||
sourceDatasetMode: 'query',
|
||||
sourceQuery: ' SELECT id, name FROM active_users ',
|
||||
syncContent: 'both',
|
||||
syncMode: 'insert_update',
|
||||
autoAddColumns: true,
|
||||
targetTableStrategy: 'smart',
|
||||
createIndexes: true,
|
||||
mongoCollectionName: ' ',
|
||||
jobId: 'job-1',
|
||||
tableOptions: { users: { insert: true, update: true, delete: false } },
|
||||
});
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
tables: ['users'],
|
||||
sourceQuery: 'SELECT id, name FROM active_users',
|
||||
content: 'data',
|
||||
mode: 'insert_update',
|
||||
autoAddColumns: false,
|
||||
targetTableStrategy: 'existing_only',
|
||||
createIndexes: false,
|
||||
jobId: 'job-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
85
frontend/src/components/dataSyncRequest.ts
Normal file
85
frontend/src/components/dataSyncRequest.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export type SourceDatasetMode = 'table' | 'query';
|
||||
|
||||
type SyncContent = 'data' | 'schema' | 'both';
|
||||
type TargetTableStrategy = 'existing_only' | 'auto_create_if_missing' | 'smart';
|
||||
|
||||
type BuildDataSyncRequestParams = {
|
||||
sourceConfig: any;
|
||||
targetConfig: any;
|
||||
selectedTables: string[];
|
||||
sourceDatasetMode: SourceDatasetMode;
|
||||
sourceQuery: string;
|
||||
syncContent: SyncContent;
|
||||
syncMode: string;
|
||||
autoAddColumns: boolean;
|
||||
targetTableStrategy: TargetTableStrategy;
|
||||
createIndexes: boolean;
|
||||
mongoCollectionName: string;
|
||||
jobId?: string;
|
||||
tableOptions?: Record<string, any>;
|
||||
};
|
||||
|
||||
type ValidateDataSyncSelectionParams = {
|
||||
sourceDatasetMode: SourceDatasetMode;
|
||||
selectedTables: string[];
|
||||
sourceQuery: string;
|
||||
syncContent: SyncContent;
|
||||
};
|
||||
|
||||
export const validateDataSyncSelection = ({
|
||||
sourceDatasetMode,
|
||||
selectedTables,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
}: ValidateDataSyncSelectionParams): string | null => {
|
||||
if (sourceDatasetMode === 'query') {
|
||||
if (!String(sourceQuery || '').trim()) {
|
||||
return '请输入源查询 SQL';
|
||||
}
|
||||
if (selectedTables.length !== 1) {
|
||||
return 'SQL 结果集同步需要选择一个目标表';
|
||||
}
|
||||
if (syncContent !== 'data') {
|
||||
return 'SQL 结果集同步仅支持仅同步数据';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedTables.length === 0) {
|
||||
return '请选择至少一张表';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const buildDataSyncRequest = ({
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode,
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName,
|
||||
jobId,
|
||||
tableOptions,
|
||||
}: BuildDataSyncRequestParams) => {
|
||||
const isQueryMode = sourceDatasetMode === 'query';
|
||||
|
||||
return {
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
tables: selectedTables,
|
||||
sourceQuery: isQueryMode ? String(sourceQuery || '').trim() : undefined,
|
||||
content: isQueryMode ? 'data' : syncContent,
|
||||
mode: syncMode,
|
||||
autoAddColumns: isQueryMode ? false : autoAddColumns,
|
||||
targetTableStrategy: isQueryMode ? 'existing_only' : targetTableStrategy,
|
||||
createIndexes: isQueryMode ? false : createIndexes,
|
||||
mongoCollectionName: String(mongoCollectionName || '').trim(),
|
||||
...(jobId ? { jobId } : {}),
|
||||
...(tableOptions ? { tableOptions } : {}),
|
||||
};
|
||||
};
|
||||
164
frontend/src/components/jvm/JVMChangePreviewModal.tsx
Normal file
164
frontend/src/components/jvm/JVMChangePreviewModal.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Alert, Descriptions, Modal, Space, Tag, Typography } from "antd";
|
||||
|
||||
import type { JVMChangePreview } from "../../types";
|
||||
import {
|
||||
formatJVMRiskLevelText,
|
||||
formatJVMValueForDisplay,
|
||||
} 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 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}>
|
||||
{formatJVMValueForDisplay(preview.before)}
|
||||
</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}>
|
||||
{formatJVMValueForDisplay(preview.after)}
|
||||
</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;
|
||||
67
frontend/src/components/jvm/JVMDiagnosticOutput.tsx
Normal file
67
frontend/src/components/jvm/JVMDiagnosticOutput.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { Empty, List, Tag, Typography } from "antd";
|
||||
|
||||
import type { JVMDiagnosticEventChunk } from "../../types";
|
||||
import {
|
||||
formatJVMDiagnosticChunksForDisplay,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const chunkTexts = formatJVMDiagnosticChunksForDisplay(chunks);
|
||||
|
||||
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",
|
||||
}}
|
||||
>
|
||||
{chunkTexts[index]}
|
||||
</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>
|
||||
);
|
||||
@@ -25,4 +25,9 @@ describe('buildRedisWorkbenchTheme', () => {
|
||||
expect(lightTheme.statusTagBg).not.toBe(lightTheme.statusTagMutedBg);
|
||||
expect(lightTheme.backdropFilter).toBe('none');
|
||||
});
|
||||
|
||||
it('can disable redis workbench blur for macOS text-entry compatibility', () => {
|
||||
const darkTheme = buildRedisWorkbenchTheme({ darkMode: true, opacity: 0.72, blur: 14, disableBackdropFilter: true });
|
||||
expect(darkTheme.backdropFilter).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { resolveTextInputSafeBackdropFilter } from '../utils/appearance';
|
||||
|
||||
type RedisWorkbenchThemeInput = {
|
||||
darkMode: boolean;
|
||||
opacity: number;
|
||||
blur: number;
|
||||
disableBackdropFilter?: boolean;
|
||||
};
|
||||
|
||||
type RedisWorkbenchTheme = {
|
||||
@@ -43,10 +46,15 @@ export const buildRedisWorkbenchTheme = ({
|
||||
darkMode,
|
||||
opacity,
|
||||
blur,
|
||||
disableBackdropFilter,
|
||||
}: RedisWorkbenchThemeInput): RedisWorkbenchTheme => {
|
||||
const normalizedOpacity = clamp(opacity, 0.1, 1);
|
||||
const normalizedBlur = Math.max(0, Math.round(blur));
|
||||
const isTranslucent = normalizedOpacity < 0.999 || normalizedBlur > 0;
|
||||
const backdropFilter = resolveTextInputSafeBackdropFilter(
|
||||
normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
|
||||
disableBackdropFilter ?? false,
|
||||
);
|
||||
|
||||
if (darkMode) {
|
||||
const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92;
|
||||
@@ -84,7 +92,7 @@ export const buildRedisWorkbenchTheme = ({
|
||||
treeSelectedBorder: 'rgba(246, 196, 83, 0.24)',
|
||||
divider: 'rgba(255, 255, 255, 0.07)',
|
||||
shadow: '0 20px 48px rgba(0, 0, 0, 0.26)',
|
||||
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
|
||||
backdropFilter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,7 +130,7 @@ export const buildRedisWorkbenchTheme = ({
|
||||
treeSelectedBorder: 'rgba(22, 119, 255, 0.18)',
|
||||
divider: 'rgba(15, 23, 42, 0.08)',
|
||||
shadow: '0 22px 52px rgba(15, 23, 42, 0.08)',
|
||||
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
|
||||
backdropFilter,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCreateTablePreviewSql,
|
||||
buildAlterTablePreviewSql,
|
||||
hasAlterTableDraftChanges,
|
||||
type BuildAlterTablePreviewInput,
|
||||
type EditableColumnSnapshot,
|
||||
} from './tableDesignerSchemaSql';
|
||||
@@ -29,6 +31,18 @@ const buildInput = (overrides: Partial<BuildAlterTablePreviewInput>): BuildAlter
|
||||
});
|
||||
|
||||
describe('tableDesignerSchemaSql', () => {
|
||||
it('detects when alter table drafts contain unsaved column changes', () => {
|
||||
expect(hasAlterTableDraftChanges(buildInput({ dbType: 'mysql' }))).toBe(true);
|
||||
expect(
|
||||
hasAlterTableDraftChanges(
|
||||
buildInput({
|
||||
dbType: 'mysql',
|
||||
columns: [baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' })],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps mysql alter preview syntax with column position clauses', () => {
|
||||
const sql = buildAlterTablePreviewSql(buildInput({ dbType: 'mysql' }));
|
||||
|
||||
@@ -51,4 +65,152 @@ describe('tableDesignerSchemaSql', () => {
|
||||
expect(sql).not.toContain('AFTER');
|
||||
expect(sql).not.toContain(' FIRST');
|
||||
});
|
||||
|
||||
it('uses mysql change column syntax when renaming a column', () => {
|
||||
const sql = buildAlterTablePreviewSql(buildInput({
|
||||
dbType: 'mysql',
|
||||
originalColumns: [baseColumn({ _key: 'name', name: 'name', type: 'varchar(64)', nullable: 'YES' })],
|
||||
columns: [baseColumn({ _key: 'name', name: 'display_name', type: 'varchar(64)', nullable: 'YES' })],
|
||||
}));
|
||||
|
||||
expect(sql).toContain('CHANGE COLUMN `name` `display_name` varchar(64) NULL');
|
||||
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,110 +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;
|
||||
}
|
||||
|
||||
if (
|
||||
curr.name !== orig.name ||
|
||||
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, dbType)} ${colDef} ${positionSql}`.trim());
|
||||
return;
|
||||
}
|
||||
|
||||
if (definitionChanged(curr, orig)) {
|
||||
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`.trim());
|
||||
}
|
||||
});
|
||||
@@ -156,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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -232,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});`);
|
||||
}
|
||||
@@ -246,10 +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}`;
|
||||
};
|
||||
|
||||
@@ -131,6 +131,10 @@ if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
|
||||
OpenDriverDownloadDirectory: async (path: string) => ({ success: true, data: { path } }),
|
||||
OpenDataRootDirectory: async () => ({ success: true }),
|
||||
SelectSQLDirectory: async (currentPath: string) => ({ success: false, message: currentPath ? '已取消' : '已取消' }),
|
||||
ListSQLDirectory: async () => ({ success: true, data: [] }),
|
||||
ReadSQLFile: async () => ({ success: false, message: '已取消' }),
|
||||
WriteSQLFile: async (_filePath: string, _content: string) => ({ success: true }),
|
||||
InstallUpdateAndRestart: async () => ({ success: false }),
|
||||
ImportConfigFile: async () => ({ success: false, message: '已取消' }),
|
||||
ImportConnectionsPayload: async (raw: string, _password?: string) => {
|
||||
|
||||
@@ -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: {
|
||||
@@ -139,4 +207,180 @@ describe('store appearance persistence', () => {
|
||||
expect(useStore.getState().globalProxy.password).toBe('proxy-secret');
|
||||
expect(useStore.getState().globalProxy.hasPassword).toBe(true);
|
||||
});
|
||||
|
||||
it('persists external SQL directories and restores valid items after reload', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().saveExternalSQLDirectory({
|
||||
id: 'ext-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
|
||||
expect(persisted.state.externalSQLDirectories).toEqual([
|
||||
{
|
||||
id: 'ext-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
storage.setItem('lite-db-storage', JSON.stringify({
|
||||
state: {
|
||||
externalSQLDirectories: [
|
||||
persisted.state.externalSQLDirectories[0],
|
||||
{ path: '', name: 'broken' },
|
||||
],
|
||||
},
|
||||
version: 7,
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await importStore();
|
||||
expect(reloaded.useStore.getState().externalSQLDirectories).toEqual([
|
||||
{
|
||||
id: 'ext-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults AI chat send shortcut to Enter in shared shortcut options', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
expect(useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
|
||||
combo: 'Enter',
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('persists recorded AI chat send shortcut and restores it after reload', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().updateShortcut('sendAIChatMessage', {
|
||||
combo: 'Meta+Enter',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
|
||||
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
|
||||
combo: 'Meta+Enter',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await importStore();
|
||||
expect(reloaded.useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
|
||||
combo: 'Meta+Enter',
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to Enter when persisted AI chat send shortcut is invalid', async () => {
|
||||
storage.setItem('lite-db-storage', JSON.stringify({
|
||||
state: {
|
||||
shortcutOptions: {
|
||||
sendAIChatMessage: {
|
||||
combo: 'A',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: 8,
|
||||
}));
|
||||
|
||||
const { useStore } = await importStore();
|
||||
|
||||
expect(useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
|
||||
combo: 'Enter',
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not overwrite recorded AI chat send shortcut during startup config refresh', async () => {
|
||||
const { useStore } = await importStore();
|
||||
useStore.getState().updateShortcut('sendAIChatMessage', {
|
||||
combo: 'Ctrl+Enter',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
useStore.getState().replaceConnections([]);
|
||||
|
||||
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
|
||||
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
|
||||
combo: 'Ctrl+Enter',
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps persisted AI chat send shortcut when startup refresh runs before shortcut hydration catches up', async () => {
|
||||
const { useStore } = await importStore();
|
||||
const shortcutOptions = useStore.getState().shortcutOptions;
|
||||
storage.setItem('lite-db-storage', JSON.stringify({
|
||||
state: {
|
||||
shortcutOptions: {
|
||||
...shortcutOptions,
|
||||
sendAIChatMessage: {
|
||||
combo: 'Meta+Enter',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: 8,
|
||||
}));
|
||||
useStore.setState({
|
||||
shortcutOptions: {
|
||||
...shortcutOptions,
|
||||
sendAIChatMessage: {
|
||||
combo: 'Enter',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useStore.getState().replaceConnections([]);
|
||||
|
||||
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
|
||||
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
|
||||
combo: 'Meta+Enter',
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let a stale default shortcut state overwrite an explicitly recorded AI chat shortcut', async () => {
|
||||
const { useStore } = await importStore();
|
||||
const shortcutOptions = useStore.getState().shortcutOptions;
|
||||
|
||||
useStore.getState().updateShortcut('sendAIChatMessage', {
|
||||
combo: 'Meta+Enter',
|
||||
enabled: true,
|
||||
});
|
||||
useStore.setState({
|
||||
shortcutOptions: {
|
||||
...shortcutOptions,
|
||||
sendAIChatMessage: {
|
||||
combo: 'Enter',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
useStore.getState().replaceGlobalProxy({});
|
||||
|
||||
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
|
||||
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
|
||||
combo: 'Meta+Enter',
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,258 @@ 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;
|
||||
confirmationToken?: string;
|
||||
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;
|
||||
confirmationToken?: 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 +283,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 +298,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 +308,7 @@ export interface ConnectionConfig {
|
||||
mongoAuthMechanism?: string;
|
||||
mongoReplicaUser?: string;
|
||||
mongoReplicaPassword?: string;
|
||||
jvm?: JVMConfig;
|
||||
}
|
||||
|
||||
export interface MongoMemberInfo {
|
||||
@@ -82,8 +335,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 +387,32 @@ 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;
|
||||
filePath?: 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 +421,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;
|
||||
@@ -166,6 +451,22 @@ export interface SavedQuery {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ExternalSQLDirectory {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ExternalSQLTreeEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
children?: ExternalSQLTreeEntry[];
|
||||
}
|
||||
|
||||
// Redis types
|
||||
export interface RedisKeyInfo {
|
||||
key: string;
|
||||
@@ -179,7 +480,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;
|
||||
@@ -202,9 +503,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;
|
||||
@@ -237,11 +538,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;
|
||||
@@ -253,40 +559,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;
|
||||
@@ -312,7 +629,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;
|
||||
@@ -327,5 +644,3 @@ export interface SecurityUpdateStatus {
|
||||
issues: SecurityUpdateIssue[];
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
89
frontend/src/utils/aiChatSendShortcut.test.ts
Normal file
89
frontend/src/utils/aiChatSendShortcut.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
canRecordShortcutForAction,
|
||||
DEFAULT_SHORTCUT_OPTIONS,
|
||||
SHORTCUT_ACTION_META,
|
||||
SHORTCUT_ACTION_ORDER,
|
||||
type ShortcutBinding,
|
||||
} from './shortcuts';
|
||||
import {
|
||||
consumeAIChatSendShortcutOnKeyDown,
|
||||
getAIChatSendShortcutLabel,
|
||||
shouldSendAIChatOnKeyDown,
|
||||
} from './aiChatSendShortcut';
|
||||
|
||||
const binding = (combo: string, enabled = true): ShortcutBinding => ({ combo, enabled });
|
||||
|
||||
describe('aiChatSendShortcut', () => {
|
||||
it('registers AI chat send in the shared shortcut center with Enter default', () => {
|
||||
expect(SHORTCUT_ACTION_ORDER).toContain('sendAIChatMessage');
|
||||
expect(DEFAULT_SHORTCUT_OPTIONS.sendAIChatMessage).toEqual({ combo: 'Enter', enabled: true });
|
||||
expect(SHORTCUT_ACTION_META.sendAIChatMessage).toMatchObject({
|
||||
label: 'AI 聊天发送',
|
||||
allowInEditable: true,
|
||||
allowWithoutModifier: true,
|
||||
scope: 'aiComposer',
|
||||
requiredKey: 'Enter',
|
||||
disallowShift: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows recording only single-modifier Enter-based AI send shortcuts', () => {
|
||||
expect(canRecordShortcutForAction('sendAIChatMessage', 'Enter')).toBe(true);
|
||||
expect(canRecordShortcutForAction('sendAIChatMessage', 'Meta+Enter')).toBe(true);
|
||||
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Enter')).toBe(true);
|
||||
expect(canRecordShortcutForAction('sendAIChatMessage', 'Alt+Enter')).toBe(true);
|
||||
expect(canRecordShortcutForAction('sendAIChatMessage', 'A')).toBe(false);
|
||||
expect(canRecordShortcutForAction('sendAIChatMessage', 'Shift+Enter')).toBe(false);
|
||||
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Shift+Enter')).toBe(false);
|
||||
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Alt+Enter')).toBe(false);
|
||||
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Meta+Enter')).toBe(false);
|
||||
expect(canRecordShortcutForAction('sendAIChatMessage', 'Meta+Alt+Enter')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps modifier requirements for global shortcuts', () => {
|
||||
expect(canRecordShortcutForAction('runQuery', 'Enter')).toBe(false);
|
||||
expect(canRecordShortcutForAction('runQuery', 'Ctrl+Enter')).toBe(true);
|
||||
});
|
||||
|
||||
it('sends on the configured Enter shortcut but never during composition or Shift+Enter', () => {
|
||||
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter' })).toBe(true);
|
||||
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter', shiftKey: true })).toBe(false);
|
||||
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter', isComposing: true })).toBe(false);
|
||||
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter', nativeEvent: { isComposing: true } })).toBe(false);
|
||||
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'a' })).toBe(false);
|
||||
expect(shouldSendAIChatOnKeyDown(binding('Enter', false), { key: 'Enter' })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches recorded Cmd or Ctrl Enter shortcuts', () => {
|
||||
expect(shouldSendAIChatOnKeyDown(binding('Meta+Enter'), { key: 'Enter' })).toBe(false);
|
||||
expect(shouldSendAIChatOnKeyDown(binding('Meta+Enter'), { key: 'Enter', metaKey: true })).toBe(true);
|
||||
expect(shouldSendAIChatOnKeyDown(binding('Meta+Enter'), { key: 'Enter', ctrlKey: true })).toBe(false);
|
||||
expect(shouldSendAIChatOnKeyDown(binding('Ctrl+Enter'), { key: 'Enter', ctrlKey: true })).toBe(true);
|
||||
expect(shouldSendAIChatOnKeyDown(binding('Ctrl+Enter'), { key: 'Enter', metaKey: true })).toBe(false);
|
||||
expect(shouldSendAIChatOnKeyDown(binding('Ctrl+Enter'), { key: 'Enter', ctrlKey: true, isComposing: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('does not allow Shift to become an AI send shortcut even if a stale binding exists', () => {
|
||||
expect(shouldSendAIChatOnKeyDown(binding('Shift+Enter'), { key: 'Enter', shiftKey: true })).toBe(false);
|
||||
expect(getAIChatSendShortcutLabel(binding('Meta+Enter'))).toBe('Meta+Enter 发送');
|
||||
expect(getAIChatSendShortcutLabel(binding('Enter', false))).toBe('快捷键发送已关闭');
|
||||
});
|
||||
|
||||
it('stops propagation after consuming the configured AI send shortcut', () => {
|
||||
const event = {
|
||||
key: 'Enter',
|
||||
metaKey: true,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
};
|
||||
const onSend = vi.fn();
|
||||
|
||||
expect(consumeAIChatSendShortcutOnKeyDown(binding('Meta+Enter'), event, onSend)).toBe(true);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1);
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
50
frontend/src/utils/aiChatSendShortcut.ts
Normal file
50
frontend/src/utils/aiChatSendShortcut.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { DEFAULT_SHORTCUT_OPTIONS, getShortcutDisplay, isShortcutMatch, type ShortcutBinding } from './shortcuts';
|
||||
|
||||
export interface AIChatSendShortcutKeyEventLike {
|
||||
key?: string;
|
||||
shiftKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
isComposing?: boolean;
|
||||
nativeEvent?: {
|
||||
isComposing?: boolean;
|
||||
};
|
||||
preventDefault?: () => void;
|
||||
stopPropagation?: () => void;
|
||||
}
|
||||
|
||||
export const getAIChatSendShortcutLabel = (binding: ShortcutBinding | undefined): string => {
|
||||
if (binding?.enabled === false) {
|
||||
return '快捷键发送已关闭';
|
||||
}
|
||||
const combo = binding?.combo || DEFAULT_SHORTCUT_OPTIONS.sendAIChatMessage.combo;
|
||||
return `${getShortcutDisplay(combo)} 发送`;
|
||||
};
|
||||
|
||||
export const shouldSendAIChatOnKeyDown = (
|
||||
binding: ShortcutBinding | undefined,
|
||||
event: AIChatSendShortcutKeyEventLike,
|
||||
): boolean => {
|
||||
if (!binding?.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (event.shiftKey || event.isComposing || event.nativeEvent?.isComposing) {
|
||||
return false;
|
||||
}
|
||||
return isShortcutMatch(event as KeyboardEvent, binding.combo);
|
||||
};
|
||||
|
||||
export const consumeAIChatSendShortcutOnKeyDown = (
|
||||
binding: ShortcutBinding | undefined,
|
||||
event: AIChatSendShortcutKeyEventLike,
|
||||
onSend: () => void,
|
||||
): boolean => {
|
||||
if (!shouldSendAIChatOnKeyDown(binding, event)) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
onSend();
|
||||
return true;
|
||||
};
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from './aiEntryLayout';
|
||||
|
||||
describe('ai entry layout', () => {
|
||||
it('keeps the sidebar utility group free of the AI entry', () => {
|
||||
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'proxy', 'theme', 'about']);
|
||||
it('keeps the sidebar utility group compact and free of the AI entry', () => {
|
||||
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'settings']);
|
||||
});
|
||||
|
||||
it('anchors the AI entry to the content edge', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'proxy', 'theme', 'about'] as const;
|
||||
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'settings'] as const;
|
||||
|
||||
export type AIEntryPlacement = 'content-edge';
|
||||
export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell';
|
||||
|
||||
11
frontend/src/utils/aiMarkdown.test.ts
Normal file
11
frontend/src/utils/aiMarkdown.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeAiMarkdown } from './aiMarkdown';
|
||||
|
||||
describe('normalizeAiMarkdown', () => {
|
||||
it('inserts a missing newline after the fenced code language marker', () => {
|
||||
expect(normalizeAiMarkdown('```sqlSELECT COUNT(*) AS order_count\nFROM customer_order;\n```')).toBe(
|
||||
'```sql\nSELECT COUNT(*) AS order_count\nFROM customer_order;\n```',
|
||||
);
|
||||
});
|
||||
});
|
||||
13
frontend/src/utils/aiMarkdown.ts
Normal file
13
frontend/src/utils/aiMarkdown.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const normalizeAiMarkdown = (content: string): string => {
|
||||
let text = String(content || '').replace(/\r\n/g, '\n');
|
||||
const knownFenceLanguages = [
|
||||
'sql', 'mermaid', 'json', 'javascript', 'typescript', 'ts', 'js', 'tsx', 'jsx',
|
||||
'bash', 'sh', 'shell', 'python', 'py', 'go', 'java', 'yaml', 'yml', 'html', 'css',
|
||||
'xml', 'markdown', 'md', 'text', 'plaintext', 'vue', 'php', 'ruby', 'rust', 'toml',
|
||||
'ini', 'diff',
|
||||
];
|
||||
const fencePattern = new RegExp(`(^|\\n)\`\`\`(${knownFenceLanguages.join('|')})([^\\n])`, 'gi');
|
||||
text = text.replace(fencePattern, '$1```$2\n$3');
|
||||
text = text.replace(/([^\n])```(?=\n|$)/g, '$1\n```');
|
||||
return text;
|
||||
};
|
||||
48
frontend/src/utils/aiSqlLimit.test.ts
Normal file
48
frontend/src/utils/aiSqlLimit.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildAIReadonlyPreviewSQL } from './aiSqlLimit';
|
||||
|
||||
describe('buildAIReadonlyPreviewSQL', () => {
|
||||
it('limits Oracle readonly SQL with ROWNUM instead of MySQL LIMIT', () => {
|
||||
const sql = buildAIReadonlyPreviewSQL('oracle', 'SELECT 1 FROM DUAL;', 50);
|
||||
|
||||
expect(sql).toBe('SELECT * FROM (SELECT 1 FROM DUAL) WHERE ROWNUM <= 50');
|
||||
expect(sql.toLowerCase()).not.toContain('limit');
|
||||
});
|
||||
|
||||
it('does not add another limit when Oracle SQL already limits rows', () => {
|
||||
expect(buildAIReadonlyPreviewSQL('oracle', 'SELECT * FROM users WHERE ROWNUM <= 10', 50))
|
||||
.toBe('SELECT * FROM users WHERE ROWNUM <= 10');
|
||||
expect(buildAIReadonlyPreviewSQL('oracle', 'SELECT * FROM users FETCH FIRST 10 ROWS ONLY', 50))
|
||||
.toBe('SELECT * FROM users FETCH FIRST 10 ROWS ONLY');
|
||||
});
|
||||
|
||||
it('resolves custom Oracle drivers from the driver alias', () => {
|
||||
expect(buildAIReadonlyPreviewSQL('custom', 'SELECT 1 FROM DUAL;', 50, 'oracle'))
|
||||
.toBe('SELECT * FROM (SELECT 1 FROM DUAL) WHERE ROWNUM <= 50');
|
||||
});
|
||||
|
||||
it('keeps MySQL-family SQL on LIMIT syntax', () => {
|
||||
expect(buildAIReadonlyPreviewSQL('mysql', 'SELECT * FROM users', 50))
|
||||
.toBe('SELECT * FROM users LIMIT 50 OFFSET 0');
|
||||
});
|
||||
|
||||
it('keeps PostgreSQL-compatible and ClickHouse SQL on LIMIT syntax', () => {
|
||||
expect(buildAIReadonlyPreviewSQL('postgres', 'SELECT * FROM users', 50))
|
||||
.toBe('SELECT * FROM users LIMIT 50 OFFSET 0');
|
||||
expect(buildAIReadonlyPreviewSQL('kingbase', 'SELECT * FROM users', 50))
|
||||
.toBe('SELECT * FROM users LIMIT 50 OFFSET 0');
|
||||
expect(buildAIReadonlyPreviewSQL('clickhouse', 'SELECT * FROM events', 50))
|
||||
.toBe('SELECT * FROM events LIMIT 50 OFFSET 0');
|
||||
});
|
||||
|
||||
it('limits Dameng readonly SQL with Oracle-compatible ROWNUM syntax', () => {
|
||||
expect(buildAIReadonlyPreviewSQL('dameng', 'SELECT 1 FROM DUAL;', 50))
|
||||
.toBe('SELECT * FROM (SELECT 1 FROM DUAL) WHERE ROWNUM <= 50');
|
||||
});
|
||||
|
||||
it('does not limit non-readonly SQL', () => {
|
||||
expect(buildAIReadonlyPreviewSQL('oracle', 'UPDATE users SET name = \'a\';', 50))
|
||||
.toBe('UPDATE users SET name = \'a\'');
|
||||
});
|
||||
});
|
||||
31
frontend/src/utils/aiSqlLimit.ts
Normal file
31
frontend/src/utils/aiSqlLimit.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { buildPaginatedSelectSQL } from './sql';
|
||||
import { resolveSqlDialect } from './sqlDialect';
|
||||
|
||||
const AI_READONLY_SQL_KEYWORDS = new Set(['select', 'show', 'describe', 'desc', 'explain', 'with', 'pragma', 'values']);
|
||||
|
||||
const trimSQLStatement = (sql: string): string => String(sql || '').trim().replace(/;\s*$/, '').trim();
|
||||
|
||||
const isAIReadonlySQL = (sql: string): boolean => {
|
||||
const firstWord = trimSQLStatement(sql).trimStart().split(/\s+/)[0]?.toLowerCase() || '';
|
||||
return AI_READONLY_SQL_KEYWORDS.has(firstWord);
|
||||
};
|
||||
|
||||
const hasExistingRowLimit = (dialect: string, sql: string): boolean => {
|
||||
const text = trimSQLStatement(sql).toLowerCase();
|
||||
if (!text) return false;
|
||||
if (/\blimit\s+\d+\b/.test(text)) return true;
|
||||
if (/\bfetch\s+(first|next)\s+\d+\s+rows?\b/.test(text)) return true;
|
||||
if (/\btop\s*\(?\s*\d+\s*\)?\b/.test(text)) return true;
|
||||
|
||||
return (dialect === 'oracle' || dialect === 'dameng') && /\brownum\b/.test(text);
|
||||
};
|
||||
|
||||
export const buildAIReadonlyPreviewSQL = (dbType: string, sql: string, limit = 50, driver = ''): string => {
|
||||
const baseSQL = trimSQLStatement(sql);
|
||||
const safeLimit = Math.max(0, Math.floor(Number(limit) || 0));
|
||||
const dialect = resolveSqlDialect(dbType, driver);
|
||||
if (!baseSQL || safeLimit <= 0 || !isAIReadonlySQL(baseSQL) || hasExistingRowLimit(dialect, baseSQL)) {
|
||||
return baseSQL;
|
||||
}
|
||||
return buildPaginatedSelectSQL(dialect, baseSQL, '', safeLimit, 0);
|
||||
};
|
||||
51
frontend/src/utils/aiTableSchemaTool.test.ts
Normal file
51
frontend/src/utils/aiTableSchemaTool.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resolveAITableSchemaToolResult } from './aiTableSchemaTool';
|
||||
|
||||
describe('resolveAITableSchemaToolResult', () => {
|
||||
it('returns DDL directly when DDL fetch succeeds', async () => {
|
||||
const fetchColumns = vi.fn();
|
||||
|
||||
const result = await resolveAITableSchemaToolResult({
|
||||
tableName: 'USERS',
|
||||
fetchDDL: vi.fn().mockResolvedValue({ success: true, data: 'CREATE TABLE USERS (ID NUMBER)' }),
|
||||
fetchColumns,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true, content: 'CREATE TABLE USERS (ID NUMBER)' });
|
||||
expect(fetchColumns).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to column metadata when DDL fetch fails due to permissions', async () => {
|
||||
const result = await resolveAITableSchemaToolResult({
|
||||
tableName: 'USERS',
|
||||
fetchDDL: vi.fn().mockResolvedValue({ success: false, message: 'ORA-31603: object not found or insufficient privileges' }),
|
||||
fetchColumns: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{ Name: 'ID', Type: 'NUMBER', Nullable: 'NO', Default: null, Comment: '主键' },
|
||||
{ Name: 'NAME', Type: 'VARCHAR2(64)', Nullable: 'YES' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('DDL 获取失败,已降级为字段元数据摘要');
|
||||
expect(result.content).toContain('ORA-31603');
|
||||
expect(result.content).toContain('可用字段:ID, NAME');
|
||||
expect(result.content).toContain('"field":"ID"');
|
||||
expect(result.content).toContain('"type":"NUMBER"');
|
||||
});
|
||||
|
||||
it('returns a combined failure when both DDL and column metadata fail', async () => {
|
||||
const result = await resolveAITableSchemaToolResult({
|
||||
tableName: 'USERS',
|
||||
fetchDDL: vi.fn().mockResolvedValue({ success: false, message: 'DDL permission denied' }),
|
||||
fetchColumns: vi.fn().mockResolvedValue({ success: false, message: 'columns permission denied' }),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.content).toContain('DDL permission denied');
|
||||
expect(result.content).toContain('columns permission denied');
|
||||
});
|
||||
});
|
||||
69
frontend/src/utils/aiTableSchemaTool.ts
Normal file
69
frontend/src/utils/aiTableSchemaTool.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
type ToolQueryResult = {
|
||||
success?: boolean;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type ResolveAITableSchemaToolResultParams = {
|
||||
tableName: string;
|
||||
fetchDDL: () => Promise<ToolQueryResult>;
|
||||
fetchColumns: () => Promise<ToolQueryResult>;
|
||||
};
|
||||
|
||||
const stringifyToolData = (data: unknown): string => (
|
||||
typeof data === 'string' ? data : JSON.stringify(data)
|
||||
);
|
||||
|
||||
const firstStringValue = (row: Record<string, unknown>, keys: string[]): string => {
|
||||
for (const key of keys) {
|
||||
const value = row[key];
|
||||
if (value !== undefined && value !== null) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeAIColumn = (raw: unknown) => {
|
||||
const row = (raw && typeof raw === 'object') ? raw as Record<string, unknown> : {};
|
||||
const keys = Object.keys(row);
|
||||
return {
|
||||
field: firstStringValue(row, ['Field', 'field', 'COLUMN_NAME', 'column_name', 'Name', 'name']) || (keys.length > 0 ? String(row[keys[0]] ?? '') : ''),
|
||||
type: firstStringValue(row, ['Type', 'type', 'DATA_TYPE', 'data_type']) || (keys.length > 1 ? String(row[keys[1]] ?? '') : ''),
|
||||
nullable: firstStringValue(row, ['Null', 'null', 'IS_NULLABLE', 'is_nullable', 'Nullable', 'nullable']),
|
||||
default: firstStringValue(row, ['Default', 'default', 'COLUMN_DEFAULT', 'column_default', 'DefaultValue']),
|
||||
comment: firstStringValue(row, ['Comment', 'comment', 'COLUMN_COMMENT', 'column_comment', 'Description']),
|
||||
};
|
||||
};
|
||||
|
||||
const buildColumnFallbackContent = (tableName: string, ddlError: string, columns: unknown[]): string => {
|
||||
const normalizedColumns = columns.map(normalizeAIColumn).filter((column) => column.field.trim());
|
||||
const fieldNames = normalizedColumns.map((column) => column.field).join(', ');
|
||||
return [
|
||||
`⚠️ 表 ${tableName} 的 DDL 获取失败,已降级为字段元数据摘要。`,
|
||||
`DDL 错误:${ddlError || '未知错误'}`,
|
||||
'该结果不包含完整索引、约束、触发器等 DDL 信息;请基于字段列表继续分析,不要因为 DDL 权限失败而停止。',
|
||||
`可用字段:${fieldNames || '无'}`,
|
||||
`详细信息:${JSON.stringify(normalizedColumns)}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const resolveAITableSchemaToolResult = async ({
|
||||
tableName,
|
||||
fetchDDL,
|
||||
fetchColumns,
|
||||
}: ResolveAITableSchemaToolResultParams): Promise<{ success: boolean; content: string }> => {
|
||||
const ddlResult = await fetchDDL();
|
||||
if (ddlResult?.success) {
|
||||
return { success: true, content: stringifyToolData(ddlResult.data) };
|
||||
}
|
||||
|
||||
const ddlError = ddlResult?.message || 'Failed to fetch DDL';
|
||||
const columnResult = await fetchColumns();
|
||||
if (columnResult?.success && Array.isArray(columnResult.data)) {
|
||||
return { success: true, content: buildColumnFallbackContent(tableName, ddlError, columnResult.data) };
|
||||
}
|
||||
|
||||
const columnError = columnResult?.message || 'Failed to fetch columns';
|
||||
return { success: false, content: `获取建表语句失败:${ddlError};降级获取字段列表也失败:${columnError}` };
|
||||
};
|
||||
21
frontend/src/utils/appVersionDisplay.test.ts
Normal file
21
frontend/src/utils/appVersionDisplay.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveAboutDisplayVersion } from './appVersionDisplay';
|
||||
|
||||
describe('resolveAboutDisplayVersion', () => {
|
||||
it('shows fixed dev version for development build', () => {
|
||||
expect(resolveAboutDisplayVersion('development', '0.6.5')).toBe('0.0.1-dev');
|
||||
});
|
||||
|
||||
it('shows fixed dev version for wails dev build type', () => {
|
||||
expect(resolveAboutDisplayVersion('dev', '0.6.5')).toBe('0.0.1-dev');
|
||||
});
|
||||
|
||||
it('keeps real version for non-development builds', () => {
|
||||
expect(resolveAboutDisplayVersion('production', '0.6.5')).toBe('0.6.5');
|
||||
});
|
||||
|
||||
it('falls back to unknown when version is empty outside development', () => {
|
||||
expect(resolveAboutDisplayVersion('production', '')).toBe('未知');
|
||||
});
|
||||
});
|
||||
14
frontend/src/utils/appVersionDisplay.ts
Normal file
14
frontend/src/utils/appVersionDisplay.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const DEV_ABOUT_VERSION = '0.0.1-dev';
|
||||
|
||||
export const resolveAboutDisplayVersion = (
|
||||
buildType: string,
|
||||
version: string | undefined,
|
||||
): string => {
|
||||
const normalizedBuildType = String(buildType || '').trim().toLowerCase();
|
||||
if (normalizedBuildType === 'development' || normalizedBuildType === 'dev') {
|
||||
return DEV_ABOUT_VERSION;
|
||||
}
|
||||
|
||||
const normalizedVersion = String(version || '').trim();
|
||||
return normalizedVersion || '未知';
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from './appearance';
|
||||
import {
|
||||
blurToFilter,
|
||||
normalizeBlurForPlatform,
|
||||
normalizeOpacityForPlatform,
|
||||
resolveAppearanceValues,
|
||||
resolveTextInputSafeBackdropFilter,
|
||||
} from './appearance';
|
||||
|
||||
describe('appearance helpers', () => {
|
||||
it('falls back to opaque non-blurred appearance when disabled', () => {
|
||||
@@ -20,4 +26,10 @@ describe('appearance helpers', () => {
|
||||
expect(blurToFilter(0)).toBeUndefined();
|
||||
expect(blurToFilter(8)).toBe('blur(8px)');
|
||||
});
|
||||
|
||||
it('disables local backdrop blur for text-entry surfaces on macOS', () => {
|
||||
expect(resolveTextInputSafeBackdropFilter('blur(18px)', true)).toBe('none');
|
||||
expect(resolveTextInputSafeBackdropFilter('blur(18px)', false)).toBe('blur(18px)');
|
||||
expect(resolveTextInputSafeBackdropFilter(undefined, true)).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,3 +80,16 @@ export const normalizeBlurForPlatform = (blur: number | undefined): number => {
|
||||
export const blurToFilter = (blur: number): string | undefined => {
|
||||
return blur > 0 ? `blur(${blur}px)` : undefined;
|
||||
};
|
||||
|
||||
// macOS WebView 下,文本输入区域祖先节点的 backdrop-filter 会和输入法候选/切换浮层叠加,
|
||||
// 造成额外的透明框。这里允许交互面板按平台降级为非模糊背景。
|
||||
export const resolveTextInputSafeBackdropFilter = (
|
||||
backdropFilter: string | undefined,
|
||||
disableForMacLike: boolean = isMacLikePlatform(),
|
||||
): string => {
|
||||
const normalized = String(backdropFilter || '').trim();
|
||||
if (!normalized || normalized === 'none') {
|
||||
return 'none';
|
||||
}
|
||||
return disableForMacLike ? 'none' : normalized;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ConnectionConfig, SavedConnection } from '../types';
|
||||
|
||||
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'invalid';
|
||||
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'mysql-workbench-xml' | 'invalid';
|
||||
export type ConnectionPackageDialogSnapshot = {
|
||||
open: boolean;
|
||||
mode: 'export' | 'import';
|
||||
@@ -105,7 +105,15 @@ const parseConnectionImportRaw = (raw: unknown): unknown => {
|
||||
}
|
||||
};
|
||||
|
||||
const isMySQLWorkbenchXML = (raw: string): boolean => (
|
||||
raw.includes('<data') && raw.includes('grt_format') && raw.includes('db.mgmt.Connection')
|
||||
);
|
||||
|
||||
export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind => {
|
||||
if (typeof raw === 'string' && isMySQLWorkbenchXML(raw)) {
|
||||
return 'mysql-workbench-xml';
|
||||
}
|
||||
|
||||
const parsed = parseConnectionImportRaw(raw);
|
||||
|
||||
if (isConnectionPackageV2AppManagedEnvelope(parsed)) {
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
107
frontend/src/utils/dataGridFind.test.ts
Normal file
107
frontend/src/utils/dataGridFind.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
attachDataGridFindRenderVersion,
|
||||
collectDataGridFindMatches,
|
||||
findDataGridTextRanges,
|
||||
hasDataGridFindRenderVersionChanged,
|
||||
normalizeDataGridFindQuery,
|
||||
resolveDataGridFindNavigationIndex,
|
||||
summarizeDataGridFindMatches,
|
||||
} from './dataGridFind';
|
||||
|
||||
describe('dataGridFind', () => {
|
||||
it('normalizes blank queries to an empty search value without changing non-blank text', () => {
|
||||
expect(normalizeDataGridFindQuery(' alpha ')).toBe(' alpha ');
|
||||
expect(normalizeDataGridFindQuery(' ')).toBe('');
|
||||
expect(normalizeDataGridFindQuery(null)).toBe('');
|
||||
});
|
||||
|
||||
it('finds case-insensitive non-overlapping text ranges', () => {
|
||||
expect(findDataGridTextRanges('Alpha beta ALPHA', 'alpha')).toEqual([
|
||||
{ start: 0, end: 5 },
|
||||
{ start: 11, end: 16 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats special characters as plain text', () => {
|
||||
expect(findDataGridTextRanges('a+b a.b a+b', 'a+b')).toEqual([
|
||||
{ start: 0, end: 3 },
|
||||
{ start: 8, end: 11 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves whitespace in non-blank plain text queries', () => {
|
||||
expect(findDataGridTextRanges(' alpha alpha ', ' alpha')).toEqual([
|
||||
{ start: 0, end: 6 },
|
||||
{ start: 6, end: 12 },
|
||||
]);
|
||||
expect(findDataGridTextRanges('alpha beta alphabeta', 'alpha ')).toEqual([
|
||||
{ start: 0, end: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no ranges for empty query or empty text', () => {
|
||||
expect(findDataGridTextRanges('alpha', '')).toEqual([]);
|
||||
expect(findDataGridTextRanges('', 'alpha')).toEqual([]);
|
||||
});
|
||||
|
||||
it('summarizes matches across selected columns only', () => {
|
||||
const rows = [
|
||||
{ id: 1, name: 'Alpha', note: 'alpha beta', hidden: 'alpha' },
|
||||
{ id: 2, name: 'Gamma', note: 'none', hidden: 'alpha' },
|
||||
];
|
||||
|
||||
expect(
|
||||
summarizeDataGridFindMatches(rows, ['name', 'note'], 'alpha', (value) => String(value ?? '')),
|
||||
).toEqual({ matchedCellCount: 2, occurrenceCount: 2 });
|
||||
});
|
||||
|
||||
it('collects ordered cell matches with row and column coordinates', () => {
|
||||
const rows = [
|
||||
{ __gonavi_row_key__: 'row-1', name: 'Alpha alpha', note: 'beta Alpha' },
|
||||
{ __gonavi_row_key__: 'row-2', name: 'none', note: 'alpha' },
|
||||
];
|
||||
|
||||
expect(
|
||||
collectDataGridFindMatches(
|
||||
rows,
|
||||
['name', 'note'],
|
||||
'alpha',
|
||||
(value) => String(value ?? ''),
|
||||
(row) => String(row.__gonavi_row_key__),
|
||||
),
|
||||
).toEqual([
|
||||
{ rowIndex: 0, rowKey: 'row-1', columnName: 'name', columnIndex: 0, occurrenceIndex: 0, start: 0, end: 5 },
|
||||
{ rowIndex: 0, rowKey: 'row-1', columnName: 'name', columnIndex: 0, occurrenceIndex: 1, start: 6, end: 11 },
|
||||
{ rowIndex: 0, rowKey: 'row-1', columnName: 'note', columnIndex: 1, occurrenceIndex: 0, start: 5, end: 10 },
|
||||
{ rowIndex: 1, rowKey: 'row-2', columnName: 'note', columnIndex: 1, occurrenceIndex: 0, start: 0, end: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves previous and next navigation indexes with wrapping', () => {
|
||||
expect(resolveDataGridFindNavigationIndex(-1, 4, 'next')).toBe(0);
|
||||
expect(resolveDataGridFindNavigationIndex(0, 4, 'next')).toBe(1);
|
||||
expect(resolveDataGridFindNavigationIndex(3, 4, 'next')).toBe(0);
|
||||
expect(resolveDataGridFindNavigationIndex(-1, 4, 'previous')).toBe(3);
|
||||
expect(resolveDataGridFindNavigationIndex(0, 4, 'previous')).toBe(3);
|
||||
expect(resolveDataGridFindNavigationIndex(2, 4, 'previous')).toBe(1);
|
||||
expect(resolveDataGridFindNavigationIndex(0, 0, 'next')).toBe(-1);
|
||||
});
|
||||
|
||||
it('tracks render version changes without exposing metadata as row data', () => {
|
||||
const rows = [{ id: 1, name: 'Alpha' }];
|
||||
|
||||
expect(attachDataGridFindRenderVersion(rows, '')).toBe(rows);
|
||||
|
||||
const alphaRows = attachDataGridFindRenderVersion(rows, 'alpha');
|
||||
const betaRows = attachDataGridFindRenderVersion(rows, 'beta');
|
||||
|
||||
expect(alphaRows).not.toBe(rows);
|
||||
expect(alphaRows[0]).not.toBe(rows[0]);
|
||||
expect(Object.keys(alphaRows[0])).toEqual(['id', 'name']);
|
||||
expect(hasDataGridFindRenderVersionChanged(alphaRows[0], rows[0])).toBe(true);
|
||||
expect(hasDataGridFindRenderVersionChanged(betaRows[0], alphaRows[0])).toBe(true);
|
||||
expect(hasDataGridFindRenderVersionChanged(rows[0], alphaRows[0])).toBe(true);
|
||||
});
|
||||
});
|
||||
145
frontend/src/utils/dataGridFind.ts
Normal file
145
frontend/src/utils/dataGridFind.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
export interface DataGridTextRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface DataGridFindSummary {
|
||||
matchedCellCount: number;
|
||||
occurrenceCount: number;
|
||||
}
|
||||
|
||||
export interface DataGridFindMatch extends DataGridTextRange {
|
||||
rowIndex: number;
|
||||
rowKey: string;
|
||||
columnName: string;
|
||||
columnIndex: number;
|
||||
occurrenceIndex: number;
|
||||
}
|
||||
|
||||
export type DataGridFindNavigationDirection = 'previous' | 'next';
|
||||
|
||||
export const DATA_GRID_FIND_RENDER_VERSION = Symbol('DATA_GRID_FIND_RENDER_VERSION');
|
||||
|
||||
export const normalizeDataGridFindQuery = (value: unknown): string => {
|
||||
const text = String(value ?? '');
|
||||
return text.trim().length === 0 ? '' : text;
|
||||
};
|
||||
|
||||
export const findDataGridTextRanges = (text: string, query: string): DataGridTextRange[] => {
|
||||
const normalizedQuery = normalizeDataGridFindQuery(query);
|
||||
if (!text || !normalizedQuery) return [];
|
||||
|
||||
const source = String(text);
|
||||
const lowerSource = source.toLocaleLowerCase();
|
||||
const lowerQuery = normalizedQuery.toLocaleLowerCase();
|
||||
const ranges: DataGridTextRange[] = [];
|
||||
let startIndex = 0;
|
||||
|
||||
while (startIndex < source.length) {
|
||||
const matchIndex = lowerSource.indexOf(lowerQuery, startIndex);
|
||||
if (matchIndex === -1) break;
|
||||
const end = matchIndex + normalizedQuery.length;
|
||||
ranges.push({ start: matchIndex, end });
|
||||
startIndex = end;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
};
|
||||
|
||||
export const attachDataGridFindRenderVersion = <T>(rows: T[], query: string): T[] => {
|
||||
const normalizedQuery = normalizeDataGridFindQuery(query);
|
||||
if (!normalizedQuery) return rows;
|
||||
|
||||
return rows.map((row) => {
|
||||
if (!row || typeof row !== 'object') return row;
|
||||
const nextRow = { ...(row as object) } as T;
|
||||
Object.defineProperty(nextRow, DATA_GRID_FIND_RENDER_VERSION, {
|
||||
value: normalizedQuery,
|
||||
enumerable: false,
|
||||
});
|
||||
return nextRow;
|
||||
});
|
||||
};
|
||||
|
||||
export const hasDataGridFindRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => {
|
||||
const nextVersion = nextRecord && typeof nextRecord === 'object'
|
||||
? (nextRecord as Record<symbol, unknown>)[DATA_GRID_FIND_RENDER_VERSION]
|
||||
: undefined;
|
||||
const previousVersion = previousRecord && typeof previousRecord === 'object'
|
||||
? (previousRecord as Record<symbol, unknown>)[DATA_GRID_FIND_RENDER_VERSION]
|
||||
: undefined;
|
||||
return nextVersion !== previousVersion;
|
||||
};
|
||||
|
||||
export const summarizeDataGridFindMatches = <T>(
|
||||
rows: T[],
|
||||
columnNames: string[],
|
||||
query: string,
|
||||
getCellText: (value: unknown, row: T, columnName: string) => string,
|
||||
): DataGridFindSummary => {
|
||||
const normalizedQuery = normalizeDataGridFindQuery(query);
|
||||
if (!normalizedQuery) {
|
||||
return { matchedCellCount: 0, occurrenceCount: 0 };
|
||||
}
|
||||
|
||||
let matchedCellCount = 0;
|
||||
let occurrenceCount = 0;
|
||||
|
||||
rows.forEach((row) => {
|
||||
columnNames.forEach((columnName) => {
|
||||
const record = row as Record<string, unknown>;
|
||||
const ranges = findDataGridTextRanges(getCellText(record[columnName], row, columnName), normalizedQuery);
|
||||
if (ranges.length > 0) {
|
||||
matchedCellCount += 1;
|
||||
occurrenceCount += ranges.length;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { matchedCellCount, occurrenceCount };
|
||||
};
|
||||
|
||||
export const collectDataGridFindMatches = <T>(
|
||||
rows: T[],
|
||||
columnNames: string[],
|
||||
query: string,
|
||||
getCellText: (value: unknown, row: T, columnName: string) => string,
|
||||
getRowKey: (row: T, rowIndex: number) => string,
|
||||
): DataGridFindMatch[] => {
|
||||
const normalizedQuery = normalizeDataGridFindQuery(query);
|
||||
if (!normalizedQuery) return [];
|
||||
|
||||
const matches: DataGridFindMatch[] = [];
|
||||
|
||||
rows.forEach((row, rowIndex) => {
|
||||
const record = row as Record<string, unknown>;
|
||||
const rowKey = getRowKey(row, rowIndex);
|
||||
columnNames.forEach((columnName, columnIndex) => {
|
||||
findDataGridTextRanges(getCellText(record[columnName], row, columnName), normalizedQuery).forEach((range, occurrenceIndex) => {
|
||||
matches.push({
|
||||
rowIndex,
|
||||
rowKey,
|
||||
columnName,
|
||||
columnIndex,
|
||||
occurrenceIndex,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
export const resolveDataGridFindNavigationIndex = (
|
||||
currentIndex: number,
|
||||
matchCount: number,
|
||||
direction: DataGridFindNavigationDirection,
|
||||
): number => {
|
||||
if (matchCount <= 0) return -1;
|
||||
if (direction === 'previous') {
|
||||
return currentIndex <= 0 ? matchCount - 1 : currentIndex - 1;
|
||||
}
|
||||
return currentIndex < 0 || currentIndex >= matchCount - 1 ? 0 : currentIndex + 1;
|
||||
};
|
||||
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` = ');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user