mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 20:29:43 +08:00
Compare commits
197 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca246725f3 | ||
|
|
c1ebce4ef5 | ||
|
|
2be09c1918 | ||
|
|
c927e33c8c | ||
|
|
824aafbdea | ||
|
|
0c1586d7a4 | ||
|
|
b1ef52f62e | ||
|
|
05a913ccb2 | ||
|
|
f51dbcfb2c | ||
|
|
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 | ||
|
|
bf5a9c3306 | ||
|
|
2569a3779a | ||
|
|
bb6271246b | ||
|
|
8e0d1b0a80 | ||
|
|
d150780879 | ||
|
|
52d2ee7592 | ||
|
|
7beb08c960 | ||
|
|
2410aad849 | ||
|
|
33b21cc5ee | ||
|
|
1a0ba9a499 | ||
|
|
7a2563b83b | ||
|
|
632e57ea60 | ||
|
|
ca76440981 | ||
|
|
af5e84213f | ||
|
|
fcade0f860 | ||
|
|
1c2377bc62 | ||
|
|
426ef3bcf6 | ||
|
|
fb500ee33b | ||
|
|
89d79ff10c | ||
|
|
aa1bb5b886 | ||
|
|
5038ae5c9b | ||
|
|
83fe3d4ed9 | ||
|
|
808c773134 | ||
|
|
5d86ee7c76 | ||
|
|
8297829be6 | ||
|
|
f696f52470 | ||
|
|
60b63d7a22 | ||
|
|
1f617f9d53 | ||
|
|
1751e14d20 | ||
|
|
82e06bd94d | ||
|
|
c810d999bd | ||
|
|
0009c98c7e | ||
|
|
070ff72ad8 | ||
|
|
803c33b306 | ||
|
|
1d882d089f | ||
|
|
19da7fc66c | ||
|
|
c1877ea013 | ||
|
|
60dbb8a559 | ||
|
|
67fe3e3017 | ||
|
|
1a042321d2 | ||
|
|
35944d58f8 | ||
|
|
5c2509c37f | ||
|
|
8e1b01b550 | ||
|
|
29fa5eb6df | ||
|
|
7c6391af3d | ||
|
|
5746796bc2 | ||
|
|
3ec7c9be9d | ||
|
|
ac6ef06413 | ||
|
|
ac0b6c05e8 | ||
|
|
37b3c78049 | ||
|
|
255cc14bf6 | ||
|
|
4718755208 | ||
|
|
91b5b85904 | ||
|
|
c842201bf4 | ||
|
|
263db6bf30 | ||
|
|
b5e8f5c022 | ||
|
|
b62d22395b | ||
|
|
f74270d585 | ||
|
|
ef64a24e01 | ||
|
|
c1266c225a | ||
|
|
acee1a06e8 | ||
|
|
eddb9f38c9 | ||
|
|
fbda6917f7 | ||
|
|
b022cd63e5 | ||
|
|
9eb42565f1 | ||
|
|
6d533167da | ||
|
|
9bbdcea3fd | ||
|
|
f992ad72e6 | ||
|
|
5c0f6f8ff4 | ||
|
|
1eb517f083 | ||
|
|
02fa0aef46 | ||
|
|
f7107a1625 | ||
|
|
08ab06c038 | ||
|
|
3402b56fdb | ||
|
|
2c2baca69f | ||
|
|
e464c2cce1 | ||
|
|
15f72c013d | ||
|
|
c2c8870841 | ||
|
|
4f7ac7149a | ||
|
|
8d8af530a7 | ||
|
|
29b96719d5 | ||
|
|
9c96246320 | ||
|
|
31644dee6b | ||
|
|
aa9d8d243a | ||
|
|
6e55d63877 | ||
|
|
c126c4b731 | ||
|
|
c85de27aac | ||
|
|
eeef0f06ed | ||
|
|
fcd4d4026c | ||
|
|
a7bee7f3b6 | ||
|
|
ed4a7b96d4 |
82
.github/workflows/dev-build.yml
vendored
82
.github/workflows/dev-build.yml
vendored
@@ -5,6 +5,10 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -242,6 +246,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEV_VERSION="${{ steps.version.outputs.version }}"
|
||||
./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.platform }}"
|
||||
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}"
|
||||
else
|
||||
@@ -316,6 +321,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"
|
||||
@@ -332,6 +340,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"
|
||||
|
||||
@@ -575,14 +594,63 @@ jobs:
|
||||
DEV_VERSION="dev-${SHORT_SHA}"
|
||||
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Format Build Time
|
||||
id: build_time
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY' >> "$GITHUB_OUTPUT"
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
raw = "${{ github.event.head_commit.timestamp }}"
|
||||
dt = datetime.fromisoformat(raw)
|
||||
china_tz = timezone(timedelta(hours=8))
|
||||
formatted = dt.astimezone(china_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"display={formatted}")
|
||||
PY
|
||||
|
||||
# 删除旧的 dev pre-release(保持只有最新一个)
|
||||
- name: Delete Previous Dev Release
|
||||
uses: dev-drprasad/delete-tag-and-release@v1.1
|
||||
continue-on-error: true
|
||||
- name: Reset Previous Dev Release
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
tag_name: dev-latest
|
||||
delete_release: true
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const tag = 'dev-latest';
|
||||
const ref = `tags/${tag}`;
|
||||
const { owner, repo } = context.repo;
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const matchedReleases = releases.filter((release) => release.tag_name === tag);
|
||||
if (matchedReleases.length === 0) {
|
||||
core.info(`No existing releases found for tag ${tag}`);
|
||||
} else {
|
||||
for (const release of matchedReleases) {
|
||||
core.info(`Deleting release ${release.id} (${release.name || 'unnamed'}) for tag ${tag}`);
|
||||
await github.rest.repos.deleteRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.git.deleteRef({
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
});
|
||||
core.info(`Deleted ref ${ref}`);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info(`No existing ref found for ${ref}`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
- name: Create Dev Pre-release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -599,7 +667,7 @@ jobs:
|
||||
**版本**: `${{ steps.version.outputs.version }}`
|
||||
**分支**: `dev`
|
||||
**提交**: [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
|
||||
**构建时间**: ${{ github.event.head_commit.timestamp }}
|
||||
**构建时间**: ${{ steps.build_time.outputs.display }}
|
||||
|
||||
> ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。
|
||||
> 每次 push 到 `dev` 分支会自动覆盖此 release。
|
||||
|
||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -237,6 +237,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.platform }}"
|
||||
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
else
|
||||
@@ -314,6 +315,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 +334,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"
|
||||
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
.gitignore
|
||||
# build / release artifacts
|
||||
frontend/release/
|
||||
**/release/
|
||||
@@ -20,7 +20,13 @@ GoNavi-Wails.exe
|
||||
.superpowers/
|
||||
.claude/
|
||||
.gemini/
|
||||
.playwright-mcp/
|
||||
**/tmpclaude-*
|
||||
docs/superpowers/
|
||||
docs/需求追踪/
|
||||
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
.worktrees
|
||||
docs
|
||||
.tmp_superpowers_edit
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
Thank you for contributing to this project.
|
||||
|
||||
This repository follows a release-first workflow: `main` is the default public branch, while releases are prepared through `release/*` branches.
|
||||
This repository uses `dev` as the default integration branch, while stable releases are published from `main` through `release/*` branches.
|
||||
|
||||
---
|
||||
|
||||
## Branch Model
|
||||
|
||||
- `main`: stable release branch and default branch
|
||||
- `dev`: day-to-day integration branch for maintainers
|
||||
- `dev`: default branch and day-to-day integration branch
|
||||
- `main`: stable release branch
|
||||
- `release/*`: release preparation branches for maintainers
|
||||
- Recommended branch names for external contributors:
|
||||
- `fix/*`: bug fixes
|
||||
@@ -25,21 +25,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||
|
||||
## How External Contributors Should Open Pull Requests
|
||||
|
||||
Whether your branch is `fix/*` or `feature/*`, external contributors should **open pull requests directly against `main`**.
|
||||
Whether your branch is `fix/*` or `feature/*`, external contributors should **open pull requests directly against `dev`**.
|
||||
|
||||
Reasons:
|
||||
|
||||
- `main` is the default branch, so the PR entry point is clearer
|
||||
- merged contributions are immediately visible on the default branch
|
||||
- maintainers can handle downstream sync and release preparation in one place
|
||||
- `dev` is the active integration branch, so changes can be reviewed in the same lane as ongoing work
|
||||
- contributors align with the branch that triggers day-to-day validation and dev builds
|
||||
- maintainers can cut `release/*` branches from `dev` without re-syncing external changes first
|
||||
|
||||
Recommended flow:
|
||||
|
||||
1. Fork this repository
|
||||
2. Create a branch in your fork (`fix/*` or `feature/*` is recommended)
|
||||
2. Sync your fork with `dev` and create a branch from `dev` (`fix/*` or `feature/*` is recommended)
|
||||
3. Make your changes and perform basic self-checks
|
||||
4. Push the branch to your fork
|
||||
5. Open a pull request against the `main` branch of this repository
|
||||
5. Open a pull request against the `dev` branch of this repository
|
||||
|
||||
---
|
||||
|
||||
@@ -63,33 +63,21 @@ Recommended expectations:
|
||||
|
||||
## Merge Strategy for Maintainers
|
||||
|
||||
Pull requests merged into `main` should generally use **Squash and merge**.
|
||||
Pull requests merged into `dev` should generally use **Squash and merge**.
|
||||
|
||||
Reasons:
|
||||
|
||||
- keeps `main` history clean and linear
|
||||
- maps each PR to a single commit on `main`
|
||||
- reduces release, audit, and rollback complexity
|
||||
- keeps `dev` history readable and easier to audit during active iteration
|
||||
- maps each PR to a single integration commit on `dev`
|
||||
- reduces cherry-pick and conflict cost before creating `release/*`
|
||||
|
||||
---
|
||||
|
||||
## Maintainer Sync Rules
|
||||
|
||||
Because external pull requests are merged directly into `main`, maintainers must sync `main` back to development and release branches to avoid branch drift.
|
||||
Because external pull requests are merged directly into `dev`, maintainers should treat `dev` as the source branch for daily collaboration and release preparation.
|
||||
|
||||
### 1. Sync `main` -> `dev` (required)
|
||||
|
||||
The automatic GitHub Actions sync workflow has been removed.
|
||||
Maintainers should sync `main` back to `dev` manually when needed:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git merge main
|
||||
git push
|
||||
```
|
||||
|
||||
### 2. Create `release/*` from `dev`
|
||||
### 1. Create `release/*` from `dev`
|
||||
|
||||
Before a release, create a release branch from `dev`, for example:
|
||||
|
||||
@@ -100,7 +88,7 @@ git checkout -b release/v0.6.0
|
||||
git push -u origin release/v0.6.0
|
||||
```
|
||||
|
||||
### 3. Release from `release/*` back to `main`
|
||||
### 2. Release from `release/*` back to `main`
|
||||
|
||||
When release preparation is complete, merge the release branch back into `main` and create a tag:
|
||||
|
||||
@@ -113,9 +101,9 @@ git tag v0.6.0
|
||||
git push origin v0.6.0
|
||||
```
|
||||
|
||||
### 4. Sync `main` back to `dev` after release
|
||||
### 3. Sync `main` back to `dev` after release
|
||||
|
||||
After the release, the same automation still applies. If needed, you can run the workflow manually (`workflow_dispatch`) or execute the fallback commands:
|
||||
After the release, sync `main` back into `dev` so the next iteration starts from the released code line:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
感谢你对本项目的贡献。
|
||||
|
||||
本项目采用“发布优先(`main` 为默认分支)+ `release/*` 分支发版”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
|
||||
本项目当前采用“`dev` 作为默认集成分支,`main` 作为稳定发布分支,`release/*` 负责发版准备”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
|
||||
|
||||
---
|
||||
|
||||
## 分支模型
|
||||
|
||||
- `main`:稳定发布分支,也是仓库默认分支
|
||||
- `dev`:日常开发集成分支,主要供维护者使用
|
||||
- `dev`:默认分支,也是日常开发集成分支
|
||||
- `main`:稳定发布分支
|
||||
- `release/*`:发布准备分支,主要供维护者使用
|
||||
- 外部贡献者建议使用以下分支命名:
|
||||
- `fix/*`:问题修复
|
||||
@@ -25,21 +25,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||
|
||||
## 外部贡献者如何提 Pull Request
|
||||
|
||||
无论是 `fix/*` 还是 `feature/*`,**外部贡献者统一直接向 `main` 发起 Pull Request**。
|
||||
无论是 `fix/*` 还是 `feature/*`,**外部贡献者统一直接向 `dev` 发起 Pull Request**。
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- `main` 是默认分支,PR 入口更直观
|
||||
- 合并后贡献会直接体现在默认分支
|
||||
- 便于维护者统一做后续同步与发版整理
|
||||
- `dev` 是当前日常集成分支,评审与合入路径和维护者开发流程一致
|
||||
- 外部贡献会直接进入触发日常校验和 dev 构建的分支
|
||||
- 维护者可以直接从 `dev` 切 `release/*`,减少额外同步步骤
|
||||
|
||||
建议流程:
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 从你自己的仓库创建分支(建议命名为 `fix/*` 或 `feature/*`)
|
||||
2. 先同步你 fork 中的 `dev`,再从 `dev` 创建分支(建议命名为 `fix/*` 或 `feature/*`)
|
||||
3. 完成代码修改,并进行必要自检
|
||||
4. 推送到你的远程分支
|
||||
5. 向本仓库的 `main` 分支发起 Pull Request
|
||||
5. 向本仓库的 `dev` 分支发起 Pull Request
|
||||
|
||||
---
|
||||
|
||||
@@ -63,33 +63,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||
|
||||
## PR 合并策略(维护者)
|
||||
|
||||
`main` 分支上的 PR 建议使用 **Squash and merge**。
|
||||
`dev` 分支上的 PR 建议使用 **Squash and merge**。
|
||||
|
||||
原因:
|
||||
|
||||
- 保持 `main` 历史干净、线性
|
||||
- 每个 PR 在 `main` 上对应一个清晰提交
|
||||
- 降低发布排查与回滚成本
|
||||
- 保持 `dev` 集成历史清晰、便于审查
|
||||
- 每个 PR 在 `dev` 上对应一个明确的集成提交
|
||||
- 降低发版前整理与冲突处理成本
|
||||
|
||||
---
|
||||
|
||||
## 维护者同步规则
|
||||
|
||||
由于外部 PR 会直接合入 `main`,维护者必须及时将 `main` 的变更同步到开发与发布分支,避免分支漂移。
|
||||
由于外部 PR 会直接合入 `dev`,维护者应将 `dev` 作为日常协作与发版准备的主线分支。
|
||||
|
||||
### 1. main → dev 同步(必做)
|
||||
|
||||
仓库已移除 GitHub Actions 自动回灌 workflow。
|
||||
当前统一采用手动方式将 `main` 同步回 `dev`:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git merge main
|
||||
git push
|
||||
```
|
||||
|
||||
### 2. 发版前从 dev 切 release/*
|
||||
### 1. 发版前从 dev 切 release/*
|
||||
|
||||
发布前由维护者基于 `dev` 创建发布分支,例如:
|
||||
|
||||
@@ -100,7 +88,7 @@ git checkout -b release/v0.6.0
|
||||
git push -u origin release/v0.6.0
|
||||
```
|
||||
|
||||
### 3. release/* → main 发版
|
||||
### 2. release/* → main 发版
|
||||
|
||||
发布准备完成后,将 `release/*` 合并回 `main`,并打标签发布:
|
||||
|
||||
@@ -113,9 +101,9 @@ git tag v0.6.0
|
||||
git push origin v0.6.0
|
||||
```
|
||||
|
||||
### 4. main 回流到 dev(发版后必做)
|
||||
### 3. main 回流到 dev(发版后必做)
|
||||
|
||||
发布完成后,仍沿用同一套自动化流程;如有需要,也可以手动触发 `workflow_dispatch`,或执行以下兜底命令,确保开发线与发布线一致:
|
||||
发布完成后,需要将 `main` 回流到 `dev`,确保下一轮开发从已发布代码线继续推进:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
|
||||
35
README.md
35
README.md
@@ -5,6 +5,8 @@
|
||||
[](https://reactjs.org/)
|
||||
[](LICENSE)
|
||||
[](https://github.com/Syngnat/GoNavi/actions)
|
||||
[](https://github.com/Syngnat/GoNavi/stargazers)
|
||||
[](https://github.com/Syngnat/GoNavi/releases)
|
||||
|
||||
**Language**: English | [简体中文](README.zh-CN.md)
|
||||
|
||||
@@ -53,19 +55,24 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
|
||||
<h2 align="center">📸 Screenshots</h2>
|
||||
|
||||
<div align="center">
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
|
||||
<br />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### AI Assistant (New)
|
||||
- **Multi-provider Support**: OpenAI, Google Gemini, Anthropic Claude, and custom API support.
|
||||
- **Context-Aware Chat**: Attach table schemas to the AI context for accurate SQL generation and assistance.
|
||||
- **Slash Commands**: Quick commands for generating SQL, explaining queries, optimizing performance, and reviewing schema designs.
|
||||
|
||||
### Performance
|
||||
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
|
||||
- **Virtualized rendering**: keeps large result sets responsive.
|
||||
@@ -205,7 +212,21 @@ For the full workflow, branch model, and maintainer sync rules, see:
|
||||
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
External contributors should open pull requests directly against `main`.
|
||||
External contributors should branch from `dev` and open pull requests against `dev`.
|
||||
|
||||
## Star History
|
||||
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Links
|
||||
|
||||
- [linux.do](https://linux.do/)
|
||||
- [AIBook](https://aibook.ren/)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
[](https://reactjs.org/)
|
||||
[](LICENSE)
|
||||
[](https://github.com/Syngnat/GoNavi/actions)
|
||||
[](https://github.com/Syngnat/GoNavi/stargazers)
|
||||
[](https://github.com/Syngnat/GoNavi/releases)
|
||||
|
||||
**语言**: [English](README.md) | 简体中文
|
||||
|
||||
@@ -52,19 +54,24 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做
|
||||
<h2 align="center">📸 项目截图</h2>
|
||||
|
||||
<div align="center">
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
|
||||
<br />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 核心特性
|
||||
|
||||
### AI 智能助手 (New)
|
||||
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude,同时支持任意自定义兼容 OpenAI 格式的 API。
|
||||
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI,让 SQL 生成、分析变得更精准。
|
||||
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等)。
|
||||
|
||||
### 性能与交互
|
||||
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
|
||||
- 虚拟滚动渲染,降低大结果集卡顿风险。
|
||||
@@ -188,7 +195,22 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0
|
||||
|
||||
- [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)
|
||||
|
||||
外部贡献者统一直接向 `main` 发起 Pull Request。
|
||||
外部贡献者应从 `dev` 拉出分支,并统一向 `dev` 发起 Pull Request。
|
||||
|
||||
## Star History (Star 增长趋势)
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 友情链接
|
||||
|
||||
- [linux.do](https://linux.do/)
|
||||
- [AI全书](https://aibook.ren/)
|
||||
|
||||
## 开源协议
|
||||
|
||||
|
||||
9
assets_dev.go
Normal file
9
assets_dev.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build dev
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
// 开发模式下由 Wails DevServer 提供前端资源,这里只提供一个稳定的占位 FS,
|
||||
// 避免编译时依赖 frontend/dist 被并发重建。
|
||||
var assets = os.DirFS(".")
|
||||
13
assets_prod.go
Normal file
13
assets_prod.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !dev
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var embeddedAssets embed.FS
|
||||
|
||||
var assets fs.FS = embeddedAssets
|
||||
@@ -152,6 +152,8 @@ echo "🚀 开始构建 optional-driver-agent"
|
||||
echo " 平台:$goos/$goarch"
|
||||
echo " 输出目录:$output_dir_abs"
|
||||
echo " 驱动列表:${drivers[*]}"
|
||||
echo "🧭 生成 driver-agent revision 指纹"
|
||||
"$SCRIPT_DIR/tools/generate-driver-agent-revisions.sh" --platform "$target_platform"
|
||||
|
||||
for driver in "${drivers[@]}"; do
|
||||
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
|
||||
|
||||
341
build-release.sh
341
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,229 +110,101 @@ 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}"
|
||||
generate_driver_agent_revisions "darwin/${platform}"
|
||||
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"
|
||||
}
|
||||
|
||||
generate_driver_agent_revisions() {
|
||||
local platform="$1"
|
||||
echo " 🧭 正在生成 driver-agent revision 指纹 (${platform})..."
|
||||
./tools/generate-driver-agent-revisions.sh --platform "$platform"
|
||||
}
|
||||
|
||||
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}"
|
||||
if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
|
||||
generate_driver_agent_revisions "windows/amd64"
|
||||
wails build -platform windows/amd64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
|
||||
@@ -323,6 +221,7 @@ fi
|
||||
# --- Windows ARM64 构建 ---
|
||||
echo -e "${GREEN}🪟 正在构建 Windows (arm64)...${NC}"
|
||||
if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
|
||||
generate_driver_agent_revisions "windows/arm64"
|
||||
wails build -platform windows/arm64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe"
|
||||
@@ -345,6 +244,7 @@ CURRENT_ARCH=$(uname -m)
|
||||
|
||||
if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
|
||||
# 本机 Linux amd64,直接构建
|
||||
generate_driver_agent_revisions "linux/amd64"
|
||||
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
||||
@@ -365,6 +265,7 @@ elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
|
||||
export CC=x86_64-linux-gnu-gcc
|
||||
export CXX=x86_64-linux-gnu-g++
|
||||
export CGO_ENABLED=1
|
||||
generate_driver_agent_revisions "linux/amd64"
|
||||
wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64"
|
||||
@@ -389,6 +290,7 @@ fi
|
||||
echo -e "${GREEN}🐧 正在构建 Linux (arm64)...${NC}"
|
||||
if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
|
||||
# 本机 Linux arm64,直接构建
|
||||
generate_driver_agent_revisions "linux/arm64"
|
||||
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
||||
@@ -408,6 +310,7 @@ elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
|
||||
export CC=aarch64-linux-gnu-gcc
|
||||
export CXX=aarch64-linux-gnu-g++
|
||||
export CGO_ENABLED=1
|
||||
generate_driver_agent_revisions "linux/arm64"
|
||||
wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
TARGET_LINUX_BIN="$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64"
|
||||
|
||||
339
cmd/manualtestseed/main.go
Normal file
339
cmd/manualtestseed/main.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
aiservice "GoNavi-Wails/internal/ai/service"
|
||||
"GoNavi-Wails/internal/app"
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/secretstore"
|
||||
)
|
||||
|
||||
const (
|
||||
modeSeedSecureStorage = "seed-secure-storage"
|
||||
modeSeedAIUpdate = "seed-ai-update"
|
||||
)
|
||||
|
||||
const (
|
||||
testConnectionID = "manualtest-postgres"
|
||||
testSecureProviderID = "manualtest-secure-provider"
|
||||
testPendingProviderID = "manualtest-pending-provider"
|
||||
testBackupDirName = "manual-test-backups"
|
||||
connectionsFileName = "connections.json"
|
||||
globalProxyFileName = "global_proxy.json"
|
||||
aiConfigFileName = "ai_config.json"
|
||||
securityUpdateFileName = "config-security-update.json"
|
||||
)
|
||||
|
||||
type backupManifest struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ConfigDir string `json:"configDir"`
|
||||
Files []backupManifestFile `json:"files"`
|
||||
}
|
||||
|
||||
type backupManifestFile struct {
|
||||
RelativePath string `json:"relativePath"`
|
||||
Existed bool `json:"existed"`
|
||||
}
|
||||
|
||||
type storedAIConfig struct {
|
||||
SchemaVersion int `json:"schemaVersion,omitempty"`
|
||||
Providers []ai.ProviderConfig `json:"providers"`
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
SafetyLevel string `json:"safetyLevel"`
|
||||
ContextLevel string `json:"contextLevel"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
mode := flag.String("mode", modeSeedSecureStorage, "seed mode: seed-secure-storage | seed-ai-update")
|
||||
flag.Parse()
|
||||
|
||||
configDir, err := resolveConfigDir()
|
||||
if err != nil {
|
||||
fatalf("resolve config dir failed: %v", err)
|
||||
}
|
||||
|
||||
store := secretstore.NewKeyringStore()
|
||||
if err := store.HealthCheck(); err != nil {
|
||||
fatalf("secret store unavailable: %v", err)
|
||||
}
|
||||
|
||||
backupDir, err := backupConfigFiles(configDir)
|
||||
if err != nil {
|
||||
fatalf("backup config files failed: %v", err)
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(*mode) {
|
||||
case modeSeedSecureStorage:
|
||||
if err := seedSecureStorage(configDir, store); err != nil {
|
||||
fatalf("seed secure storage failed: %v", err)
|
||||
}
|
||||
fmt.Printf("mode=%s\nbackup=%s\nconnectionId=%s\nproviderId=%s\n", modeSeedSecureStorage, backupDir, testConnectionID, testSecureProviderID)
|
||||
case modeSeedAIUpdate:
|
||||
if err := seedAIUpdate(configDir, store); err != nil {
|
||||
fatalf("seed ai update failed: %v", err)
|
||||
}
|
||||
fmt.Printf("mode=%s\nbackup=%s\npendingProviderId=%s\n", modeSeedAIUpdate, backupDir, testPendingProviderID)
|
||||
default:
|
||||
fatalf("unsupported mode: %s", *mode)
|
||||
}
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func resolveConfigDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(homeDir, ".gonavi"), nil
|
||||
}
|
||||
|
||||
func backupConfigFiles(configDir string) (string, error) {
|
||||
backupDir := filepath.Join(configDir, testBackupDirName, time.Now().Format("20060102-150405"))
|
||||
files := []string{
|
||||
connectionsFileName,
|
||||
globalProxyFileName,
|
||||
aiConfigFileName,
|
||||
filepath.Join("migrations", securityUpdateFileName),
|
||||
}
|
||||
|
||||
manifest := backupManifest{
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
ConfigDir: configDir,
|
||||
Files: make([]backupManifestFile, 0, len(files)),
|
||||
}
|
||||
|
||||
for _, relativePath := range files {
|
||||
srcPath := filepath.Join(configDir, relativePath)
|
||||
info, err := os.Stat(srcPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
manifest.Files = append(manifest.Files, backupManifestFile{
|
||||
RelativePath: relativePath,
|
||||
Existed: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(backupDir, relativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
manifest.Files = append(manifest.Files, backupManifestFile{
|
||||
RelativePath: relativePath,
|
||||
Existed: true,
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
manifestData, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupDir, "manifest.json"), manifestData, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return backupDir, nil
|
||||
}
|
||||
|
||||
func seedSecureStorage(configDir string, store secretstore.SecretStore) error {
|
||||
if err := cleanupKnownTestSecrets(store); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appService := app.NewAppWithSecretStore(store)
|
||||
_ = appService.DeleteConnection(testConnectionID)
|
||||
|
||||
if _, err := appService.SaveConnection(connection.SavedConnectionInput{
|
||||
ID: testConnectionID,
|
||||
Name: "手工测试 PostgreSQL",
|
||||
Config: connection.ConnectionConfig{
|
||||
ID: testConnectionID,
|
||||
Type: "postgres",
|
||||
Host: "127.0.0.1",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "manualtest-pg-secret",
|
||||
Database: "postgres",
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := appService.SaveGlobalProxy(connection.SaveGlobalProxyInput{
|
||||
Enabled: true,
|
||||
Type: "http",
|
||||
Host: "127.0.0.1",
|
||||
Port: 7890,
|
||||
User: "manual-test",
|
||||
Password: "manualtest-proxy-secret",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storeConfig := aiservice.NewProviderConfigStore(configDir, store)
|
||||
snapshot, err := storeConfig.LoadRuntime()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot.Providers = filterProviders(snapshot.Providers, testSecureProviderID, testPendingProviderID)
|
||||
snapshot.Providers = append(snapshot.Providers, ai.ProviderConfig{
|
||||
ID: testSecureProviderID,
|
||||
Type: "custom",
|
||||
Name: "手工测试 Secure Provider",
|
||||
APIKey: "manualtest-ai-secret",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4o-mini",
|
||||
APIFormat: "openai",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "Bearer manualtest-header-secret",
|
||||
"X-Trace-Id": "manualtest-visible",
|
||||
},
|
||||
MaxTokens: 2048,
|
||||
Temperature: 0.2,
|
||||
})
|
||||
if snapshot.SafetyLevel == "" {
|
||||
snapshot.SafetyLevel = ai.PermissionReadOnly
|
||||
}
|
||||
if snapshot.ContextLevel == "" {
|
||||
snapshot.ContextLevel = ai.ContextSchemaOnly
|
||||
}
|
||||
return storeConfig.Save(snapshot)
|
||||
}
|
||||
|
||||
func seedAIUpdate(configDir string, store secretstore.SecretStore) error {
|
||||
if err := cleanupKnownTestSecrets(store); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, aiConfigFileName)
|
||||
cfg, err := readStoredAIConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.Providers = filterProviders(cfg.Providers, testSecureProviderID, testPendingProviderID)
|
||||
cfg.Providers = append(cfg.Providers, ai.ProviderConfig{
|
||||
ID: testPendingProviderID,
|
||||
Type: "custom",
|
||||
Name: "手工测试 待迁移 AI",
|
||||
APIKey: "manualtest-ai-update-secret",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4o-mini",
|
||||
APIFormat: "openai",
|
||||
MaxTokens: 1024,
|
||||
})
|
||||
if cfg.SchemaVersion == 0 {
|
||||
cfg.SchemaVersion = 2
|
||||
}
|
||||
if cfg.Providers == nil {
|
||||
cfg.Providers = []ai.ProviderConfig{}
|
||||
}
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(configPath, data, 0o644)
|
||||
}
|
||||
|
||||
func readStoredAIConfig(configPath string) (storedAIConfig, error) {
|
||||
cfg := storedAIConfig{
|
||||
Providers: []ai.ProviderConfig{},
|
||||
SafetyLevel: string(ai.PermissionReadOnly),
|
||||
ContextLevel: string(ai.ContextSchemaOnly),
|
||||
SchemaVersion: 2,
|
||||
ActiveProvider: "",
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
return storedAIConfig{}, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return storedAIConfig{}, err
|
||||
}
|
||||
if cfg.Providers == nil {
|
||||
cfg.Providers = []ai.ProviderConfig{}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func filterProviders(providers []ai.ProviderConfig, excludedIDs ...string) []ai.ProviderConfig {
|
||||
excluded := make(map[string]struct{}, len(excludedIDs))
|
||||
for _, id := range excludedIDs {
|
||||
excluded[strings.TrimSpace(id)] = struct{}{}
|
||||
}
|
||||
filtered := make([]ai.ProviderConfig, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
if _, skip := excluded[strings.TrimSpace(provider.ID)]; skip {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, provider)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func cleanupKnownTestSecrets(store secretstore.SecretStore) error {
|
||||
type secretRef struct {
|
||||
kind string
|
||||
id string
|
||||
}
|
||||
refs := []secretRef{
|
||||
{kind: "connection", id: testConnectionID},
|
||||
{kind: "global-proxy", id: "default"},
|
||||
{kind: "ai-provider", id: testSecureProviderID},
|
||||
{kind: "ai-provider", id: testPendingProviderID},
|
||||
}
|
||||
|
||||
for _, item := range refs {
|
||||
ref, err := secretstore.BuildRef(item.kind, item.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := store.Delete(ref); err != nil && !isIgnorableDeleteError(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isIgnorableDeleteError(err error) bool {
|
||||
if err == nil || os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
message := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(message, "could not be found") ||
|
||||
strings.Contains(message, "not be found in the keyring") ||
|
||||
strings.Contains(message, "element not found")
|
||||
}
|
||||
@@ -37,6 +37,7 @@ type agentResponse struct {
|
||||
const (
|
||||
agentMethodConnect = "connect"
|
||||
agentMethodClose = "close"
|
||||
agentMethodMetadata = "metadata"
|
||||
agentMethodPing = "ping"
|
||||
agentMethodQuery = "query"
|
||||
agentMethodExec = "exec"
|
||||
@@ -131,6 +132,13 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
||||
*inst = nil
|
||||
}
|
||||
return resp
|
||||
case agentMethodMetadata:
|
||||
resp.Data = map[string]string{
|
||||
"driverType": strings.TrimSpace(agentDriverType),
|
||||
"agentRevision": db.OptionalDriverAgentRevision(agentDriverType),
|
||||
"protocolSchema": "json-lines-v1",
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
if *inst == nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
)
|
||||
|
||||
type duckMapLike map[any]any
|
||||
@@ -66,6 +67,33 @@ func TestNormalizeAgentResponseData_KeepByteSlice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestMetadataReportsAgentRevision(t *testing.T) {
|
||||
previousDriverType := agentDriverType
|
||||
previousFactory := agentDatabaseFactory
|
||||
t.Cleanup(func() {
|
||||
agentDriverType = previousDriverType
|
||||
agentDatabaseFactory = previousFactory
|
||||
})
|
||||
agentDriverType = "clickhouse"
|
||||
agentDatabaseFactory = func() db.Database { return nil }
|
||||
|
||||
var inst db.Database
|
||||
resp := handleRequest(&inst, agentRequest{ID: 7, Method: agentMethodMetadata})
|
||||
if !resp.Success {
|
||||
t.Fatalf("metadata request failed: %s", resp.Error)
|
||||
}
|
||||
data, ok := resp.Data.(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("metadata response data type = %T", resp.Data)
|
||||
}
|
||||
if data["driverType"] != "clickhouse" {
|
||||
t.Fatalf("unexpected driver type: %q", data["driverType"])
|
||||
}
|
||||
if data["agentRevision"] != db.OptionalDriverAgentRevision("clickhouse") {
|
||||
t.Fatalf("unexpected agent revision: %q", data["agentRevision"])
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAgentTimeoutDB struct {
|
||||
queryCalled bool
|
||||
queryContextCalled bool
|
||||
|
||||
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
|
||||
234
frontend/package-lock.json
generated
234
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gonavi-client",
|
||||
"version": "0.0.1",
|
||||
"version": "0.6.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gonavi-client",
|
||||
"version": "0.0.1",
|
||||
"version": "0.6.5",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -23,6 +23,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "^3.8.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sql-formatter": "^15.7.0",
|
||||
"uuid": "^9.0.1",
|
||||
@@ -32,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"
|
||||
@@ -1210,6 +1213,42 @@
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1567,6 +1606,18 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1988,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",
|
||||
@@ -2001,6 +2062,12 @@
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "9.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
||||
@@ -3046,6 +3113,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||
@@ -3130,6 +3203,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -3211,6 +3294,12 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -3404,6 +3493,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/inline-style-parser": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||
@@ -5511,6 +5610,29 @@
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -5535,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",
|
||||
@@ -5555,6 +5691,66 @@
|
||||
"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",
|
||||
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/refractor": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
|
||||
@@ -5637,6 +5833,12 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
@@ -5888,6 +6090,12 @@
|
||||
"node": ">=12.22"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -6178,6 +6386,28 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gonavi-client",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.6.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -25,6 +25,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "^3.8.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sql-formatter": "^15.7.0",
|
||||
"uuid": "^9.0.1",
|
||||
@@ -34,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 @@
|
||||
dcb87159cf0f1f6f750d1c4870911d3f
|
||||
d0464f9da25e9356e61652e638c99ffe
|
||||
6
frontend/public/db-icons/sqlserver.svg
Normal file
6
frontend/public/db-icons/sqlserver.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>SQL Server</title>
|
||||
<path fill="#A91D22" d="M4.2 7.25c1.05-1.56 4.53-2.69 8.24-2.69 3.34 0 6.13.91 7.25 2.15.57.64.63 1.29.16 1.87-1 1.27-3.81 2.09-7.18 2.09-3.85 0-7.1-1.03-8.29-2.52-.32-.4-.38-.61-.18-.9Z"/>
|
||||
<path fill="#D63539" d="M5.07 11.11c1.27-1.2 4.24-2.04 7.42-2.04 3.59 0 6.58 1.04 7.34 2.54.27.54.16 1.07-.34 1.55-1.18 1.12-3.89 1.81-7.12 1.81-3.56 0-6.56-.91-7.6-2.25-.4-.52-.31-1.02.3-1.61Z"/>
|
||||
<path fill="#F15F5C" d="M7.2 16.12c1.12-.75 3.11-1.18 5.38-1.18 2.43 0 4.59.52 5.71 1.39.84.65 1 1.42.42 2.05-.92 1-3.09 1.63-5.74 1.63-2.87 0-5.34-.75-6.22-1.88-.53-.68-.36-1.37.45-2.01Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 691 B |
@@ -7,7 +7,7 @@ html, body, #root {
|
||||
}
|
||||
|
||||
body, #root {
|
||||
border-radius: 14px; /* Slightly rounded app window corners */
|
||||
border-radius: var(--gonavi-border-radius); /* Slightly rounded app window corners */
|
||||
}
|
||||
|
||||
/* 侧边栏 Tree 样式优化 */
|
||||
@@ -37,6 +37,41 @@ body, #root {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-content {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-list-holder,
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-list-holder-inner {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-treenode {
|
||||
width: auto;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-node-content-wrapper {
|
||||
width: auto !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree-scroll-shell .ant-tree .ant-tree-title {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-tree {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -340,3 +375,47 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check
|
||||
.driver-manager-hscroll-inner {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.security-update-action-btn.ant-btn,
|
||||
.security-update-action-btn.ant-btn-default,
|
||||
.security-update-action-btn.ant-btn-primary,
|
||||
.security-update-action-btn.ant-btn-text {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.security-update-action-btn.ant-btn:focus,
|
||||
.security-update-action-btn.ant-btn:focus-visible,
|
||||
.security-update-action-btn.ant-btn-default:focus,
|
||||
.security-update-action-btn.ant-btn-default:focus-visible,
|
||||
.security-update-action-btn.ant-btn-primary:focus,
|
||||
.security-update-action-btn.ant-btn-primary:focus-visible,
|
||||
.security-update-action-btn.ant-btn-text:focus,
|
||||
.security-update-action-btn.ant-btn-text:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.security-update-banner {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.security-update-result-card {
|
||||
transition: background 0.22s ease, box-shadow 0.22s ease, transform 0.22s ease;
|
||||
}
|
||||
|
||||
.security-update-result-card-active {
|
||||
animation: security-update-result-pulse 1.8s ease;
|
||||
}
|
||||
|
||||
@keyframes security-update-result-pulse {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
1555
frontend/src/App.tsx
1555
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
@@ -14,11 +19,15 @@ import { AIMessageBubble } from './ai/AIMessageBubble';
|
||||
import { AIChatInput } from './ai/AIChatInput';
|
||||
import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
|
||||
import type { AIComposerNotice } from '../utils/aiComposerNotice';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
buildMissingModelNotice,
|
||||
buildMissingProviderNotice,
|
||||
buildModelFetchFailedNotice,
|
||||
} from '../utils/aiComposerNotice';
|
||||
import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit';
|
||||
import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool';
|
||||
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
|
||||
|
||||
interface AIChatPanelProps {
|
||||
width?: number;
|
||||
@@ -226,9 +235,12 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const resizeStartX = useRef(0);
|
||||
const resizeStartWidth = useRef(0);
|
||||
const toolCallRoundRef = useRef(0); // 连续失败轮次计数
|
||||
const totalToolRoundRef = useRef(0); // 全局工具调用总轮次计数(防止无限循环)
|
||||
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);
|
||||
@@ -245,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(() => {
|
||||
@@ -259,7 +316,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const conn = useStore.getState().connections.find(c => c.id === connectionId);
|
||||
if (conn) {
|
||||
import('../../wailsjs/go/app/App').then(({ DBShowCreateTable }) => {
|
||||
DBShowCreateTable(conn.config as any, dbName, tableName).then(res => {
|
||||
DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, dbName, tableName).then(res => {
|
||||
if (res.success && res.data) {
|
||||
let createSql = '';
|
||||
if (typeof res.data === 'string') createSql = res.data;
|
||||
@@ -304,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();
|
||||
|
||||
@@ -351,7 +413,12 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
if (!activeProvider) return;
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const payload = { ...activeProvider, model: val };
|
||||
const payload = {
|
||||
...activeProvider,
|
||||
model: val,
|
||||
apiKey: activeProvider.apiKey || '',
|
||||
hasSecret: activeProvider.hasSecret ?? Boolean(activeProvider.secretRef),
|
||||
};
|
||||
await Service?.AISaveProvider?.(payload);
|
||||
setActiveProvider(payload);
|
||||
setComposerNotice(null);
|
||||
@@ -486,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);
|
||||
@@ -498,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,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;
|
||||
@@ -517,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;
|
||||
@@ -577,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];
|
||||
@@ -673,12 +781,36 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
if (lastUserMsgIndex >= 0) {
|
||||
const userMsg = historyLocal[lastUserMsgIndex];
|
||||
truncateAIChatMessages(sid, userMsg.id);
|
||||
|
||||
// 重置计数器(与 handleSend 保持一致)
|
||||
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,
|
||||
jvmPlanContext: retryJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext,
|
||||
};
|
||||
addAIChatMessage(sid, connectingMsg);
|
||||
|
||||
const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1);
|
||||
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;
|
||||
@@ -692,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 {
|
||||
@@ -701,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;
|
||||
@@ -783,6 +1027,29 @@ 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;
|
||||
if (totalToolRoundRef.current > MAX_TOOL_CALL_ROUNDS) {
|
||||
updateAIChatMessage(sid, currentAsstMsgId, { loading: false, phase: 'idle' });
|
||||
useStore.getState().addAIChatMessage(sid, {
|
||||
id: genId(), role: 'assistant',
|
||||
content: `⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`,
|
||||
timestamp: Date.now(),
|
||||
jvmPlanContext: inheritedJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext,
|
||||
});
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const results: AIChatMessage[] = [];
|
||||
// 【串行逐条执行 + 实时写入 store】
|
||||
for (const tc of toolCalls) {
|
||||
@@ -805,7 +1072,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
const conn = useStore.getState().connections.find(c => c.id === args.connectionId);
|
||||
if (conn) {
|
||||
try {
|
||||
const dbRes = await DBGetDatabases(conn.config as any);
|
||||
const dbRes = await DBGetDatabases(buildRpcConnectionConfig(conn.config) as any);
|
||||
if (dbRes?.success && Array.isArray(dbRes.data)) {
|
||||
let dNames = dbRes.data.map((r: any) => r.Database || r.database || Object.values(r)[0]);
|
||||
if (dNames.length > 50) dNames = [...dNames.slice(0, 50), '...(截断)'];
|
||||
@@ -826,7 +1093,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
try {
|
||||
const rawDbName = args.dbName || args.database;
|
||||
const safeDbName = rawDbName ? String(rawDbName).trim() : '';
|
||||
const tbRes = await DBGetTables(conn.config as any, safeDbName);
|
||||
const tbRes = await DBGetTables(buildRpcConnectionConfig(conn.config) as any, safeDbName);
|
||||
if (tbRes?.success && Array.isArray(tbRes.data)) {
|
||||
let tNames = tbRes.data.map((r: any) => r.Table || r.table || Object.values(r)[0] as string);
|
||||
if (tNames.length > 150) tNames = [...tNames.slice(0, 150), '...(截断)'];
|
||||
@@ -852,7 +1119,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
|
||||
const safeTable = args.tableName ? String(args.tableName).trim() : '';
|
||||
const { DBGetColumns } = await import('../../wailsjs/go/app/App');
|
||||
const colRes = await DBGetColumns(conn.config as any, safeDbName, safeTable);
|
||||
const colRes = await DBGetColumns(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
|
||||
if (colRes?.success && Array.isArray(colRes.data)) {
|
||||
// 只保留关键字段信息,减少 token 占用
|
||||
const cols = colRes.data.map((c: any) => {
|
||||
@@ -882,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(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}`;
|
||||
}
|
||||
@@ -910,7 +1180,8 @@ SELECT * FROM users WHERE status = 1;
|
||||
}
|
||||
}
|
||||
const { DBQuery } = await import('../../wailsjs/go/app/App');
|
||||
const qRes = await DBQuery(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);
|
||||
@@ -959,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;
|
||||
@@ -972,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);
|
||||
|
||||
@@ -999,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;
|
||||
// 在这里加入长度检查和自动摘要(带上动态限额)
|
||||
@@ -1020,11 +1298,16 @@ SELECT * FROM users WHERE status = 1;
|
||||
}
|
||||
|
||||
const allMessages = [...sysMessages, ...finalMessagesPayload];
|
||||
|
||||
// 【软收敛】超过 10 轮工具调用后,不再传递 tools 参数,从物理层面强制模型只能用文本回答
|
||||
const SOFT_LIMIT_ROUNDS = 10;
|
||||
const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : LOCAL_TOOLS;
|
||||
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (Service?.AIChatStream) {
|
||||
await Service.AIChatStream(sid, allMessages, LOCAL_TOOLS);
|
||||
await Service.AIChatStream(sid, allMessages, chainTools);
|
||||
} else if (Service?.AIChatSend) {
|
||||
const result = await Service.AIChatSend(allMessages, LOCAL_TOOLS);
|
||||
const result = await Service.AIChatSend(allMessages, chainTools);
|
||||
const errR = result?.error || '未知错误';
|
||||
const errC = sanitizeErrorMsg(errR);
|
||||
useStore.getState().addAIChatMessage(sid, {
|
||||
@@ -1032,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);
|
||||
}
|
||||
@@ -1057,7 +1342,12 @@ SELECT * FROM users WHERE status = 1;
|
||||
setComposerNotice(null);
|
||||
|
||||
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('');
|
||||
@@ -1076,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: '模型接入中' });
|
||||
@@ -1128,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);
|
||||
@@ -1137,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 {
|
||||
@@ -1264,7 +1584,8 @@ SELECT * FROM users WHERE status = 1;
|
||||
const handleDeleteMessage = useCallback((id: string) => deleteAIChatMessage(sid, id), [sid, deleteAIChatMessage]);
|
||||
const activeConnectionConfig = useMemo(() => {
|
||||
if (!inferredConnectionId) return undefined;
|
||||
return connections.find(c => c.id === inferredConnectionId)?.config;
|
||||
const connection = connections.find(c => c.id === inferredConnectionId);
|
||||
return connection ? buildRpcConnectionConfig(connection.config) : undefined;
|
||||
}, [inferredConnectionId, connections]);
|
||||
const contextUsageChars = useMemo(() =>
|
||||
messages.reduce((sum, m) => sum + (m.content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0),
|
||||
@@ -1383,6 +1704,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
activeProvider={activeProvider}
|
||||
dynamicModels={dynamicModels}
|
||||
loadingModels={loadingModels}
|
||||
sendShortcutBinding={aiChatSendShortcutBinding}
|
||||
composerNotice={composerNotice}
|
||||
onModelChange={handleModelChange}
|
||||
onFetchModels={fetchDynamicModels}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
|
||||
import { Modal, Button, Input, Select, Form, Checkbox, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
|
||||
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types';
|
||||
import {
|
||||
getProviderFingerprint,
|
||||
getProviderHostname,
|
||||
matchQwenPresetKey,
|
||||
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_CODING_PLAN_MODELS,
|
||||
resolveProviderPresetKey,
|
||||
resolvePresetBaseURL,
|
||||
resolvePresetModelSelection,
|
||||
resolvePresetTransport,
|
||||
@@ -20,7 +18,8 @@ import {
|
||||
PROVIDER_PRESET_GRID_STYLE,
|
||||
PROVIDER_PRESET_CARD_TITLE_STYLE,
|
||||
} 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 {
|
||||
@@ -28,6 +27,7 @@ interface AISettingsModalProps {
|
||||
onClose: () => void;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
focusProviderId?: string;
|
||||
}
|
||||
|
||||
// 预设配置:每个预设映射到后端 type(openai/anthropic/gemini/custom)并附带默认 URL 和 Model
|
||||
@@ -62,28 +62,9 @@ const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
|
||||
const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1];
|
||||
|
||||
const matchProviderPreset = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl'>): ProviderPreset => {
|
||||
const qwenPresetKey = matchQwenPresetKey(provider);
|
||||
if (qwenPresetKey) {
|
||||
return findPreset(qwenPresetKey);
|
||||
}
|
||||
const fingerprint = getProviderFingerprint(provider.baseUrl);
|
||||
const exactPreset = PROVIDER_PRESETS.find(pr =>
|
||||
pr.backendType === provider.type
|
||||
&& fingerprint !== ''
|
||||
&& fingerprint === getProviderFingerprint(pr.defaultBaseUrl)
|
||||
);
|
||||
if (exactPreset) {
|
||||
return exactPreset;
|
||||
}
|
||||
|
||||
const host = getProviderHostname(provider.baseUrl);
|
||||
if (host.endsWith('moonshot.cn')) {
|
||||
return findPreset('moonshot');
|
||||
}
|
||||
return PROVIDER_PRESETS.find(pr => pr.backendType === provider.type && host !== '' && host === getProviderHostname(pr.defaultBaseUrl))
|
||||
|| PROVIDER_PRESETS.find(pr => pr.backendType === provider.type)
|
||||
|| findPreset('custom');
|
||||
const matchProviderPreset = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): ProviderPreset => {
|
||||
const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom');
|
||||
return findPreset(presetKey);
|
||||
};
|
||||
|
||||
const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [
|
||||
@@ -98,7 +79,7 @@ const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; ico
|
||||
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
|
||||
];
|
||||
|
||||
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme }) => {
|
||||
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
|
||||
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
|
||||
const [activeProviderId, setActiveProviderId] = useState<string>('');
|
||||
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
|
||||
@@ -109,6 +90,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
|
||||
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers');
|
||||
const [clearProviderSecret, setClearProviderSecret] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const modalBodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -126,6 +108,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const watchedType = Form.useWatch('type', form);
|
||||
const watchedPresetKey = Form.useWatch('presetKey', form);
|
||||
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
|
||||
const watchedApiKeyInput = Form.useWatch('apiKey', form);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
@@ -152,18 +135,52 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
|
||||
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !focusProviderId) {
|
||||
return;
|
||||
}
|
||||
if (!providers.some((provider) => provider.id === focusProviderId)) {
|
||||
return;
|
||||
}
|
||||
setActiveSection('providers');
|
||||
setActiveProviderId(focusProviderId);
|
||||
}, [focusProviderId, open, providers]);
|
||||
|
||||
const applyProviderEditorSession = useCallback((session: ProviderEditorSession) => {
|
||||
setEditingProvider(session.editingProvider as AIProviderConfig | null);
|
||||
setIsEditing(session.isEditing);
|
||||
setTestStatus(session.testStatus);
|
||||
setClearProviderSecret(session.clearProviderSecret);
|
||||
form.resetFields();
|
||||
if (session.formValues) {
|
||||
form.setFieldsValue(session.formValues);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
const resetProviderEditorSession = useCallback(() => {
|
||||
applyProviderEditorSession(buildClosedProviderEditorSession());
|
||||
}, [applyProviderEditorSession]);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
resetProviderEditorSession();
|
||||
onClose();
|
||||
}, [onClose, resetProviderEditorSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
resetProviderEditorSession();
|
||||
}
|
||||
}, [open, resetProviderEditorSession]);
|
||||
const handleAddProvider = () => {
|
||||
const preset = findPreset('openai');
|
||||
const newProvider: AIProviderConfig = {
|
||||
id: '', type: preset.backendType, name: '', apiKey: '',
|
||||
baseUrl: preset.defaultBaseUrl, model: preset.defaultModel,
|
||||
models: [], maxTokens: 4096, temperature: 0.7,
|
||||
};
|
||||
setEditingProvider({ ...newProvider, presetKey: 'openai' } as any);
|
||||
setIsEditing(true);
|
||||
setTestStatus('idle');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ ...newProvider, presetKey: 'openai', apiFormat: 'openai' });
|
||||
applyProviderEditorSession(buildAddProviderEditorSession({
|
||||
presetKey: 'openai',
|
||||
presetBackendType: preset.backendType,
|
||||
presetBaseUrl: preset.defaultBaseUrl,
|
||||
presetModel: preset.defaultModel,
|
||||
presetModels: preset.models,
|
||||
apiFormat: 'openai',
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEditProvider = (p: AIProviderConfig) => {
|
||||
@@ -174,17 +191,16 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
presetFixedApiFormat: matchedPreset.fixedApiFormat,
|
||||
valuesApiFormat: p.apiFormat,
|
||||
});
|
||||
setEditingProvider(p);
|
||||
setIsEditing(true);
|
||||
setTestStatus('idle');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
...p,
|
||||
type: resolvedTransport.type,
|
||||
models: p.models || [],
|
||||
presetKey: matchedPreset.key,
|
||||
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
|
||||
});
|
||||
applyProviderEditorSession(buildEditProviderEditorSession({
|
||||
provider: { ...p, presetKey: matchedPreset.key } as any,
|
||||
formValues: {
|
||||
...p,
|
||||
type: resolvedTransport.type,
|
||||
models: p.models || [],
|
||||
presetKey: matchedPreset.key,
|
||||
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDeleteProvider = async (id: string) => {
|
||||
@@ -238,12 +254,18 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
presetFixedApiFormat: preset.fixedApiFormat,
|
||||
valuesApiFormat: values.apiFormat,
|
||||
});
|
||||
|
||||
const secretDraft = resolveProviderSecretDraft({
|
||||
hasSecret: editingProvider?.hasSecret,
|
||||
apiKeyInput: values.apiKey,
|
||||
clearSecret: clearProviderSecret,
|
||||
});
|
||||
const payload = {
|
||||
...editingProvider,
|
||||
...values,
|
||||
...resolvedTransport,
|
||||
name: finalName,
|
||||
apiKey: secretDraft.apiKey,
|
||||
hasSecret: secretDraft.hasSecret,
|
||||
model: finalModel,
|
||||
models: resolvedModels,
|
||||
baseUrl: finalBaseUrl,
|
||||
@@ -251,7 +273,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
};
|
||||
// 后端 AISaveProvider 统一处理新增和更新,返回 void,失败抛异常
|
||||
await Service?.AISaveProvider?.(payload);
|
||||
void messageApi.success('已保存'); setIsEditing(false); setEditingProvider(null); void loadConfig();
|
||||
void messageApi.success('已保存'); resetProviderEditorSession(); void loadConfig();
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) { /* antd form validation error, ignore */ }
|
||||
@@ -308,10 +330,20 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
presetFixedApiFormat: preset.fixedApiFormat,
|
||||
valuesApiFormat: values.apiFormat,
|
||||
});
|
||||
const secretDraft = resolveProviderSecretDraft({
|
||||
hasSecret: editingProvider?.hasSecret,
|
||||
apiKeyInput: values.apiKey,
|
||||
clearSecret: clearProviderSecret,
|
||||
});
|
||||
if (secretDraft.mode === 'clear') {
|
||||
throw new Error('测试连接前请填写新的 API Key,或取消清除已保存密钥');
|
||||
}
|
||||
const res = await Service?.AITestProvider?.({
|
||||
...editingProvider,
|
||||
...values,
|
||||
...resolvedTransport,
|
||||
apiKey: secretDraft.apiKey,
|
||||
hasSecret: secretDraft.hasSecret,
|
||||
baseUrl: finalBaseUrl,
|
||||
model: finalModel,
|
||||
models: resolvedModels,
|
||||
@@ -422,7 +454,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
<div>
|
||||
{/* 顶部返回 */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Button size="small" onClick={() => { setIsEditing(false); setEditingProvider(null); }}
|
||||
<Button size="small" onClick={resetProviderEditorSession}
|
||||
style={{ borderRadius: 8 }}>← 返回</Button>
|
||||
<span style={{ fontWeight: 700, fontSize: 16, color: overlayTheme.titleText }}>
|
||||
{editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'}
|
||||
@@ -513,11 +545,25 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
<div style={fieldLabelStyle}>
|
||||
<KeyOutlined style={{ fontSize: 14 }} /> 认证 & 连接
|
||||
</div>
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ required: true, message: '请输入 API Key' }]} style={{ marginBottom: 16 }}>
|
||||
<Input.Password placeholder="sk-... / 你的 API Key"
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || clearProviderSecret || editingProvider?.hasSecret) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); } }]} style={{ marginBottom: editingProvider?.hasSecret ? 8 : 16 }}>
|
||||
<Input.Password placeholder={editingProvider?.hasSecret ? '留空表示继续沿用已保存密钥' : 'sk-... / 你的 API Key'}
|
||||
size="middle"
|
||||
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
|
||||
</Form.Item>
|
||||
{editingProvider?.hasSecret && (
|
||||
<div style={{ marginBottom: 16, padding: '10px 12px', borderRadius: 10, border: `1px solid ${cardBorder}`, background: cardBg }}>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
|
||||
当前已保存 API Key。留空表示继续沿用,输入新值表示替换。
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={clearProviderSecret}
|
||||
disabled={String(watchedApiKeyInput || '').trim() !== ''}
|
||||
onChange={(event) => setClearProviderSecret(event.target.checked)}
|
||||
>
|
||||
清除已保存 API Key
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Endpoint (URL)</span>} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}>
|
||||
@@ -720,7 +766,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
</div>
|
||||
}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onCancel={handleModalClose}
|
||||
footer={null}
|
||||
width={820}
|
||||
styles={{
|
||||
@@ -786,3 +832,9 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
};
|
||||
|
||||
export default AISettingsModal;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
102
frontend/src/components/ConnectionPackagePasswordModal.tsx
Normal file
102
frontend/src/components/ConnectionPackagePasswordModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Checkbox, Input, Modal, Typography } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ConnectionPackagePasswordModalMode = 'import' | 'export';
|
||||
|
||||
export interface ConnectionPackagePasswordModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
mode?: ConnectionPackagePasswordModalMode;
|
||||
includeSecrets?: boolean;
|
||||
useFilePassword?: boolean;
|
||||
password: string;
|
||||
error?: string;
|
||||
confirmLoading?: boolean;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onIncludeSecretsChange?: (value: boolean) => void;
|
||||
onUseFilePasswordChange?: (value: boolean) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConnectionPackagePasswordModal({
|
||||
open,
|
||||
title,
|
||||
mode = 'import',
|
||||
includeSecrets = true,
|
||||
useFilePassword = false,
|
||||
password,
|
||||
error,
|
||||
confirmLoading,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
onIncludeSecretsChange,
|
||||
onUseFilePasswordChange,
|
||||
onPasswordChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConnectionPackagePasswordModalProps) {
|
||||
const isExportMode = mode === 'export';
|
||||
const showFilePasswordInput = isExportMode ? useFilePassword : true;
|
||||
const placeholder = isExportMode ? '请输入文件保护密码(可选)' : '请输入恢复包密码';
|
||||
const helperText = !includeSecrets
|
||||
? '将仅导出连接配置,不包含密码。'
|
||||
: (useFilePassword
|
||||
? '请通过单独渠道将密码告知接收方,不要和文件一起发送。'
|
||||
: '密码已加密保护。如需通过公网传输,建议设置文件保护密码。');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
okText={confirmText}
|
||||
cancelText={cancelText}
|
||||
confirmLoading={confirmLoading}
|
||||
onOk={onConfirm}
|
||||
onCancel={onCancel}
|
||||
destroyOnClose={false}
|
||||
maskClosable={false}
|
||||
>
|
||||
{isExportMode ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Checkbox
|
||||
checked={includeSecrets}
|
||||
onChange={(event) => onIncludeSecretsChange?.(event.target.checked)}
|
||||
>
|
||||
导出连接密码
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={useFilePassword}
|
||||
disabled={!includeSecrets}
|
||||
onChange={(event) => onUseFilePasswordChange?.(event.target.checked)}
|
||||
>
|
||||
设置文件保护密码
|
||||
</Checkbox>
|
||||
</div>
|
||||
) : null}
|
||||
{showFilePasswordInput ? (
|
||||
<Input.Password
|
||||
autoFocus
|
||||
value={password}
|
||||
placeholder={placeholder}
|
||||
disabled={isExportMode && !useFilePassword}
|
||||
onChange={(event) => onPasswordChange(event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
{isExportMode ? (
|
||||
<Text type={useFilePassword ? 'warning' : 'secondary'} style={{ display: 'block', marginTop: 8 }}>
|
||||
{helperText}
|
||||
</Text>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text type="danger" style={{ display: 'block', marginTop: 8 }}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
464
frontend/src/components/DataGrid.ddl.test.tsx
Normal file
464
frontend/src/components/DataGrid.ddl.test.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DataGrid, { buildDataGridCommitChangeSet, GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
|
||||
|
||||
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();
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeValue = (_columnName: string, value: any) => value;
|
||||
const rowKeyToString = (key: any) => String(key);
|
||||
|
||||
const commitColumnGuard = (columnName: string) => (
|
||||
columnName !== GONAVI_ROW_KEY && columnName !== ORACLE_ROWID_LOCATOR_COLUMN
|
||||
);
|
||||
|
||||
describe('DataGrid commit change set', () => {
|
||||
it('uses unique locator values instead of falling back to the whole row', () => {
|
||||
const result = buildDataGridCommitChangeSet({
|
||||
addedRows: [],
|
||||
modifiedRows: {
|
||||
'row-1': { [GONAVI_ROW_KEY]: 'row-1', EMAIL: 'a@example.com', NAME: 'new-name', AGE: 42 },
|
||||
},
|
||||
deletedRowKeys: new Set(),
|
||||
data: [{ [GONAVI_ROW_KEY]: 'row-1', EMAIL: 'a@example.com', NAME: 'old-name', AGE: 42 }],
|
||||
editLocator: {
|
||||
strategy: 'unique-key',
|
||||
columns: ['EMAIL'],
|
||||
valueColumns: ['EMAIL'],
|
||||
readOnly: false,
|
||||
},
|
||||
visibleColumnNames: ['EMAIL', 'NAME', 'AGE'],
|
||||
rowKeyToString,
|
||||
normalizeCommitCellValue: normalizeValue,
|
||||
shouldCommitColumn: commitColumnGuard,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
changes: {
|
||||
inserts: [],
|
||||
updates: [{ keys: { EMAIL: 'a@example.com' }, values: { NAME: 'new-name' } }],
|
||||
deletes: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses hidden Oracle ROWID only as locator and excludes it from update values', () => {
|
||||
const result = buildDataGridCommitChangeSet({
|
||||
addedRows: [],
|
||||
modifiedRows: {
|
||||
'row-1': { [GONAVI_ROW_KEY]: 'row-1', NAME: 'new-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'BBBB' },
|
||||
},
|
||||
deletedRowKeys: new Set(),
|
||||
data: [{ [GONAVI_ROW_KEY]: 'row-1', NAME: 'old-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }],
|
||||
editLocator: {
|
||||
strategy: 'oracle-rowid',
|
||||
columns: ['ROWID'],
|
||||
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
|
||||
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
|
||||
readOnly: false,
|
||||
},
|
||||
visibleColumnNames: ['NAME'],
|
||||
rowKeyToString,
|
||||
normalizeCommitCellValue: normalizeValue,
|
||||
shouldCommitColumn: commitColumnGuard,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
changes: {
|
||||
inserts: [],
|
||||
updates: [{ keys: { ROWID: 'AAAA' }, values: { NAME: 'new-name' } }],
|
||||
deletes: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('commits only writable result columns and maps aliases back to table columns', () => {
|
||||
const result = buildDataGridCommitChangeSet({
|
||||
addedRows: [],
|
||||
modifiedRows: {
|
||||
'row-1': {
|
||||
[GONAVI_ROW_KEY]: 'row-1',
|
||||
DISPLAY_NAME: 'new-name',
|
||||
NAME_UPPER: 'NEW-NAME',
|
||||
},
|
||||
},
|
||||
deletedRowKeys: new Set(),
|
||||
data: [{
|
||||
[GONAVI_ROW_KEY]: 'row-1',
|
||||
ID: 7,
|
||||
DISPLAY_NAME: 'old-name',
|
||||
NAME_UPPER: 'OLD-NAME',
|
||||
}],
|
||||
editLocator: {
|
||||
strategy: 'primary-key',
|
||||
columns: ['ID'],
|
||||
valueColumns: ['ID'],
|
||||
writableColumns: {
|
||||
DISPLAY_NAME: 'NAME',
|
||||
},
|
||||
readOnly: false,
|
||||
},
|
||||
visibleColumnNames: ['DISPLAY_NAME', 'NAME_UPPER'],
|
||||
rowKeyToString,
|
||||
normalizeCommitCellValue: normalizeValue,
|
||||
shouldCommitColumn: commitColumnGuard,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
changes: {
|
||||
inserts: [],
|
||||
updates: [{ keys: { ID: 7 }, values: { NAME: 'new-name' } }],
|
||||
deletes: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when no safe locator is available', () => {
|
||||
const result = buildDataGridCommitChangeSet({
|
||||
addedRows: [],
|
||||
modifiedRows: {
|
||||
'row-1': { [GONAVI_ROW_KEY]: 'row-1', NAME: 'new-name' },
|
||||
},
|
||||
deletedRowKeys: new Set(),
|
||||
data: [{ [GONAVI_ROW_KEY]: 'row-1', NAME: 'old-name' }],
|
||||
editLocator: undefined,
|
||||
visibleColumnNames: ['NAME'],
|
||||
rowKeyToString,
|
||||
normalizeCommitCellValue: normalizeValue,
|
||||
shouldCommitColumn: commitColumnGuard,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, error: '当前结果没有可用的安全行定位方式,无法提交修改。' });
|
||||
});
|
||||
|
||||
it('rejects delete rows when unique locator value is null', () => {
|
||||
const result = buildDataGridCommitChangeSet({
|
||||
addedRows: [],
|
||||
modifiedRows: {},
|
||||
deletedRowKeys: new Set(['row-1']),
|
||||
data: [{ [GONAVI_ROW_KEY]: 'row-1', EMAIL: null, NAME: 'old-name' }],
|
||||
editLocator: {
|
||||
strategy: 'unique-key',
|
||||
columns: ['EMAIL'],
|
||||
valueColumns: ['EMAIL'],
|
||||
readOnly: false,
|
||||
},
|
||||
visibleColumnNames: ['EMAIL', 'NAME'],
|
||||
rowKeyToString,
|
||||
normalizeCommitCellValue: normalizeValue,
|
||||
shouldCommitColumn: commitColumnGuard,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, error: '定位列 EMAIL 的值为空,无法安全提交修改。' });
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
195
frontend/src/components/DataGrid.layout.test.tsx
Normal file
195
frontend/src/components/DataGrid.layout.test.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
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"
|
||||
pkColumns={['id']}
|
||||
/>,
|
||||
);
|
||||
|
||||
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,11 +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 };
|
||||
@@ -22,6 +26,7 @@ type TableDiffSummary = {
|
||||
updates?: number;
|
||||
deletes?: number;
|
||||
same?: number;
|
||||
schemaDiffCount?: number;
|
||||
message?: string;
|
||||
targetTableExists?: boolean;
|
||||
plannedAction?: string;
|
||||
@@ -74,7 +79,10 @@ const toSqlLiteral = (value: any, dbType: string): string => {
|
||||
return value ? 'TRUE' : 'FALSE';
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return `'${value.toISOString().replace(/'/g, "''")}'`;
|
||||
return `'${formatLocalDateTimeLiteral(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
@@ -86,6 +94,20 @@ const toSqlLiteral = (value: any, dbType: string): string => {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
};
|
||||
|
||||
const toTypedSqlLiteral = (value: any, dbType: string, columnType?: string): string => {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = normalizeTemporalLiteralText(value, columnType, false);
|
||||
return toSqlLiteral(normalized, dbType);
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
const normalized = String(columnType || '').trim()
|
||||
? formatLocalDateTimeLiteral(value)
|
||||
: value.toISOString();
|
||||
return toSqlLiteral(normalized, dbType);
|
||||
}
|
||||
return toSqlLiteral(value, dbType);
|
||||
};
|
||||
|
||||
const resolveRedisDbIndex = (raw?: string): number => {
|
||||
const value = Number(String(raw || '').trim());
|
||||
return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0;
|
||||
@@ -100,7 +122,19 @@ const buildSqlPreview = (
|
||||
if (!previewData || !tableName) return { sqlText: '', statementCount: 0 };
|
||||
const tableExpr = quoteSqlTable(dbType, tableName);
|
||||
const pkCol = String(previewData.pkColumn || 'id');
|
||||
const columnTypesByLowerName = previewData?.columnTypes && typeof previewData.columnTypes === 'object'
|
||||
? 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 : [];
|
||||
@@ -118,7 +152,7 @@ const buildSqlPreview = (
|
||||
const columns = Object.keys(row);
|
||||
if (columns.length === 0) return;
|
||||
const colExpr = columns.map((c) => quoteSqlIdent(dbType, c)).join(', ');
|
||||
const valExpr = columns.map((c) => toSqlLiteral(row[c], dbType)).join(', ');
|
||||
const valExpr = columns.map((c) => toTypedSqlLiteral(row[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])).join(', ');
|
||||
statements.push(`INSERT INTO ${tableExpr} (${colExpr}) VALUES (${valExpr});`);
|
||||
});
|
||||
}
|
||||
@@ -134,10 +168,10 @@ const buildSqlPreview = (
|
||||
const setCols = changedColumns.filter((c: string) => String(c) !== pkCol);
|
||||
if (setCols.length === 0) return;
|
||||
const setExpr = setCols
|
||||
.map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toSqlLiteral(source[c], dbType)}`)
|
||||
.map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toTypedSqlLiteral(source[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])}`)
|
||||
.join(', ');
|
||||
statements.push(
|
||||
`UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
|
||||
`UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -147,7 +181,7 @@ const buildSqlPreview = (
|
||||
const pk = String(rowWrap?.pk ?? '');
|
||||
if (selectedDelete.size > 0 && !selectedDelete.has(pk)) return;
|
||||
statements.push(
|
||||
`DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
|
||||
`DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -168,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>('');
|
||||
@@ -181,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');
|
||||
@@ -215,14 +252,11 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const logBoxRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
const normalizeConnConfig = (conn: SavedConnection, database?: string) => ({
|
||||
...conn.config,
|
||||
port: Number((conn.config as any).port),
|
||||
password: conn.config.password || "",
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
|
||||
database: typeof database === 'string' ? database : (conn.config.database || ""),
|
||||
});
|
||||
const normalizeConnConfig = (conn: SavedConnection, database?: string) => (
|
||||
buildRpcConnectionConfig(conn.config, {
|
||||
database: typeof database === 'string' ? database : (conn.config.database || ''),
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -264,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');
|
||||
@@ -312,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('');
|
||||
@@ -357,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 : [];
|
||||
@@ -368,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);
|
||||
@@ -385,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");
|
||||
|
||||
@@ -402,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);
|
||||
@@ -455,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);
|
||||
@@ -482,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;
|
||||
@@ -520,33 +598,21 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
stage: '准备开始',
|
||||
});
|
||||
|
||||
const config = {
|
||||
sourceConfig: {
|
||||
...sConn.config,
|
||||
port: Number((sConn.config as any).port),
|
||||
password: sConn.config.password || "",
|
||||
useSSH: sConn.config.useSSH || false,
|
||||
ssh: sConn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
|
||||
database: sourceDb,
|
||||
},
|
||||
targetConfig: {
|
||||
...tConn.config,
|
||||
port: Number((tConn.config as any).port),
|
||||
password: tConn.config.password || "",
|
||||
useSSH: tConn.config.useSSH || false,
|
||||
ssh: tConn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
|
||||
database: targetDb,
|
||||
},
|
||||
tables: selectedTables,
|
||||
content: syncContent,
|
||||
mode: syncMode,
|
||||
const config = buildDataSyncRequest({
|
||||
sourceConfig: normalizeConnConfig(sConn, sourceDb),
|
||||
targetConfig: normalizeConnConfig(tConn, targetDb),
|
||||
selectedTables,
|
||||
sourceDatasetMode,
|
||||
sourceQuery,
|
||||
syncContent,
|
||||
syncMode,
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
mongoCollectionName,
|
||||
tableOptions,
|
||||
jobId,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await DataSync(config as any);
|
||||
@@ -590,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[] = [];
|
||||
@@ -600,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]);
|
||||
@@ -625,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,
|
||||
@@ -832,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
|
||||
@@ -843,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 ? '迁移模式' : '同步模式'}>
|
||||
@@ -858,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>
|
||||
@@ -881,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>
|
||||
@@ -922,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 && (
|
||||
@@ -1055,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>
|
||||
);
|
||||
@@ -1128,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>
|
||||
@@ -1163,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: (
|
||||
@@ -1268,7 +1444,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
key: 'sql',
|
||||
label: `SQL(${previewSql.statementCount})`,
|
||||
@@ -1277,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}
|
||||
@@ -1309,7 +1493,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{previewSql.sqlText || '-- 当前勾选范围下无 SQL 可预览'}
|
||||
{previewSql.sqlText || (previewHasDataDiff ? '-- 当前勾选范围下无 SQL 可预览' : '-- 当前表结构无可执行变更')}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
|
||||
199
frontend/src/components/DataViewer.primary-key.test.tsx
Normal file
199
frontend/src/components/DataViewer.primary-key.test.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TabData } from '../types';
|
||||
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
|
||||
import DataViewer from './DataViewer';
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'oracle',
|
||||
config: {
|
||||
type: 'oracle',
|
||||
host: '127.0.0.1',
|
||||
port: 1521,
|
||||
user: 'scott',
|
||||
password: '',
|
||||
database: 'ORCLPDB1',
|
||||
},
|
||||
},
|
||||
],
|
||||
addSqlLog: vi.fn(),
|
||||
}));
|
||||
|
||||
const backendApp = vi.hoisted(() => ({
|
||||
DBQuery: vi.fn(),
|
||||
DBGetColumns: vi.fn(),
|
||||
DBGetIndexes: vi.fn(),
|
||||
}));
|
||||
|
||||
const messageApi = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}));
|
||||
|
||||
const dataGridState = vi.hoisted(() => ({
|
||||
latestProps: null as any,
|
||||
}));
|
||||
|
||||
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('antd', () => ({
|
||||
message: messageApi,
|
||||
}));
|
||||
|
||||
vi.mock('./DataGrid', () => ({
|
||||
default: (props: any) => {
|
||||
dataGridState.latestProps = props;
|
||||
return <div data-grid="true" />;
|
||||
},
|
||||
GONAVI_ROW_KEY: '__gonavi_row_key__',
|
||||
}));
|
||||
|
||||
const createTab = (overrides: Partial<TabData> = {}): TabData => ({
|
||||
id: 'tab-1',
|
||||
title: 'EDC_LOG',
|
||||
type: 'table',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'MYCIMLED',
|
||||
tableName: 'EDC_LOG',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const flushPromises = async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
describe('DataViewer safe editing locator', () => {
|
||||
const renderAndReload = async (tab: TabData = createTab()) => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<DataViewer tab={tab} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await dataGridState.latestProps.onReload();
|
||||
});
|
||||
await flushPromises();
|
||||
return renderer!;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dataGridState.latestProps = null;
|
||||
storeState.connections[0].config.type = 'oracle';
|
||||
storeState.connections[0].config.database = 'ORCLPDB1';
|
||||
backendApp.DBQuery.mockResolvedValue({
|
||||
success: true,
|
||||
fields: ['ID', 'NAME'],
|
||||
data: [{ ID: 7, NAME: 'old-name' }],
|
||||
});
|
||||
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
|
||||
});
|
||||
|
||||
it('enables table preview editing after primary keys are loaded', async () => {
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
|
||||
});
|
||||
|
||||
const renderer = await renderAndReload();
|
||||
|
||||
expect(dataGridState.latestProps?.pkColumns).toEqual(['ID']);
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'primary-key',
|
||||
columns: ['ID'],
|
||||
valueColumns: ['ID'],
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('uses a unique index when the table has no primary key', async () => {
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ name: 'EMAIL', key: '' }, { name: 'NAME', key: '' }],
|
||||
});
|
||||
backendApp.DBGetIndexes.mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ name: 'UK_EMAIL', columnName: 'EMAIL', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' }],
|
||||
});
|
||||
|
||||
const renderer = await renderAndReload();
|
||||
|
||||
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'unique-key',
|
||||
columns: ['EMAIL'],
|
||||
valueColumns: ['EMAIL'],
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('uses hidden Oracle ROWID when no primary or unique key is available', async () => {
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }],
|
||||
});
|
||||
backendApp.DBQuery.mockResolvedValue({
|
||||
success: true,
|
||||
fields: ['ID', 'NAME', ORACLE_ROWID_LOCATOR_COLUMN],
|
||||
data: [{ ID: 7, NAME: 'old-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }],
|
||||
});
|
||||
|
||||
const renderer = await renderAndReload();
|
||||
|
||||
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'oracle-rowid',
|
||||
columns: ['ROWID'],
|
||||
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
|
||||
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
expect(backendApp.DBQuery.mock.calls.some((call: any[]) => String(call[2]).includes(`ROWID AS "${ORACLE_ROWID_LOCATOR_COLUMN}"`))).toBe(true);
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('keeps non-Oracle table preview read-only when no safe locator exists', async () => {
|
||||
storeState.connections[0].config.type = 'mysql';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }],
|
||||
});
|
||||
|
||||
const renderer = await renderAndReload(createTab({ dbName: 'main', tableName: 'users', title: 'users' }));
|
||||
|
||||
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'none',
|
||||
readOnly: true,
|
||||
reason: '未检测到主键或可用唯一索引,无法安全提交修改。',
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(true);
|
||||
expect(messageApi.warning).toHaveBeenCalledWith('表 main.users 保持只读:未检测到主键或可用唯一索引,无法安全提交修改。');
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,26 @@
|
||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { TabData, ColumnDefinition } from '../types';
|
||||
import { TabData, ColumnDefinition, IndexDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import { DBQuery, DBGetColumns, DBGetIndexes } from '../../wailsjs/go/app/App';
|
||||
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 { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveApproximateTableCountStrategy } from '../utils/approximateTableCount';
|
||||
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
|
||||
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
buildEffectiveFilterConditions,
|
||||
normalizeQuickWhereCondition,
|
||||
validateQuickWhereCondition,
|
||||
} from '../utils/dataGridWhereFilter';
|
||||
import {
|
||||
ORACLE_ROWID_LOCATOR_COLUMN,
|
||||
resolveEditRowLocator,
|
||||
type EditRowLocator,
|
||||
} from '../utils/rowLocator';
|
||||
import { isOracleLikeDialect } from '../utils/sqlDialect';
|
||||
|
||||
type ViewerPaginationState = {
|
||||
current: number;
|
||||
@@ -14,6 +28,7 @@ type ViewerPaginationState = {
|
||||
total: number;
|
||||
totalKnown: boolean;
|
||||
totalApprox: boolean;
|
||||
approximateTotal?: number;
|
||||
totalCountLoading: boolean;
|
||||
totalCountCancelled: boolean;
|
||||
};
|
||||
@@ -70,28 +85,45 @@ const parseTotalFromCountRow = (row: any): number | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseDuckDBApproxTotalRow = (row: any): number | null => {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const entries = Object.entries(row as Record<string, unknown>);
|
||||
if (entries.length === 0) return null;
|
||||
const buildDataViewerReadOnlyLocator = (reason: string): EditRowLocator => ({
|
||||
strategy: 'none',
|
||||
columns: [],
|
||||
valueColumns: [],
|
||||
readOnly: true,
|
||||
reason,
|
||||
});
|
||||
|
||||
const preferredKeys = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'count', 'total'];
|
||||
for (const preferred of preferredKeys) {
|
||||
for (const [key, raw] of entries) {
|
||||
if (String(key || '').trim().toLowerCase() !== preferred) continue;
|
||||
const parsed = toNonNegativeFiniteNumber(raw);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
const formatDataViewerTableName = (dbName: string, tableName: string): string => (
|
||||
dbName ? `${dbName}.${tableName}` : tableName
|
||||
);
|
||||
|
||||
const getTableColumnNames = (columns: ColumnDefinition[] | undefined): string[] => (
|
||||
(columns || [])
|
||||
.map((column) => String(column?.name || '').trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const resolveDataViewerOrderFallbackColumns = (locator: EditRowLocator | undefined, pkColumns: string[]): string[] => {
|
||||
if (locator && !locator.readOnly && locator.strategy !== 'oracle-rowid') {
|
||||
return locator.valueColumns.length > 0 ? locator.valueColumns : locator.columns;
|
||||
}
|
||||
return pkColumns;
|
||||
};
|
||||
|
||||
const buildDataViewerBaseSelectSQL = (
|
||||
dbType: string,
|
||||
tableName: string,
|
||||
whereSQL: string,
|
||||
locator?: EditRowLocator,
|
||||
): string => {
|
||||
const quotedTableName = quoteQualifiedIdent(dbType, tableName);
|
||||
if (locator?.strategy !== 'oracle-rowid') {
|
||||
return `SELECT * FROM ${quotedTableName} ${whereSQL}`;
|
||||
}
|
||||
|
||||
for (const [key, raw] of entries) {
|
||||
const normalized = String(key || '').trim().toLowerCase();
|
||||
if (normalized.includes('estimate') || normalized.includes('row') || normalized.includes('count') || normalized.includes('total')) {
|
||||
const parsed = toNonNegativeFiniteNumber(raw);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
const alias = 'gonavi_row_source';
|
||||
const rowIDAlias = quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN);
|
||||
return `SELECT ${alias}.*, ${alias}.ROWID AS ${rowIDAlias} FROM ${quotedTableName} ${alias} ${whereSQL}`;
|
||||
};
|
||||
|
||||
const normalizeDuckDBIdentifier = (raw: string): string => {
|
||||
@@ -155,6 +187,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 }>;
|
||||
@@ -185,11 +218,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)
|
||||
@@ -201,11 +235,12 @@ const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
|
||||
};
|
||||
};
|
||||
|
||||
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
|
||||
const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
||||
const [editLocator, setEditLocator] = useState<EditRowLocator | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const connections = useStore(state => state.connections);
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
@@ -214,6 +249,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const countKeyRef = useRef<string>('');
|
||||
const duckdbApproxSeqRef = useRef(0);
|
||||
const duckdbApproxKeyRef = useRef<string>('');
|
||||
const oracleApproxSeqRef = useRef(0);
|
||||
const oracleApproxKeyRef = useRef<string>('');
|
||||
const manualCountSeqRef = useRef(0);
|
||||
const manualCountKeyRef = useRef<string>('');
|
||||
const pkSeqRef = useRef(0);
|
||||
@@ -228,6 +265,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
left: initialViewerSnapshot.scrollLeft,
|
||||
});
|
||||
const initialLoadRef = useRef(false);
|
||||
const skipNextAutoFetchRef = useRef(false);
|
||||
|
||||
const [pagination, setPagination] = useState<ViewerPaginationState>({
|
||||
current: initialViewerSnapshot.currentPage,
|
||||
@@ -243,17 +281,21 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
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);
|
||||
const currentConnType = currentConnCaps.type;
|
||||
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
|
||||
const preferManualTotalCount = currentConnCaps.preferManualTotalCount;
|
||||
const supportsApproximateTableCount = currentConnCaps.supportsApproximateTableCount;
|
||||
const supportsApproximateTotalPages = currentConnCaps.supportsApproximateTotalPages;
|
||||
const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial<ViewerFilterSnapshot>) => {
|
||||
const normalizedTabId = String(tabId || '').trim();
|
||||
if (!normalizedTabId) return;
|
||||
viewerFilterSnapshotsByTab.set(normalizedTabId, {
|
||||
showFilter,
|
||||
conditions: normalizeViewerFilterConditions(filterConditions),
|
||||
quickWhereCondition: normalizeQuickWhereCondition(quickWhereCondition),
|
||||
currentPage: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
sortInfo,
|
||||
@@ -261,12 +303,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
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;
|
||||
@@ -274,7 +317,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
useEffect(() => {
|
||||
persistViewerSnapshot(tab.id);
|
||||
}, [tab.id, persistViewerSnapshot]);
|
||||
}, [persistViewerSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -285,9 +328,11 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
useEffect(() => {
|
||||
const snapshot = getViewerFilterSnapshot(tab.id);
|
||||
setPkColumns([]);
|
||||
setEditLocator(undefined);
|
||||
pkKeyRef.current = '';
|
||||
countKeyRef.current = '';
|
||||
duckdbApproxKeyRef.current = '';
|
||||
oracleApproxKeyRef.current = '';
|
||||
manualCountKeyRef.current = '';
|
||||
duckdbSafeSelectCacheRef.current = {};
|
||||
latestConfigRef.current = null;
|
||||
@@ -297,6 +342,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
latestCountKeyRef.current = '';
|
||||
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
|
||||
initialLoadRef.current = false;
|
||||
skipNextAutoFetchRef.current = true;
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: snapshot.currentPage,
|
||||
@@ -304,6 +350,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total: 0,
|
||||
totalKnown: false,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
@@ -317,10 +364,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
});
|
||||
}, [tab.id, persistViewerSnapshot]);
|
||||
|
||||
const handleDuckDBManualCount = useCallback(async () => {
|
||||
if (latestDbTypeRef.current !== 'duckdb') {
|
||||
return;
|
||||
}
|
||||
const handleManualTotalCount = useCallback(async () => {
|
||||
const config = latestConfigRef.current;
|
||||
const dbName = latestDbNameRef.current;
|
||||
const countSql = latestCountSqlRef.current;
|
||||
@@ -335,13 +379,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const countSeq = ++manualCountSeqRef.current;
|
||||
const countStart = Date.now();
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: true, totalCountCancelled: false }));
|
||||
const countConfig: any = { ...(config as any), timeout: 120 };
|
||||
const countConfig = buildRpcConnectionConfig(config, { timeout: 120 });
|
||||
|
||||
try {
|
||||
const resCount = await DBQuery(countConfig as any, dbName, countSql);
|
||||
const countDuration = Date.now() - countStart;
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-duckdb-manual-count`,
|
||||
id: `log-${Date.now()}-manual-count`,
|
||||
timestamp: Date.now(),
|
||||
sql: countSql,
|
||||
status: resCount?.success ? 'success' : 'error',
|
||||
@@ -375,6 +419,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total,
|
||||
totalKnown: true,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
@@ -386,7 +431,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
}, [addSqlLog]);
|
||||
|
||||
const handleDuckDBCancelManualCount = useCallback(() => {
|
||||
const handleCancelManualTotalCount = useCallback(() => {
|
||||
manualCountSeqRef.current++;
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true }));
|
||||
}, []);
|
||||
@@ -410,9 +455,17 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
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 || '';
|
||||
@@ -420,7 +473,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
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);
|
||||
@@ -430,15 +483,97 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const whereSQL = isMongoDB
|
||||
? JSON.stringify(mongoFilter || {})
|
||||
: buildWhereSQL(dbType, filterConditions);
|
||||
: buildWhereSQL(dbType, effectiveFilterConditions);
|
||||
|
||||
let pkColumnsForQuery = pkColumns;
|
||||
let editLocatorForQuery = editLocator;
|
||||
if (!isMongoDB && !forceReadOnly && tableName) {
|
||||
const locatorKey = `${tab.connectionId}|${dbTypeLower}|${dbName}|${tableName}`;
|
||||
if (pkKeyRef.current !== locatorKey || !editLocatorForQuery) {
|
||||
pkKeyRef.current = locatorKey;
|
||||
const locatorSeq = ++pkSeqRef.current;
|
||||
try {
|
||||
const [resCols, resIndexes] = await Promise.all([
|
||||
DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName),
|
||||
DBGetIndexes(buildRpcConnectionConfig(config) as any, dbName, tableName)
|
||||
.catch((error: any) => ({ success: false, message: String(error?.message || error || '加载索引失败'), data: [] })),
|
||||
]);
|
||||
if (fetchSeqRef.current !== seq) return;
|
||||
if (pkSeqRef.current !== locatorSeq) return;
|
||||
if (pkKeyRef.current !== locatorKey) return;
|
||||
|
||||
if (!resCols?.success || !Array.isArray(resCols.data)) {
|
||||
const nextLocator = buildDataViewerReadOnlyLocator('无法加载主键/唯一索引元数据,无法安全提交修改。');
|
||||
pkColumnsForQuery = [];
|
||||
editLocatorForQuery = nextLocator;
|
||||
setPkColumns([]);
|
||||
setEditLocator(nextLocator);
|
||||
message.warning(`表 ${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason}`);
|
||||
} else {
|
||||
const columnDefs = resCols.data as ColumnDefinition[];
|
||||
const primaryKeys = columnDefs
|
||||
.filter((column: any) => column?.key === 'PRI')
|
||||
.map((column: any) => String(column?.name || '').trim())
|
||||
.filter(Boolean);
|
||||
const indexes = resIndexes?.success && Array.isArray(resIndexes.data)
|
||||
? resIndexes.data as IndexDefinition[]
|
||||
: [];
|
||||
const resultColumns = getTableColumnNames(columnDefs);
|
||||
const locatorColumns = isOracleLikeDialect(dbType)
|
||||
? [...resultColumns, ORACLE_ROWID_LOCATOR_COLUMN]
|
||||
: resultColumns;
|
||||
let nextLocator = resolveEditRowLocator({
|
||||
dbType,
|
||||
resultColumns: locatorColumns,
|
||||
primaryKeys,
|
||||
indexes,
|
||||
allowOracleRowID: true,
|
||||
});
|
||||
|
||||
if (nextLocator.readOnly && primaryKeys.length === 0 && !resIndexes?.success && !isOracleLikeDialect(dbType)) {
|
||||
nextLocator = buildDataViewerReadOnlyLocator('无法加载唯一索引元数据,无法安全提交修改。');
|
||||
}
|
||||
|
||||
pkColumnsForQuery = primaryKeys;
|
||||
editLocatorForQuery = nextLocator;
|
||||
setPkColumns(primaryKeys);
|
||||
setEditLocator(nextLocator);
|
||||
if (nextLocator.readOnly) {
|
||||
message.warning(`表 ${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason || '当前结果没有可用的安全行定位方式,无法提交修改。'}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (fetchSeqRef.current !== seq) return;
|
||||
if (pkSeqRef.current !== locatorSeq) return;
|
||||
if (pkKeyRef.current !== locatorKey) return;
|
||||
const nextLocator = buildDataViewerReadOnlyLocator('无法加载主键/唯一索引元数据,无法安全提交修改。');
|
||||
pkColumnsForQuery = [];
|
||||
editLocatorForQuery = nextLocator;
|
||||
setPkColumns([]);
|
||||
setEditLocator(nextLocator);
|
||||
message.warning(`表 ${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const countSql = isMongoDB
|
||||
? buildMongoCountCommand(tableName, mongoFilter || {})
|
||||
: `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
const orderBySQL = isMongoDB ? '' : buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
const orderBySQL = isMongoDB
|
||||
? ''
|
||||
: buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocatorForQuery, pkColumnsForQuery));
|
||||
const totalRows = Number(pagination.total);
|
||||
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
|
||||
const totalKnown = pagination.totalKnown && hasFiniteTotal;
|
||||
const totalPages = hasFiniteTotal ? Math.max(1, Math.ceil(totalRows / size)) : 0;
|
||||
const approximateTotalRows = Number(pagination.approximateTotal);
|
||||
const hasApproximateTotalPages =
|
||||
!totalKnown &&
|
||||
supportsApproximateTotalPages &&
|
||||
pagination.totalApprox &&
|
||||
Number.isFinite(approximateTotalRows) &&
|
||||
approximateTotalRows > 0;
|
||||
const effectiveTotalRows = hasApproximateTotalPages ? approximateTotalRows : totalRows;
|
||||
const totalPages = Number.isFinite(effectiveTotalRows) && effectiveTotalRows > 0 ? Math.max(1, Math.ceil(effectiveTotalRows / size)) : 0;
|
||||
const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page);
|
||||
const offset = (currentPage - 1) * size;
|
||||
const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse';
|
||||
@@ -457,7 +592,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
skip: offset,
|
||||
});
|
||||
} else {
|
||||
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
const baseSql = buildDataViewerBaseSelectSQL(dbType, tableName, whereSQL, editLocatorForQuery);
|
||||
sql = `${baseSql}${orderBySQL}`;
|
||||
// ClickHouse 深分页在超大 OFFSET 下容易超时。对于总数已知且存在 ORDER BY 的场景,
|
||||
// 当“尾部偏移”小于“头部偏移”时,改为反向 ORDER BY + 小 OFFSET,并在前端翻转结果。
|
||||
@@ -485,7 +620,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const executeDataQuery = async (querySql: string, attemptLabel: string) => {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await DBQuery(config as any, dbName, querySql);
|
||||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, querySql);
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-data`,
|
||||
timestamp: Date.now(),
|
||||
@@ -521,7 +656,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
let safeSelect = duckdbSafeSelectCacheRef.current[cacheKey] || '';
|
||||
if (!safeSelect) {
|
||||
try {
|
||||
const resCols = await DBGetColumns(config as any, dbName, tableName);
|
||||
const resCols = await DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName);
|
||||
if (resCols?.success && Array.isArray(resCols.data)) {
|
||||
const columnDefs = resCols.data as ColumnDefinition[];
|
||||
const selectParts = columnDefs.map((col) => {
|
||||
@@ -545,7 +680,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
if (safeSelect) {
|
||||
let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, pkColumns), size + 1, offset);
|
||||
fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocatorForQuery, pkColumnsForQuery)), size + 1, offset);
|
||||
executedSql = fallbackSql;
|
||||
resData = await executeDataQuery(fallbackSql, '复杂类型降级重试');
|
||||
}
|
||||
@@ -568,26 +703,6 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
message.warning('已自动提升排序缓冲并重试成功。');
|
||||
}
|
||||
}
|
||||
|
||||
if (pkColumns.length === 0) {
|
||||
const pkKey = `${tab.connectionId}|${dbName}|${tableName}`;
|
||||
if (pkKeyRef.current !== pkKey) {
|
||||
pkKeyRef.current = pkKey;
|
||||
const pkSeq = ++pkSeqRef.current;
|
||||
DBGetColumns(config as any, dbName, tableName)
|
||||
.then((resCols: any) => {
|
||||
if (pkSeqRef.current !== pkSeq) return;
|
||||
if (pkKeyRef.current !== pkKey) return;
|
||||
if (!resCols?.success) return;
|
||||
const pks = (resCols.data as ColumnDefinition[]).filter((c: any) => c.key === 'PRI').map((c: any) => c.name);
|
||||
setPkColumns(pks);
|
||||
})
|
||||
.catch(() => {
|
||||
if (pkSeqRef.current !== pkSeq) return;
|
||||
if (pkKeyRef.current !== pkKey) return;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (resData.success) {
|
||||
let resultData = resData.data as any[];
|
||||
@@ -632,6 +747,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total: derivedTotal,
|
||||
totalKnown: true,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
};
|
||||
@@ -647,13 +763,20 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
}
|
||||
const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey;
|
||||
if (isDuckDB && prev.totalApprox && duckdbApproxKeyRef.current === countKey && Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
|
||||
const hasApproximateTotalForCurrentKey =
|
||||
prev.totalApprox &&
|
||||
(duckdbApproxKeyRef.current === countKey || oracleApproxKeyRef.current === countKey) &&
|
||||
Number.isFinite(prev.approximateTotal) &&
|
||||
Number(prev.approximateTotal) >= minExpectedTotal;
|
||||
if (hasApproximateTotalForCurrentKey) {
|
||||
return {
|
||||
...prev,
|
||||
current: currentPage,
|
||||
pageSize: size,
|
||||
total: derivedTotal,
|
||||
totalKnown: false,
|
||||
totalApprox: true,
|
||||
approximateTotal: prev.approximateTotal,
|
||||
totalCountLoading: keepManualCounting,
|
||||
totalCountCancelled: false,
|
||||
};
|
||||
@@ -665,12 +788,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total: derivedTotal,
|
||||
totalKnown: false,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: keepManualCounting,
|
||||
totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled,
|
||||
};
|
||||
});
|
||||
|
||||
const shouldRunAsyncCount = !derivedTotalKnown && !isDuckDB;
|
||||
const shouldRunAsyncCount = !derivedTotalKnown && !preferManualTotalCount;
|
||||
if (shouldRunAsyncCount) {
|
||||
if (countKeyRef.current !== countKey) {
|
||||
countKeyRef.current = countKey;
|
||||
@@ -678,7 +802,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const countStart = Date.now();
|
||||
// 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应;
|
||||
// DuckDB 大文件场景下该统计会显著拖慢翻页,已禁用后台 COUNT。
|
||||
const countConfig: any = { ...(config as any), timeout: 5 };
|
||||
const countConfig = buildRpcConnectionConfig(config, { timeout: 5 });
|
||||
|
||||
DBQuery(countConfig, dbName, countSql)
|
||||
.then((resCount: any) => {
|
||||
@@ -695,7 +819,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
});
|
||||
|
||||
if (countSeqRef.current !== countSeq) return;
|
||||
if (countKeyRef.current !== countKey) return;
|
||||
if (latestCountKeyRef.current !== countKey) return;
|
||||
|
||||
if (!resCount.success) return;
|
||||
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
|
||||
@@ -708,6 +832,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total,
|
||||
totalKnown: true,
|
||||
totalApprox: false,
|
||||
approximateTotal: undefined,
|
||||
totalCountLoading: false,
|
||||
totalCountCancelled: false,
|
||||
}));
|
||||
@@ -720,48 +845,88 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (isDuckDB && !derivedTotalKnown && whereSQL.trim() === '' && duckdbApproxKeyRef.current !== countKey) {
|
||||
duckdbApproxKeyRef.current = countKey;
|
||||
const approxSeq = ++duckdbApproxSeqRef.current;
|
||||
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
|
||||
const escapedSchema = escapeSQLLiteral(schemaName);
|
||||
const escapedTable = escapeSQLLiteral(pureTableName);
|
||||
const approxConfig: any = { ...(config as any), timeout: 3 };
|
||||
const approxSqlCandidates = [
|
||||
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
|
||||
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
|
||||
];
|
||||
if (!derivedTotalKnown) {
|
||||
const approximateCountStrategy = supportsApproximateTableCount
|
||||
? resolveApproximateTableCountStrategy({ dbType: dbTypeLower, whereSQL })
|
||||
: 'none';
|
||||
|
||||
(async () => {
|
||||
for (const approxSql of approxSqlCandidates) {
|
||||
try {
|
||||
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
|
||||
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
||||
if (countKeyRef.current !== countKey) return;
|
||||
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
|
||||
if (approximateCountStrategy === 'duckdb-estimated-size' && duckdbApproxKeyRef.current !== countKey) {
|
||||
duckdbApproxKeyRef.current = countKey;
|
||||
const approxSeq = ++duckdbApproxSeqRef.current;
|
||||
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
|
||||
const escapedSchema = escapeSQLLiteral(schemaName);
|
||||
const escapedTable = escapeSQLLiteral(pureTableName);
|
||||
const approxConfig = buildRpcConnectionConfig(config, { timeout: 3 });
|
||||
const approxSqlCandidates = [
|
||||
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
|
||||
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
|
||||
];
|
||||
|
||||
const approxTotal = parseDuckDBApproxTotalRow(approxRes.data[0]);
|
||||
if (approxTotal === null) continue;
|
||||
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
|
||||
(async () => {
|
||||
for (const approxSql of approxSqlCandidates) {
|
||||
try {
|
||||
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
|
||||
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
||||
if (latestCountKeyRef.current !== countKey) return;
|
||||
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
|
||||
|
||||
const approxTotal = parseApproximateTableCountRow(approxRes.data[0]);
|
||||
if (approxTotal === null) continue;
|
||||
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
|
||||
|
||||
setPagination(prev => {
|
||||
if (latestCountKeyRef.current !== countKey) return prev;
|
||||
if (prev.totalKnown) return prev;
|
||||
return {
|
||||
...prev,
|
||||
totalKnown: false,
|
||||
totalApprox: true,
|
||||
approximateTotal: approxTotal,
|
||||
totalCountCancelled: false,
|
||||
};
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
||||
if (latestCountKeyRef.current !== countKey) return;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (approximateCountStrategy === 'oracle-num-rows' && oracleApproxKeyRef.current !== countKey) {
|
||||
oracleApproxKeyRef.current = countKey;
|
||||
const approxSeq = ++oracleApproxSeqRef.current;
|
||||
const approxConfig = buildRpcConnectionConfig(config, { timeout: 3 });
|
||||
const approxSql = buildOracleApproximateTotalSql({ dbName, tableName });
|
||||
|
||||
DBQuery(approxConfig as any, dbName, approxSql)
|
||||
.then((approxRes: any) => {
|
||||
if (oracleApproxSeqRef.current !== approxSeq) return;
|
||||
if (latestCountKeyRef.current !== countKey) return;
|
||||
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) return;
|
||||
|
||||
const approxTotal = parseApproximateTableCountRow(approxRes.data[0], ['approx_total', 'num_rows', 'estimated_rows', 'row_count', 'count', 'total']);
|
||||
if (approxTotal === null) return;
|
||||
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) return;
|
||||
|
||||
setPagination(prev => {
|
||||
if (countKeyRef.current !== countKey) return prev;
|
||||
if (latestCountKeyRef.current !== countKey) return prev;
|
||||
if (prev.totalKnown) return prev;
|
||||
return {
|
||||
...prev,
|
||||
total: approxTotal,
|
||||
totalKnown: false,
|
||||
totalApprox: true,
|
||||
approximateTotal: approxTotal,
|
||||
totalCountCancelled: false,
|
||||
};
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
||||
if (countKeyRef.current !== countKey) return;
|
||||
}
|
||||
}
|
||||
})();
|
||||
})
|
||||
.catch(() => {
|
||||
if (oracleApproxSeqRef.current !== approxSeq) return;
|
||||
if (latestCountKeyRef.current !== countKey) return;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.error(String(resData.message || '查询失败'));
|
||||
@@ -780,9 +945,9 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
});
|
||||
}
|
||||
if (fetchSeqRef.current === seq) setLoading(false);
|
||||
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown]);
|
||||
// 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。
|
||||
// 主键信息只会在首次加载后更新一次,避免循环查询。
|
||||
}, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, editLocator, forceReadOnly, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
|
||||
// 依赖定位列:在无手动排序时可回退到安全定位列稳定排序。
|
||||
// 定位信息只会在表上下文变化后重新加载,避免循环查询。
|
||||
|
||||
// Handlers memoized
|
||||
const handleReload = useCallback(() => {
|
||||
@@ -808,33 +973,51 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
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}`;
|
||||
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
sql += buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocator, pkColumns));
|
||||
const normalizedType = dbType.toLowerCase();
|
||||
const hasSortForBuffer = hasExplicitSort(sortInfo);
|
||||
if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
|
||||
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, editLocator, pkColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialLoadRef.current) {
|
||||
const action = resolveDataViewerAutoFetchAction({
|
||||
skipNextAutoFetch: skipNextAutoFetchRef.current,
|
||||
hasInitialLoad: initialLoadRef.current,
|
||||
});
|
||||
if (action === 'skip') {
|
||||
skipNextAutoFetchRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (action === 'load-current-page') {
|
||||
initialLoadRef.current = true;
|
||||
fetchData(pagination.current, pagination.pageSize);
|
||||
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' }}>
|
||||
@@ -847,17 +1030,20 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
dbName={tab.dbName}
|
||||
connectionId={tab.connectionId}
|
||||
pkColumns={pkColumns}
|
||||
editLocator={editLocator}
|
||||
onReload={handleReload}
|
||||
onSort={handleSort}
|
||||
onPageChange={handlePageChange}
|
||||
pagination={pagination}
|
||||
onRequestTotalCount={currentConnType === 'duckdb' ? handleDuckDBManualCount : undefined}
|
||||
onCancelTotalCount={currentConnType === 'duckdb' ? handleDuckDBCancelManualCount : undefined}
|
||||
onRequestTotalCount={preferManualTotalCount ? handleManualTotalCount : undefined}
|
||||
onCancelTotalCount={preferManualTotalCount ? handleCancelManualTotalCount : undefined}
|
||||
showFilter={showFilter}
|
||||
onToggleFilter={handleToggleFilter}
|
||||
onApplyFilter={handleApplyFilter}
|
||||
appliedFilterConditions={filterConditions}
|
||||
readOnly={forceReadOnly}
|
||||
quickWhereCondition={quickWhereCondition}
|
||||
onApplyQuickWhereCondition={handleApplyQuickWhereCondition}
|
||||
readOnly={forceReadOnly || !editLocator || editLocator.readOnly}
|
||||
sortInfoExternal={sortInfo}
|
||||
exportSqlWithFilter={exportSqlWithFilter || undefined}
|
||||
scrollSnapshot={scrollSnapshotRef.current}
|
||||
|
||||
@@ -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',
|
||||
@@ -37,7 +38,7 @@ export const getDbDefaultColor = (type: string): string =>
|
||||
|
||||
const BRAND_SVG_TYPES = new Set([
|
||||
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'clickhouse', 'sqlite',
|
||||
'diros', 'sphinx', 'duckdb',
|
||||
'diros', 'sphinx', 'duckdb', 'sqlserver',
|
||||
]);
|
||||
|
||||
/** 品牌 SVG 图标:用 <img> 加载 /db-icons/*.svg */
|
||||
@@ -110,7 +111,7 @@ const OracleIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.oracle} label="Or" />
|
||||
);
|
||||
const SQLServerIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.sqlserver} label="SS" />
|
||||
<BrandSvgIcon type="sqlserver" size={size} color={color} />
|
||||
);
|
||||
const DorisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<BrandSvgIcon type="diros" size={size} color={color} />
|
||||
@@ -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',
|
||||
|
||||
@@ -4,11 +4,29 @@ import { Spin, Alert } from 'antd';
|
||||
import { TabData } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery } from '../../wailsjs/go/app/App';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
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);
|
||||
@@ -201,7 +219,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
const sql = String(query || '').trim();
|
||||
if (!sql) continue;
|
||||
try {
|
||||
const result = await DBQuery(config as any, dbName, sql);
|
||||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
|
||||
if (!result.success || !Array.isArray(result.data)) {
|
||||
lastMessage = result.message || lastMessage;
|
||||
continue;
|
||||
@@ -227,7 +245,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
];
|
||||
for (const query of candidates) {
|
||||
try {
|
||||
const result = await DBQuery(config as any, dbName, query);
|
||||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
|
||||
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -256,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,
|
||||
@@ -11,6 +16,7 @@ import {
|
||||
GetDriverVersionPackageSize,
|
||||
GetDriverStatusList,
|
||||
InstallLocalDriverPackage,
|
||||
OpenDriverDownloadDirectory,
|
||||
RemoveDriverPackage,
|
||||
SelectDriverPackageDirectory,
|
||||
SelectDriverPackageFile,
|
||||
@@ -33,6 +39,11 @@ type DriverStatusRow = {
|
||||
packagePath?: string;
|
||||
executablePath?: string;
|
||||
downloadedAt?: string;
|
||||
agentRevision?: string;
|
||||
expectedRevision?: string;
|
||||
needsUpdate?: boolean;
|
||||
updateReason?: string;
|
||||
affectedConnections?: number;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
@@ -354,6 +365,13 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
packagePath: String(item.packagePath || '').trim() || undefined,
|
||||
executablePath: String(item.executablePath || '').trim() || undefined,
|
||||
downloadedAt: String(item.downloadedAt || '').trim() || undefined,
|
||||
agentRevision: String(item.agentRevision || '').trim() || undefined,
|
||||
expectedRevision: String(item.expectedRevision || '').trim() || undefined,
|
||||
needsUpdate: !!item.needsUpdate,
|
||||
updateReason: String(item.updateReason || '').trim() || undefined,
|
||||
affectedConnections: Number.isFinite(Number(item.affectedConnections))
|
||||
? Number(item.affectedConnections)
|
||||
: undefined,
|
||||
message: String(item.message || '').trim() || undefined,
|
||||
}));
|
||||
setRows(nextRows);
|
||||
@@ -757,6 +775,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
};
|
||||
}, [appendOperationLog, open]);
|
||||
|
||||
const resolveLocalImportVersion = useCallback((row: DriverStatusRow) => {
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const selectedOption =
|
||||
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
|
||||
options.find((item) => item.recommended) ||
|
||||
options[0];
|
||||
return selectedOption?.version || row.pinnedVersion || '';
|
||||
}, [selectedVersionMap, versionMap]);
|
||||
|
||||
const installDriver = useCallback(async (row: DriverStatusRow) => {
|
||||
setActionState({ driverType: row.type, kind: 'install' });
|
||||
setProgressMap((prev) => ({
|
||||
@@ -820,9 +848,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
percent: 0,
|
||||
},
|
||||
}));
|
||||
appendOperationLog(row.type, `[START] 开始本地导入(${sourceLabel}):${pathText}`);
|
||||
const selectedVersion = resolveLocalImportVersion(row);
|
||||
const versionTip = selectedVersion ? `(${selectedVersion})` : '';
|
||||
appendOperationLog(row.type, `[START] 开始本地导入${versionTip}(${sourceLabel}):${pathText}`);
|
||||
try {
|
||||
const result = await InstallLocalDriverPackage(row.type, pathText, downloadDir);
|
||||
const result = await InstallLocalDriverPackage(row.type, pathText, downloadDir, selectedVersion);
|
||||
if (!result?.success) {
|
||||
const errText = result?.message || `导入 ${row.name} 本地驱动包失败`;
|
||||
appendOperationLog(row.type, `[ERROR] ${errText}`);
|
||||
@@ -831,9 +861,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
}
|
||||
return false;
|
||||
}
|
||||
appendOperationLog(row.type, '[DONE] 本地导入安装完成');
|
||||
appendOperationLog(row.type, `[DONE] 本地导入安装完成 ${versionTip}`.trim());
|
||||
if (!options?.silentToast) {
|
||||
message.success(`${row.name} 本地驱动包已安装启用`);
|
||||
message.success(`${row.name}${versionTip} 本地驱动包已安装启用`);
|
||||
}
|
||||
if (!options?.skipRefresh) {
|
||||
await refreshStatus(false);
|
||||
@@ -842,7 +872,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
} finally {
|
||||
setActionState({ driverType: '', kind: '' });
|
||||
}
|
||||
}, [appendOperationLog, downloadDir, refreshStatus]);
|
||||
}, [appendOperationLog, downloadDir, refreshStatus, resolveLocalImportVersion]);
|
||||
|
||||
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
|
||||
const fileRes = await SelectDriverPackageFile(downloadDir);
|
||||
@@ -936,6 +966,18 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
message.error(`目录导入失败${forceTip}:失败 ${failCount}${skipTip}`);
|
||||
}, [appendOperationLog, downloadDir, forceOverwriteInstalled, installDriverFromLocalPath, refreshStatus, rows]);
|
||||
|
||||
const openDriverDirectory = useCallback(async () => {
|
||||
try {
|
||||
const res = await OpenDriverDownloadDirectory(downloadDir);
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.message || '打开驱动目录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
|
||||
message.error(`打开驱动目录失败: ${errMsg}`);
|
||||
}
|
||||
}, [downloadDir]);
|
||||
|
||||
const openDriverLog = useCallback((driverType: string) => {
|
||||
const normalized = String(driverType || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
@@ -975,7 +1017,17 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
title: '数据源',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150,
|
||||
width: 220,
|
||||
render: (_: string, row: DriverStatusRow) => (
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<Text strong>{row.name}</Text>
|
||||
{row.message ? (
|
||||
<Text type={row.needsUpdate ? 'warning' : 'secondary'} style={{ fontSize: 12 }}>
|
||||
{row.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '安装包大小',
|
||||
@@ -1012,6 +1064,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
|
||||
return <Tag color="processing">安装中 {Math.round(progress.percent)}%</Tag>;
|
||||
}
|
||||
if (row.needsUpdate) {
|
||||
return <Tag color="warning">强烈建议重装</Tag>;
|
||||
}
|
||||
if (row.connectable) {
|
||||
return <Tag color="success">已启用</Tag>;
|
||||
}
|
||||
@@ -1059,37 +1114,44 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
const versionLocked = row.packageInstalled || row.connectable;
|
||||
if (versionLocked) {
|
||||
const installedVersion = String(row.installedVersion || '').trim();
|
||||
const revisionHint = row.needsUpdate ? ',需重装' : '';
|
||||
if (installedVersion) {
|
||||
return <Text type="secondary">{installedVersion}(已安装,移除后可更换)</Text>;
|
||||
return <Text type="secondary">{installedVersion}(已安装{revisionHint},移除后可更换)</Text>;
|
||||
}
|
||||
return <Text type="secondary">已安装(移除后可更换)</Text>;
|
||||
return <Text type="secondary">已安装({row.needsUpdate ? '需重装,' : ''}移除后可更换)</Text>;
|
||||
}
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const selectOptions = buildVersionSelectOptions(options);
|
||||
const mongoHint = row.type === 'mongodb'
|
||||
? '当前仅支持 MongoDB 1.17.x 和 2.x;更老 1.x 暂不提供安装。'
|
||||
: '';
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
loading={!!versionLoadingMap[row.type]}
|
||||
disabled={actionState.driverType === row.type}
|
||||
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
|
||||
value={selectedKey}
|
||||
options={selectOptions as any}
|
||||
onOpenChange={(open) => {
|
||||
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
|
||||
void loadVersionOptions(row, true);
|
||||
return;
|
||||
}
|
||||
if (open && selectedKey) {
|
||||
void loadVersionPackageSize(row, selectedKey);
|
||||
}
|
||||
}}
|
||||
onChange={(value) => {
|
||||
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
|
||||
void loadVersionPackageSize(row, value);
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
loading={!!versionLoadingMap[row.type]}
|
||||
disabled={actionState.driverType === row.type}
|
||||
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
|
||||
value={selectedKey}
|
||||
options={selectOptions as any}
|
||||
onOpenChange={(open) => {
|
||||
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
|
||||
void loadVersionOptions(row, true);
|
||||
return;
|
||||
}
|
||||
if (open && selectedKey) {
|
||||
void loadVersionPackageSize(row, selectedKey);
|
||||
}
|
||||
}}
|
||||
onChange={(value) => {
|
||||
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
|
||||
void loadVersionPackageSize(row, value);
|
||||
}}
|
||||
/>
|
||||
{mongoHint ? <Text type="secondary" style={{ fontSize: 12 }}>{mongoHint}</Text> : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -1112,7 +1174,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
const logs = operationLogMap[row.type] || [];
|
||||
const hasLogs = logs.length > 0;
|
||||
|
||||
const mainAction = row.connectable ? (
|
||||
const mainAction = row.needsUpdate ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={loadingInstallOrRemove}
|
||||
onClick={() => installDriver(row)}
|
||||
>
|
||||
重装驱动
|
||||
</Button>
|
||||
) : row.connectable ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
@@ -1140,7 +1211,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'}
|
||||
@@ -1173,9 +1244,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
row.type,
|
||||
row.pinnedVersion,
|
||||
row.installedVersion,
|
||||
row.updateReason,
|
||||
row.message,
|
||||
row.builtIn ? '内置' : '外置',
|
||||
row.connectable ? '已启用' : row.packageInstalled ? '已安装' : '未启用',
|
||||
row.needsUpdate ? '强烈建议重装' : row.connectable ? '已启用' : row.packageInstalled ? '已安装' : '未启用',
|
||||
];
|
||||
const searchableText = normalizeDriverSearchText(searchableParts.filter(Boolean).join(' '));
|
||||
return searchableText.includes(normalizedSearchKeyword);
|
||||
@@ -1342,10 +1414,14 @@ 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">行内“本地导入”仅用于单个驱动文件/总包(如 `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>
|
||||
<Button icon={<FolderOpenOutlined />} onClick={() => void openDriverDirectory()}>
|
||||
打开驱动目录
|
||||
</Button>
|
||||
{networkStatus?.logPath ? (
|
||||
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
|
||||
运行日志文件:{networkStatus.logPath}
|
||||
@@ -1374,6 +1450,12 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
onChange={(checked) => setForceOverwriteInstalled(checked)}
|
||||
disabled={batchDirectoryImporting}
|
||||
/>
|
||||
<Button
|
||||
icon={<FolderOpenOutlined />}
|
||||
onClick={() => void openDriverDirectory()}
|
||||
>
|
||||
打开驱动目录
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FolderOpenOutlined />}
|
||||
loading={batchDirectoryImporting}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { DBQuery, DBGetTables, DBGetAllColumns } from '../../wailsjs/go/app/App'
|
||||
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;
|
||||
@@ -66,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;
|
||||
@@ -106,7 +109,7 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
|
||||
try {
|
||||
// 1. 获取所有表
|
||||
const tablesRes = await DBGetTables(config as any, dbName);
|
||||
const tablesRes = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName);
|
||||
if (!tablesRes.success) {
|
||||
message.error('获取表列表失败: ' + tablesRes.message);
|
||||
setSearching(false);
|
||||
@@ -124,7 +127,7 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
setProgress({ current: 0, total: tableNames.length, tableName: '' });
|
||||
|
||||
// 2. 获取所有列信息(返回 any[],含 tableName/name/type 字段)
|
||||
const allColsRes = await DBGetAllColumns(config as any, dbName);
|
||||
const allColsRes = await DBGetAllColumns(buildRpcConnectionConfig(config) as any, dbName);
|
||||
const allColumns: any[] = (allColsRes?.success && Array.isArray(allColsRes.data)) ? allColsRes.data : [];
|
||||
|
||||
// 按表名分组
|
||||
@@ -166,7 +169,7 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
const sql = buildLimitedSelectSQL(dbType, baseSql, MAX_MATCH_ROWS_PER_TABLE);
|
||||
|
||||
try {
|
||||
const res = await DBQuery(config as any, dbName, sql);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
|
||||
if (res.success && Array.isArray(res.data) && res.data.length > 0) {
|
||||
// 检查哪些列实际匹配了
|
||||
const matchedCols = new Set<string>();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { PreviewImportFile, ImportDataWithProgress } from '../../wailsjs/go/app/App';
|
||||
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
|
||||
import { useStore } from '../store';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
interface ImportPreviewModalProps {
|
||||
visible: boolean;
|
||||
@@ -107,7 +108,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }
|
||||
};
|
||||
|
||||
const res = await ImportDataWithProgress(config as any, dbName, tableName, filePath);
|
||||
const res = await ImportDataWithProgress(buildRpcConnectionConfig(config) as any, dbName, tableName, filePath);
|
||||
|
||||
if (res.success && res.data) {
|
||||
setImportResult(res.data);
|
||||
|
||||
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
545
frontend/src/components/QueryEditor.external-sql-save.test.tsx
Normal file
545
frontend/src/components/QueryEditor.external-sql-save.test.tsx
Normal file
@@ -0,0 +1,545 @@
|
||||
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 { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
|
||||
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(),
|
||||
DBGetIndexes: 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 dataGridState = vi.hoisted(() => ({
|
||||
latestProps: null as any,
|
||||
}));
|
||||
|
||||
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: (props: any) => {
|
||||
dataGridState.latestProps = props;
|
||||
return <div data-grid="true" />;
|
||||
},
|
||||
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: ({ items }: any) => <div>{items?.[0]?.children}</div>,
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
messageApi.warning.mockReset();
|
||||
backendApp.WriteSQLFile.mockResolvedValue({ success: true });
|
||||
backendApp.DBQueryMulti.mockResolvedValue({ success: true, data: [] });
|
||||
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
|
||||
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
|
||||
backendApp.GenerateQueryID.mockResolvedValue('query-1');
|
||||
storeState.connections[0].config.type = 'mysql';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
dataGridState.latestProps = null;
|
||||
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,
|
||||
}));
|
||||
});
|
||||
|
||||
it('automatically appends hidden primary key locator columns for editable query results', async () => {
|
||||
storeState.connections[0].config.type = 'oracle';
|
||||
storeState.connections[0].config.database = 'ORCLPDB1';
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ columns: ['NAME', '__gonavi_locator_1_ID'], rows: [{ NAME: 'old-name', __gonavi_locator_1_ID: 7 }] }],
|
||||
});
|
||||
backendApp.DBGetColumns.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(dataGridState.latestProps?.tableName).toBe('MYCIMLED.EDC_LOG');
|
||||
expect(dataGridState.latestProps?.pkColumns).toEqual(['ID']);
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'primary-key',
|
||||
columns: ['ID'],
|
||||
valueColumns: ['__gonavi_locator_1_ID'],
|
||||
hiddenColumns: ['__gonavi_locator_1_ID'],
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(dataGridState.latestProps?.resultSql).toBe('SELECT NAME FROM MYCIMLED.EDC_LOG');
|
||||
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"ID" AS "__gonavi_locator_1_ID"');
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses a unique index locator for query results without primary keys', async () => {
|
||||
storeState.connections[0].config.type = 'oracle';
|
||||
storeState.connections[0].config.database = 'ORCLPDB1';
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ columns: ['NAME', '__gonavi_locator_1_EMAIL'], rows: [{ NAME: 'old-name', __gonavi_locator_1_EMAIL: 'a@example.com' }] }],
|
||||
});
|
||||
backendApp.DBGetColumns.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ name: 'EMAIL', key: '' }, { name: 'NAME', key: '' }],
|
||||
});
|
||||
backendApp.DBGetIndexes.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ name: 'UK_EMAIL', columnName: 'EMAIL', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' }],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'unique-key',
|
||||
columns: ['EMAIL'],
|
||||
valueColumns: ['__gonavi_locator_1_EMAIL'],
|
||||
hiddenColumns: ['__gonavi_locator_1_EMAIL'],
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"EMAIL" AS "__gonavi_locator_1_EMAIL"');
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses hidden Oracle ROWID for query results without primary or unique keys', async () => {
|
||||
storeState.connections[0].config.type = 'oracle';
|
||||
storeState.connections[0].config.database = 'ORCLPDB1';
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ columns: ['NAME', ORACLE_ROWID_LOCATOR_COLUMN], rows: [{ NAME: 'old-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }] }],
|
||||
});
|
||||
backendApp.DBGetColumns.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ name: 'NAME', key: '' }],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'oracle-rowid',
|
||||
columns: ['ROWID'],
|
||||
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
|
||||
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain(`ROWID AS "${ORACLE_ROWID_LOCATOR_COLUMN}"`);
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps non-Oracle query results read-only when no safe locator exists', async () => {
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }],
|
||||
});
|
||||
backendApp.DBGetColumns.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ name: 'NAME', key: '' }],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM users' })} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(dataGridState.latestProps?.tableName).toBe('users');
|
||||
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'none',
|
||||
readOnly: true,
|
||||
reason: '未检测到主键或可用唯一索引,无法安全提交修改。',
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(true);
|
||||
expect(messageApi.warning).toHaveBeenCalledWith('查询结果保持只读:main.users 未检测到主键或可用唯一索引,无法安全提交修改。');
|
||||
});
|
||||
|
||||
it('allows editable table columns while leaving expression columns out of commits', async () => {
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{
|
||||
columns: ['DISPLAY_NAME', 'NAME_UPPER', '__gonavi_locator_1_ID'],
|
||||
rows: [{ DISPLAY_NAME: 'old-name', NAME_UPPER: 'OLD-NAME', __gonavi_locator_1_ID: 7 }],
|
||||
}],
|
||||
});
|
||||
backendApp.DBGetColumns.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({
|
||||
dbName: 'main',
|
||||
query: 'SELECT NAME AS DISPLAY_NAME, UPPER(NAME) AS NAME_UPPER FROM users',
|
||||
})} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(dataGridState.latestProps?.tableName).toBe('users');
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'primary-key',
|
||||
columns: ['ID'],
|
||||
valueColumns: ['__gonavi_locator_1_ID'],
|
||||
hiddenColumns: ['__gonavi_locator_1_ID'],
|
||||
writableColumns: {
|
||||
DISPLAY_NAME: 'NAME',
|
||||
},
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('`ID` AS `__gonavi_locator_1_ID`');
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'diros',
|
||||
'sphinx',
|
||||
'postgres',
|
||||
'kingbase',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'sqlserver',
|
||||
'sqlite',
|
||||
'duckdb',
|
||||
'oracle',
|
||||
'dameng',
|
||||
'tdengine',
|
||||
'clickhouse',
|
||||
])(
|
||||
'keeps aggregate query results silently read-only for %s',
|
||||
async (dbType) => {
|
||||
storeState.connections[0].config.type = dbType;
|
||||
storeState.connections[0].config.database = dbType === 'oracle' || dbType === 'dameng' ? 'APP' : 'main';
|
||||
const forceReadOnlyQueryResult = dbType === 'tdengine' || dbType === 'clickhouse';
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ columns: ['COUNT'], rows: [{ COUNT: 1 }] }],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({
|
||||
dbName: storeState.connections[0].config.database,
|
||||
query: 'SELECT count(1) FROM users',
|
||||
})} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(dataGridState.latestProps?.tableName).toBe(forceReadOnlyQueryResult ? undefined : 'users');
|
||||
expect(dataGridState.latestProps?.editLocator).toBeUndefined();
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(true);
|
||||
expect(backendApp.DBGetColumns).not.toHaveBeenCalled();
|
||||
expect(backendApp.DBGetIndexes).not.toHaveBeenCalled();
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Button, Space, message } from 'antd';
|
||||
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import Editor, { OnMount } from '@monaco-editor/react';
|
||||
|
||||
interface RedisCommandEditorProps {
|
||||
@@ -14,6 +15,67 @@ interface CommandResult {
|
||||
result: any;
|
||||
error?: string;
|
||||
timestamp: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
// 智能解析 Redis 脚本块,保护多行引号内的换行符
|
||||
function parseRedisScriptBlocks(script: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
let currentBlock = "";
|
||||
let inQuote: string | null = null;
|
||||
let isEscaping = false;
|
||||
|
||||
const lines = script.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!inQuote && (trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('#'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let j = 0; j < line.length; j++) {
|
||||
const char = line[j];
|
||||
|
||||
if (isEscaping) {
|
||||
isEscaping = false;
|
||||
currentBlock += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
isEscaping = true;
|
||||
currentBlock += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
if (inQuote === char) {
|
||||
inQuote = null;
|
||||
} else if (!inQuote) {
|
||||
inQuote = char;
|
||||
}
|
||||
}
|
||||
|
||||
currentBlock += char;
|
||||
}
|
||||
|
||||
if (inQuote || (i < lines.length - 1 && currentBlock.trim() !== '')) {
|
||||
if (!inQuote) {
|
||||
blocks.push(currentBlock.trim());
|
||||
currentBlock = "";
|
||||
} else {
|
||||
currentBlock += '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBlock.trim() !== '') {
|
||||
blocks.push(currentBlock.trim());
|
||||
}
|
||||
|
||||
return blocks.filter(b => b.trim() !== '');
|
||||
}
|
||||
|
||||
const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, redisDB }) => {
|
||||
@@ -23,6 +85,13 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
||||
const [command, setCommand] = useState('');
|
||||
const [results, setResults] = useState<CommandResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// UI Layout state
|
||||
const [editorHeight, setEditorHeight] = useState(250);
|
||||
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const resultsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const editorRef = useRef<any>(null);
|
||||
|
||||
const getConfig = useCallback(() => {
|
||||
@@ -37,77 +106,173 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
||||
};
|
||||
}, [connection, redisDB]);
|
||||
|
||||
const handleEditorMount: OnMount = (editor) => {
|
||||
const handleEditorMount: OnMount = (editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
// Add keyboard shortcut for execute
|
||||
editor.addCommand(
|
||||
// Ctrl/Cmd + Enter
|
||||
2048 | 3, // KeyMod.CtrlCmd | KeyCode.Enter
|
||||
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
|
||||
() => handleExecute()
|
||||
);
|
||||
|
||||
if (!(window as any).__redisCompletionRegistered) {
|
||||
(window as any).__redisCompletionRegistered = true;
|
||||
|
||||
const redisCommands = [
|
||||
"APPEND", "AUTH", "BGREWRITEAOF", "BGSAVE", "BITCOUNT", "BITFIELD", "BITOP",
|
||||
"BITPOS", "BLPOP", "BRPOP", "BRPOPLPUSH", "BZMPOP", "BZPOPMIN", "BZPOPMAX",
|
||||
"CLIENT", "CLUSTER", "COMMAND", "CONFIG", "DBSIZE", "DEBUG", "DECR", "DECRBY",
|
||||
"DEL", "DISCARD", "DUMP", "ECHO", "EVAL", "EVALSHA", "EXEC", "EXISTS", "EXPIRE",
|
||||
"EXPIREAT", "EXPIRETIME", "FLUSHALL", "FLUSHDB", "GEOADD", "GEODIST", "GEOHASH",
|
||||
"GEOPOS", "GEORADIUS", "GEORADIUSBYMEMBER", "GEOSEARCH", "GEOSEARCHSTORE",
|
||||
"GET", "GETBIT", "GETDEL", "GETEX", "GETRANGE", "GETSET", "HDEL", "HELLO",
|
||||
"HEXISTS", "HGET", "HGETALL", "HINCRBY", "HINCRBYFLOAT", "HKEYS", "HLEN",
|
||||
"HMGET", "HMSET", "HSCAN", "HSET", "HSETNX", "HSTRLEN", "HVALS", "INCR",
|
||||
"INCRBY", "INCRBYFLOAT", "INFO", "KEYS", "LASTSAVE", "LCS", "LINDEX", "LINSERT",
|
||||
"LLEN", "LMOVE", "LMPOP", "LPOP", "LPOS", "LPUSH", "LPUSHX", "LRANGE", "LREM",
|
||||
"LSET", "LTRIM", "MEMORY", "MGET", "MIGRATE", "MODULE", "MONITOR", "MOVE", "MSET",
|
||||
"MSETNX", "MULTI", "OBJECT", "PERSIST", "PEXPIRE", "PEXPIREAT", "PEXPIRETIME",
|
||||
"PFADD", "PFCOUNT", "PFMERGE", "PING", "PSETEX", "PSUBSCRIBE", "PTTL", "PUBLISH",
|
||||
"PUBSUB", "PUNSUBSCRIBE", "QUIT", "RANDOMKEY", "READONLY", "READWRITE", "RENAME",
|
||||
"RENAMENX", "RESET", "RESTORE", "ROLE", "RPOP", "RPOPLPUSH", "RPUSH", "RPUSHX",
|
||||
"SADD", "SAVE", "SCAN", "SCARD", "SCRIPT", "SDIFF", "SDIFFSTORE", "SELECT",
|
||||
"SET", "SETBIT", "SETEX", "SETNX", "SETRANGE", "SHUTDOWN", "SINTER", "SINTERCARD",
|
||||
"SINTERSTORE", "SISMEMBER", "SLAVEOF", "SLOWLOG", "SMEMBERS", "SMISMEMBER",
|
||||
"SMOVE", "SORT", "SORT_RO", "SPOP", "SRANDMEMBER", "SREM", "SSCAN", "STRLEN",
|
||||
"SUBSCRIBE", "SUNION", "SUNIONSTORE", "SWAPDB", "SYNC", "TIME", "TOUCH", "TTL",
|
||||
"TYPE", "UNLINK", "UNSUBSCRIBE", "UNWATCH", "WAIT", "WATCH", "XACK", "XADD",
|
||||
"XAUTOCLAIM", "XCLAIM", "XDEL", "XGROUP", "XINFO", "XLEN", "XPENDING", "XRANGE",
|
||||
"XREAD", "XREADGROUP", "XREVRANGE", "XTRIM", "ZADD", "ZCARD", "ZCOUNT", "ZDIFF",
|
||||
"ZDIFFSTORE", "ZINCRBY", "ZINTER", "ZINTERCARD", "ZINTERSTORE", "ZLEXCOUNT",
|
||||
"ZMPOP", "ZMSCORE", "ZPOPMAX", "ZPOPMIN", "ZRANDMEMBER", "ZRANGE", "ZRANGEBYLEX",
|
||||
"ZRANGEBYSCORE", "ZRANK", "ZREM", "ZREMRANGEBYLEX", "ZREMRANGEBYRANK",
|
||||
"ZREMRANGEBYSCORE", "ZREVRANGE", "ZREVRANGEBYLEX", "ZREVRANGEBYSCORE", "ZREVRANK",
|
||||
"ZSCAN", "ZSCORE", "ZUNION", "ZUNIONSTORE"
|
||||
];
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('redis', {
|
||||
provideCompletionItems: (model: any, position: any) => {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn
|
||||
};
|
||||
return {
|
||||
suggestions: redisCommands.map(cmd => ({
|
||||
label: cmd,
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: cmd,
|
||||
range: range,
|
||||
detail: "Redis Command"
|
||||
}))
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
|
||||
const cmdToExecute = command.trim();
|
||||
let cmdToExecute = '';
|
||||
|
||||
// 1. 获取用户是否有高亮选中的文本
|
||||
const selection = editorRef.current?.getSelection();
|
||||
if (selection && !selection.isEmpty()) {
|
||||
cmdToExecute = editorRef.current?.getModel()?.getValueInRange(selection) || '';
|
||||
} else {
|
||||
// 没有选中则取全部文本
|
||||
cmdToExecute = editorRef.current?.getValue() || '';
|
||||
}
|
||||
|
||||
cmdToExecute = cmdToExecute.trim();
|
||||
if (!cmdToExecute) {
|
||||
message.warning('请输入命令');
|
||||
message.warning('请输入要执行的命令');
|
||||
return;
|
||||
}
|
||||
|
||||
// Support multiple commands separated by newlines
|
||||
const commands = cmdToExecute.split('\n').filter(c => c.trim() && !c.trim().startsWith('//') && !c.trim().startsWith('#'));
|
||||
// 2. 智能解析多行命令
|
||||
const commands = parseRedisScriptBlocks(cmdToExecute);
|
||||
if (commands.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
const newResults: CommandResult[] = [];
|
||||
|
||||
for (const cmd of commands) {
|
||||
const trimmedCmd = cmd.trim();
|
||||
if (!trimmedCmd) continue;
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisExecuteCommand(config, trimmedCmd);
|
||||
const res = await (window as any).go.app.App.RedisExecuteCommand(buildRpcConnectionConfig(config), cmd);
|
||||
newResults.push({
|
||||
command: trimmedCmd,
|
||||
command: cmd,
|
||||
result: res.success ? res.data : null,
|
||||
error: res.success ? undefined : res.message,
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
durationMs: Date.now() - start
|
||||
});
|
||||
} catch (e: any) {
|
||||
newResults.push({
|
||||
command: trimmedCmd,
|
||||
command: cmd,
|
||||
result: null,
|
||||
error: e?.message || String(e),
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
durationMs: Date.now() - start
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setResults(prev => [...newResults, ...prev]);
|
||||
setResults(prev => [...prev, ...newResults]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Auto scroll to bottom when new results arrive
|
||||
useEffect(() => {
|
||||
if (resultsEndRef.current) {
|
||||
resultsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [results]);
|
||||
|
||||
const handleClear = () => {
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
const formatResult = (result: any): string => {
|
||||
const formatResult = (result: any): React.ReactNode => {
|
||||
if (result === null || result === undefined) {
|
||||
return '(nil)';
|
||||
return <span style={{ color: '#569cd6' }}>(nil)</span>;
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
return `"${result}"`;
|
||||
// 尝试美化 JSON 字符串
|
||||
try {
|
||||
const parsed = JSON.parse(result);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return (
|
||||
<div style={{ marginTop: 4, padding: 8, background: 'rgba(0,0,0,0.2)', borderRadius: 4 }}>
|
||||
{JSON.stringify(parsed, null, 2)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// not a valid json, just return string
|
||||
}
|
||||
return <span style={{ color: '#ce9178' }}>"{result}"</span>;
|
||||
}
|
||||
if (typeof result === 'number') {
|
||||
return `(integer) ${result}`;
|
||||
return <span style={{ color: '#b5cea8' }}>(integer) {result}</span>;
|
||||
}
|
||||
if (Array.isArray(result)) {
|
||||
if (result.length === 0) {
|
||||
return '(empty array)';
|
||||
}
|
||||
return result.map((item, index) => `${index + 1}) ${formatResult(item)}`).join('\n');
|
||||
return (
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
{result.map((item, index) => (
|
||||
<div key={index} style={{ display: 'flex' }}>
|
||||
<span style={{ color: '#608b4e', marginRight: 8, userSelect: 'none' }}>{index + 1})</span>
|
||||
<div>{formatResult(item)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (typeof result === 'object') {
|
||||
return JSON.stringify(result, null, 2);
|
||||
@@ -115,18 +280,56 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
||||
return String(result);
|
||||
};
|
||||
|
||||
// Resizing logic
|
||||
const handleDragStart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragRef.current = { startY: e.clientY, startHeight: editorHeight };
|
||||
document.addEventListener('mousemove', handleDragMove);
|
||||
document.addEventListener('mouseup', handleDragEnd);
|
||||
document.body.style.cursor = 'row-resize';
|
||||
};
|
||||
|
||||
const handleDragMove = useCallback((e: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const delta = e.clientY - dragRef.current.startY;
|
||||
let newHeight = dragRef.current.startHeight + delta;
|
||||
|
||||
// 限制高度
|
||||
const minHeight = 100;
|
||||
const maxHeight = containerRef.current ? containerRef.current.clientHeight - 100 : 800;
|
||||
if (newHeight < minHeight) newHeight = minHeight;
|
||||
if (newHeight > maxHeight) newHeight = maxHeight;
|
||||
|
||||
setEditorHeight(newHeight);
|
||||
|
||||
// 更新编辑器布局
|
||||
if (editorRef.current) {
|
||||
editorRef.current.layout();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
dragRef.current = null;
|
||||
document.removeEventListener('mousemove', handleDragMove);
|
||||
document.removeEventListener('mouseup', handleDragEnd);
|
||||
document.body.style.cursor = 'default';
|
||||
if (editorRef.current) {
|
||||
editorRef.current.layout();
|
||||
}
|
||||
}, [handleDragMove]);
|
||||
|
||||
if (!connection) {
|
||||
return <div style={{ padding: 20 }}>连接不存在</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* Command Input */}
|
||||
<div style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', background: '#fff' }}>
|
||||
{/* Editor Top Pane */}
|
||||
<div style={{ height: editorHeight, minHeight: 100, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#fdfdfd' }}>
|
||||
<Space>
|
||||
<span style={{ fontWeight: 500 }}>Redis 命令</span>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>db{redisDB}</span>
|
||||
<span style={{ fontWeight: 600 }}>Redis Console</span>
|
||||
<span style={{ color: '#888', fontSize: 13, background: '#f0f0f0', padding: '2px 8px', borderRadius: 12 }}>db{redisDB}</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
@@ -135,68 +338,89 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
||||
onClick={handleExecute}
|
||||
loading={loading}
|
||||
>
|
||||
执行 (Ctrl+Enter)
|
||||
执行 (Cmd+Enter)
|
||||
</Button>
|
||||
<Button icon={<ClearOutlined />} onClick={handleClear}>清空结果</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Editor
|
||||
height="150px"
|
||||
defaultLanguage="plaintext"
|
||||
value={command}
|
||||
onChange={(value) => setCommand(value || '')}
|
||||
onMount={handleEditorMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: 'on',
|
||||
fontSize: 14,
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<Editor
|
||||
defaultLanguage="redis"
|
||||
language="redis"
|
||||
value={command}
|
||||
onChange={(value) => setCommand(value || '')}
|
||||
onMount={handleEditorMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: 'on',
|
||||
fontSize: 14,
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 4,
|
||||
padding: { top: 10, bottom: 10 }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: 'monospace' }}>
|
||||
{results.length === 0 ? (
|
||||
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>
|
||||
输入 Redis 命令并按 Ctrl+Enter 执行
|
||||
<br />
|
||||
<span style={{ fontSize: 12 }}>支持多行命令,每行一个命令</span>
|
||||
</div>
|
||||
) : (
|
||||
results.map((item, index) => (
|
||||
<div key={item.timestamp + index} style={{ padding: '8px 12px', borderBottom: '1px solid #333' }}>
|
||||
<div style={{ color: '#569cd6', marginBottom: 4 }}>
|
||||
> {item.command}
|
||||
{/* Resizer Handle */}
|
||||
<div
|
||||
className="horizontal-resizer"
|
||||
onMouseDown={handleDragStart}
|
||||
style={{
|
||||
height: 8,
|
||||
cursor: 'row-resize',
|
||||
background: '#f0f0f0',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 40, height: 4, background: '#ccc', borderRadius: 2 }} />
|
||||
</div>
|
||||
|
||||
{/* Results Terminal Bottom Pane */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '4px 12px', background: '#252526', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #333' }}>
|
||||
<span style={{ color: '#ccc', fontSize: 12 }}>Execution Output</span>
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={handleClear} style={{ color: '#aaa' }}>清空控制台</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: '"Consolas", "Courier New", monospace', fontSize: 13, padding: 12 }}>
|
||||
{results.length === 0 ? (
|
||||
<div style={{ color: '#666', textAlign: 'center', marginTop: 40 }}>
|
||||
<div>在此终端执行命令,结果会以原样输出</div>
|
||||
<div style={{ fontSize: 12, marginTop: 12 }}>
|
||||
Tips: <code>选中任意行</code> 按 <code style={{ color: '#999' }}>Ctrl + Enter</code> 仅执行选中段落
|
||||
</div>
|
||||
{item.error ? (
|
||||
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
|
||||
(error) {item.error}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#ce9178', whiteSpace: 'pre-wrap' }}>
|
||||
{formatResult(item.result)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Common Commands Help */}
|
||||
<div style={{ padding: '8px 12px', borderTop: '1px solid #f0f0f0', background: '#fafafa', fontSize: 12, color: '#666' }}>
|
||||
常用命令:
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
<code>KEYS *</code> |
|
||||
<code style={{ marginLeft: 8 }}>GET key</code> |
|
||||
<code style={{ marginLeft: 8 }}>SET key value</code> |
|
||||
<code style={{ marginLeft: 8 }}>HGETALL key</code> |
|
||||
<code style={{ marginLeft: 8 }}>INFO</code> |
|
||||
<code style={{ marginLeft: 8 }}>DBSIZE</code>
|
||||
</span>
|
||||
) : (
|
||||
results.map((item, index) => (
|
||||
<div key={item.timestamp + index} style={{ marginBottom: 16 }}>
|
||||
<div style={{ color: '#569cd6', marginBottom: 6, fontWeight: 'bold' }}>
|
||||
<span style={{ color: '#4CAF50', marginRight: 8 }}>➜</span>
|
||||
{item.command}
|
||||
<span style={{ color: '#666', fontSize: 11, marginLeft: 12, fontWeight: 'normal' }}>[{item.durationMs}ms]</span>
|
||||
</div>
|
||||
|
||||
<div style={{ paddingLeft: 20 }}>
|
||||
{item.error ? (
|
||||
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
|
||||
(error) {item.error}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{formatResult(item.result)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={resultsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
379
frontend/src/components/RedisMonitor.tsx
Normal file
379
frontend/src/components/RedisMonitor.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Card, Row, Col, Statistic, Select, Button, message, Tag, Typography, Tooltip, Spin } from 'antd';
|
||||
import { AreaChart, Area, XAxis, YAxis, Tooltip as RechartsTooltip, ResponsiveContainer, CartesianGrid, Legend, LineChart, Line } from 'recharts';
|
||||
import {
|
||||
DesktopOutlined,
|
||||
DashboardOutlined,
|
||||
ApiOutlined,
|
||||
HddOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { SavedConnection } from '../types';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { RedisGetServerInfo } from '../../wailsjs/go/app/App';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface RedisMonitorProps {
|
||||
connectionId: string;
|
||||
redisDB: number;
|
||||
}
|
||||
|
||||
// Data point for charts
|
||||
interface MetricPoint {
|
||||
time: string;
|
||||
qps: number;
|
||||
memory: number; // in MB
|
||||
memory_rss: number; // in MB
|
||||
clients: number;
|
||||
cpuSys: number;
|
||||
cpuUser: number;
|
||||
hitRate: number;
|
||||
keys: number;
|
||||
}
|
||||
|
||||
const MAX_HISTORY_POINTS = 60; // Keep up to 60 data points
|
||||
|
||||
const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const darkMode = theme === 'dark';
|
||||
|
||||
const [isRunning, setIsRunning] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [history, setHistory] = useState<MetricPoint[]>([]);
|
||||
const [currentInfo, setCurrentInfo] = useState<Record<string, string>>({});
|
||||
|
||||
// Ref to track if component is mounted to prevent state updates after unmount
|
||||
const mountedRef = useRef(true);
|
||||
// Interval ref
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Previous ops counter to calculate QPS if instantaneous_ops_per_sec is not enough
|
||||
const prevMetricsRef = useRef({ prevOps: 0, prevTime: 0 });
|
||||
|
||||
const connection = connections.find((c: SavedConnection) => c.id === connectionId);
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
if (!connection) return;
|
||||
|
||||
try {
|
||||
const config = buildRpcConnectionConfig(connection.config, { redisDB });
|
||||
const res = await RedisGetServerInfo(config);
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
if (!res.success) {
|
||||
setError(res.message || 'Failed to fetch Redis info');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
const infoMap = res.data as Record<string, string>;
|
||||
setCurrentInfo(infoMap);
|
||||
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleTimeString([], { hour12: false, second: '2-digit' });
|
||||
|
||||
// Parse values
|
||||
const qps = parseInt(infoMap['instantaneous_ops_per_sec'] || '0', 10);
|
||||
const memBytes = parseInt(infoMap['used_memory'] || '0', 10);
|
||||
const memRssBytes = parseInt(infoMap['used_memory_rss'] || '0', 10);
|
||||
const clients = parseInt(infoMap['connected_clients'] || '0', 10);
|
||||
const cpuSys = parseFloat(infoMap['used_cpu_sys'] || '0');
|
||||
const cpuUser = parseFloat(infoMap['used_cpu_user'] || '0');
|
||||
|
||||
const hits = parseInt(infoMap['keyspace_hits'] || '0', 10);
|
||||
const misses = parseInt(infoMap['keyspace_misses'] || '0', 10);
|
||||
const hitRate = (hits + misses) > 0 ? (hits / (hits + misses)) * 100 : 0;
|
||||
|
||||
let keys = 0;
|
||||
Object.keys(infoMap).forEach(k => {
|
||||
if (k.startsWith('db')) {
|
||||
const m = infoMap[k].match(/keys=(\d+)/);
|
||||
if (m) keys += parseInt(m[1], 10);
|
||||
}
|
||||
});
|
||||
|
||||
const point: MetricPoint = {
|
||||
time: timeStr,
|
||||
qps,
|
||||
memory: parseFloat((memBytes / 1024 / 1024).toFixed(2)),
|
||||
memory_rss: parseFloat((memRssBytes / 1024 / 1024).toFixed(2)),
|
||||
clients,
|
||||
cpuSys: parseFloat(cpuSys.toFixed(2)),
|
||||
cpuUser: parseFloat(cpuUser.toFixed(2)),
|
||||
hitRate: parseFloat(hitRate.toFixed(2)),
|
||||
keys
|
||||
};
|
||||
|
||||
setHistory(prev => {
|
||||
const next = [...prev, point];
|
||||
if (next.length > MAX_HISTORY_POINTS) {
|
||||
return next.slice(next.length - MAX_HISTORY_POINTS);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (loading) setLoading(false);
|
||||
|
||||
} catch (err: any) {
|
||||
if (mountedRef.current) {
|
||||
setError(err.message || 'Unknown error');
|
||||
if (loading) setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
fetchMetrics(); // initial fetch
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
intervalRef.current = setInterval(fetchMetrics, 2000); // 2 second interval
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [isRunning, connectionId, redisDB, connection]);
|
||||
|
||||
if (!connection) {
|
||||
return <div style={{ padding: 20 }}>Connection not found.</div>;
|
||||
}
|
||||
|
||||
// Determine styles for charts based on theme
|
||||
const chartTextColor = darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)';
|
||||
const chartGridColor = darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
|
||||
const cardBgColor = darkMode ? '#1f1f1f' : '#ffffff';
|
||||
|
||||
const getFormatMemoryString = (bytes: string) => {
|
||||
const val = parseInt(bytes || '0', 10);
|
||||
if (val > 1024*1024*1024) return (val/1024/1024/1024).toFixed(2) + ' GB';
|
||||
if (val > 1024*1024) return (val/1024/1024).toFixed(2) + ' MB';
|
||||
if (val > 1024) return (val/1024).toFixed(2) + ' KB';
|
||||
return val + ' B';
|
||||
};
|
||||
|
||||
const getUptimeString = (seconds: string) => {
|
||||
const d = parseInt(seconds || '0', 10);
|
||||
if (d < 60) return `${d}s`;
|
||||
if (d < 3600) return `${Math.floor(d/60)}m ${d%60}s`;
|
||||
if (d < 86400) return `${Math.floor(d/3600)}h ${Math.floor((d%3600)/60)}m`;
|
||||
return `${Math.floor(d/86400)}d ${Math.floor((d%86400)/3600)}h`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', overflow: 'auto', padding: '16px 24px', backgroundColor: darkMode ? '#141414' : '#f0f2f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0, fontWeight: 600 }}>
|
||||
<DashboardOutlined style={{ marginRight: 8, color: '#1677ff' }} />
|
||||
Redis 实例监控
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
{connection.name}
|
||||
{currentInfo.redis_version && ` • Redis ${currentInfo.redis_version}`}
|
||||
{currentInfo.os && ` • ${currentInfo.os}`}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{error && <Tag color="error" style={{ height: 32, lineHeight: '30px', fontSize: 13 }}>{error}</Tag>}
|
||||
{loading && !error && <Spin style={{ alignSelf: 'center', marginRight: 16 }} />}
|
||||
|
||||
<Button
|
||||
type={isRunning ? "default" : "primary"}
|
||||
icon={isRunning ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={() => setIsRunning(!isRunning)}
|
||||
>
|
||||
{isRunning ? '暂停刷新' : '恢复刷新'}
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchMetrics}>
|
||||
立即刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontWeight: 500 }}><DesktopOutlined /> 已用内存 (Used)</span>}
|
||||
value={getFormatMemoryString(currentInfo.used_memory || '0')}
|
||||
valueStyle={{ color: '#eb2f96', fontWeight: 600 }}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Peak: {getFormatMemoryString(currentInfo.used_memory_peak || '0')}</Text>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontWeight: 500 }}><ApiOutlined /> 客户端数量 (Clients)</span>}
|
||||
value={currentInfo.connected_clients || '0'}
|
||||
valueStyle={{ color: '#1677ff', fontWeight: 600 }}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Blocked: {currentInfo.blocked_clients || '0'}</Text>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontWeight: 500 }}><HddOutlined /> 吞吐量 (OPS)</span>}
|
||||
value={currentInfo.instantaneous_ops_per_sec || '0'}
|
||||
valueStyle={{ color: '#52c41a', fontWeight: 600 }}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>cmds/s</Text>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontWeight: 500 }}>启动时长 (Uptime)</span>}
|
||||
value={getUptimeString(currentInfo.uptime_in_seconds || '0')}
|
||||
valueStyle={{ color: '#fa8c16', fontWeight: 600 }}
|
||||
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Days: {currentInfo.uptime_in_days || '0'}</Text>}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card
|
||||
bordered={false}
|
||||
title="请求吞吐量 (QPS)"
|
||||
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorQps" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#52c41a" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#52c41a" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||
itemStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="qps" name="QPS" stroke="#52c41a" strokeWidth={2} fillOpacity={1} fill="url(#colorQps)" isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card
|
||||
bordered={false}
|
||||
title="内存开销 (Memory)"
|
||||
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} domain={['auto', 'auto']} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||
itemStyle={{ fontWeight: 600 }}
|
||||
formatter={(value: any) => [`${value} MB`]}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36}/>
|
||||
<Line type="monotone" dataKey="memory" name="Used Memory" stroke="#eb2f96" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="memory_rss" name="RSS Memory" stroke="#722ed1" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card
|
||||
bordered={false}
|
||||
title="CPU 使用率 (CPU Usage)"
|
||||
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||
itemStyle={{ fontWeight: 600 }}
|
||||
formatter={(value: any) => [`${value} s`]}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36}/>
|
||||
<Line type="monotone" dataKey="cpuSys" name="System" stroke="#cf1322" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="cpuUser" name="User" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card
|
||||
bordered={false}
|
||||
title="连接信息 (Clients & Keys)"
|
||||
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
|
||||
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
|
||||
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
|
||||
<YAxis yAxisId="left" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
|
||||
itemStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36}/>
|
||||
<Line yAxisId="left" type="stepAfter" dataKey="clients" name="Clients" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="right" type="stepAfter" dataKey="keys" name="Total Keys" stroke="#fa8c16" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Card bordered={false} title="详细服务器参数" style={{ background: cardBgColor, borderRadius: 8 }}>
|
||||
<div style={{ columnCount: 3, columnGap: 40 }}>
|
||||
{['redis_version', 'os', 'arch_bits', 'multiplexing_api', 'gcc_version', 'run_id', 'tcp_port', 'uptime_in_days', 'hz', 'lru_clock', 'role', 'maxmemory_human', 'maxmemory_policy', 'mem_fragmentation_ratio', 'keyspace_hits', 'keyspace_misses', 'total_connections_received'].map(key => (
|
||||
currentInfo[key] ? (
|
||||
<div key={key} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8, borderBottom: `1px dashed ${chartGridColor}` }}>
|
||||
<Text type="secondary">{key}</Text>
|
||||
<Text strong>{currentInfo[key]}</Text>
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedisMonitor;
|
||||
@@ -1,12 +1,21 @@
|
||||
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,
|
||||
applyTreeNodeCheck,
|
||||
@@ -18,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;
|
||||
|
||||
@@ -38,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;
|
||||
@@ -282,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;
|
||||
@@ -292,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);
|
||||
@@ -429,7 +309,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisScanKeys(config, normalizedPattern, fromCursor, effectiveTargetCount);
|
||||
const res = await (window as any).go.app.App.RedisScanKeys(buildRpcConnectionConfig(config), normalizedPattern, fromCursor, effectiveTargetCount);
|
||||
if (requestId !== latestLoadRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -466,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;
|
||||
@@ -508,7 +410,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
setValueLoading(true);
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisGetValue(config, key);
|
||||
const res = await (window as any).go.app.App.RedisGetValue(buildRpcConnectionConfig(config), key);
|
||||
if (res.success) {
|
||||
setKeyValue(res.data);
|
||||
setSelectedKey(key);
|
||||
@@ -539,7 +441,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisDeleteKeys(config, keysToDelete);
|
||||
const res = await (window as any).go.app.App.RedisDeleteKeys(buildRpcConnectionConfig(config), keysToDelete);
|
||||
if (res.success) {
|
||||
message.success(`已删除 ${res.data.deleted} 个 Key`);
|
||||
setKeys(prev => prev.filter(k => !keysToDelete.includes(k.key)));
|
||||
@@ -567,7 +469,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
try {
|
||||
const values = await ttlForm.validateFields();
|
||||
const res = await (window as any).go.app.App.RedisSetTTL(config, selectedKey, values.ttl);
|
||||
const res = await (window as any).go.app.App.RedisSetTTL(buildRpcConnectionConfig(config), selectedKey, values.ttl);
|
||||
if (res.success) {
|
||||
message.success('TTL 设置成功');
|
||||
setTtlModalOpen(false);
|
||||
@@ -586,7 +488,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
if (!config || !selectedKey) return;
|
||||
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisSetString(config, selectedKey, editValue, keyValue?.ttl || -1);
|
||||
const res = await (window as any).go.app.App.RedisSetString(buildRpcConnectionConfig(config), selectedKey, editValue, keyValue?.ttl || -1);
|
||||
if (res.success) {
|
||||
message.success('保存成功');
|
||||
setEditModalOpen(false);
|
||||
@@ -605,7 +507,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
try {
|
||||
const values = await newKeyForm.validateFields();
|
||||
const res = await (window as any).go.app.App.RedisSetString(config, values.key, values.value, values.ttl || -1);
|
||||
const res = await (window as any).go.app.App.RedisSetString(buildRpcConnectionConfig(config), values.key, values.value, values.ttl || -1);
|
||||
if (res.success) {
|
||||
message.success('创建成功');
|
||||
setNewKeyModalOpen(false);
|
||||
@@ -642,7 +544,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const existsRes = await (window as any).go.app.App.RedisKeyExists(config, nextKey);
|
||||
const existsRes = await (window as any).go.app.App.RedisKeyExists(buildRpcConnectionConfig(config), nextKey);
|
||||
if (!existsRes?.success) {
|
||||
message.error('校验目标 Key 失败: ' + (existsRes?.message || '未知错误'));
|
||||
return;
|
||||
@@ -652,7 +554,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await (window as any).go.app.App.RedisRenameKey(config, renameTargetKey, nextKey);
|
||||
const res = await (window as any).go.app.App.RedisRenameKey(buildRpcConnectionConfig(config), renameTargetKey, nextKey);
|
||||
if (res.success) {
|
||||
const nextState = applyRenamedRedisKeyState(
|
||||
{
|
||||
@@ -1039,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
|
||||
@@ -1060,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%' }}>
|
||||
@@ -1145,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 };
|
||||
});
|
||||
|
||||
@@ -1177,7 +1046,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisSetHashField(config, selectedKey, field, newValue);
|
||||
const res = await (window as any).go.app.App.RedisSetHashField(buildRpcConnectionConfig(config), selectedKey, field, newValue);
|
||||
if (res.success) {
|
||||
message.success('修改成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1193,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(config, selectedKey, field);
|
||||
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, [field]);
|
||||
if (res.success) {
|
||||
message.success('删除成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1213,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>
|
||||
@@ -1306,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 };
|
||||
});
|
||||
|
||||
@@ -1338,7 +1184,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisListSet(config, selectedKey, index, newValue);
|
||||
const res = await (window as any).go.app.App.RedisListSet(buildRpcConnectionConfig(config), selectedKey, index, newValue);
|
||||
if (res.success) {
|
||||
message.success('修改成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1354,7 +1200,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisListPush(config, selectedKey, { values: [value], position });
|
||||
const res = await (window as any).go.app.App.RedisListPush(buildRpcConnectionConfig(config), selectedKey, { values: [value], position });
|
||||
if (res.success) {
|
||||
message.success('添加成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1476,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 };
|
||||
});
|
||||
|
||||
@@ -1508,7 +1331,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisSetAdd(config, selectedKey, [member]);
|
||||
const res = await (window as any).go.app.App.RedisSetAdd(buildRpcConnectionConfig(config), selectedKey, [member]);
|
||||
if (res.success) {
|
||||
message.success('添加成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1524,7 +1347,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisSetRemove(config, selectedKey, [member]);
|
||||
const res = await (window as any).go.app.App.RedisSetRemove(buildRpcConnectionConfig(config), selectedKey, [member]);
|
||||
if (res.success) {
|
||||
message.success('删除成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1613,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 };
|
||||
});
|
||||
|
||||
@@ -1645,7 +1445,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisZSetAdd(config, selectedKey, [{ member, score }]);
|
||||
const res = await (window as any).go.app.App.RedisZSetAdd(buildRpcConnectionConfig(config), selectedKey, [{ member, score }]);
|
||||
if (res.success) {
|
||||
message.success('添加成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1661,7 +1461,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisZSetRemove(config, selectedKey, [member]);
|
||||
const res = await (window as any).go.app.App.RedisZSetRemove(buildRpcConnectionConfig(config), selectedKey, [member]);
|
||||
if (res.success) {
|
||||
message.success('删除成功');
|
||||
loadKeyValue(selectedKey);
|
||||
@@ -1778,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,
|
||||
@@ -1841,7 +1620,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisStreamAdd(config, selectedKey, fieldMap, id || '*');
|
||||
const res = await (window as any).go.app.App.RedisStreamAdd(buildRpcConnectionConfig(config), selectedKey, fieldMap, id || '*');
|
||||
if (res.success) {
|
||||
const newID = res.data?.id ? ` (${res.data.id})` : '';
|
||||
message.success(`添加成功${newID}`);
|
||||
@@ -1859,7 +1638,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisStreamDelete(config, selectedKey, [id]);
|
||||
const res = await (window as any).go.app.App.RedisStreamDelete(buildRpcConnectionConfig(config), selectedKey, [id]);
|
||||
if (res.success) {
|
||||
const deleted = Number(res.data?.deleted ?? 0);
|
||||
if (deleted > 0) {
|
||||
@@ -1887,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>
|
||||
@@ -2049,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 }}>
|
||||
@@ -2061,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>
|
||||
@@ -2151,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 || '')}
|
||||
@@ -2176,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" />
|
||||
@@ -2206,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>
|
||||
|
||||
154
frontend/src/components/SecurityUpdateBanner.tsx
Normal file
154
frontend/src/components/SecurityUpdateBanner.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Button } from 'antd';
|
||||
import { CloseOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { SecurityUpdateStatus } from '../types';
|
||||
import { getSecurityUpdateStatusMeta } from '../utils/securityUpdatePresentation';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||
SECURITY_UPDATE_BANNER_CLASS,
|
||||
getSecurityUpdateActionButtonStyle,
|
||||
getSecurityUpdateBannerSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateBannerProps {
|
||||
status: SecurityUpdateStatus;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
onStart: () => void;
|
||||
onRetry: () => void;
|
||||
onRestart: () => void;
|
||||
onOpenDetails: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const resolvePrimaryAction = (
|
||||
status: SecurityUpdateStatus,
|
||||
actions: Pick<SecurityUpdateBannerProps, 'onStart' | 'onRetry' | 'onRestart' | 'onOpenDetails'>,
|
||||
) => {
|
||||
switch (status.overallStatus) {
|
||||
case 'postponed':
|
||||
return {
|
||||
label: '立即更新',
|
||||
onClick: actions.onStart,
|
||||
};
|
||||
case 'needs_attention':
|
||||
return {
|
||||
label: '查看详情',
|
||||
onClick: actions.onOpenDetails,
|
||||
};
|
||||
case 'rolled_back':
|
||||
return {
|
||||
label: '重新开始更新',
|
||||
onClick: actions.onRestart,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: '查看详情',
|
||||
onClick: actions.onOpenDetails,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSecondaryAction = (
|
||||
status: SecurityUpdateStatus,
|
||||
actions: Pick<SecurityUpdateBannerProps, 'onRetry' | 'onOpenDetails'>,
|
||||
) => {
|
||||
switch (status.overallStatus) {
|
||||
case 'needs_attention':
|
||||
return {
|
||||
label: '重新检查',
|
||||
onClick: actions.onRetry,
|
||||
};
|
||||
case 'rolled_back':
|
||||
return {
|
||||
label: '查看详情',
|
||||
onClick: actions.onOpenDetails,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const SecurityUpdateBanner = ({
|
||||
status,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
onStart,
|
||||
onRetry,
|
||||
onRestart,
|
||||
onOpenDetails,
|
||||
onDismiss,
|
||||
}: SecurityUpdateBannerProps) => {
|
||||
const statusMeta = getSecurityUpdateStatusMeta(status);
|
||||
const primaryAction = resolvePrimaryAction(status, { onStart, onRetry, onRestart, onOpenDetails });
|
||||
const secondaryAction = resolveSecondaryAction(status, { onRetry, onOpenDetails });
|
||||
const actionButtonStyle = getSecurityUpdateActionButtonStyle();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={SECURITY_UPDATE_BANNER_CLASS}
|
||||
style={{
|
||||
margin: '12px 12px 0',
|
||||
padding: '14px 16px',
|
||||
borderRadius: 16,
|
||||
...getSecurityUpdateBannerSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 14,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: overlayTheme.iconBg,
|
||||
color: overlayTheme.iconColor,
|
||||
flexShrink: 0,
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||
已保存配置可进行安全更新
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
{statusMeta.description}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
{secondaryAction ? (
|
||||
<Button className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={secondaryAction.onClick}>
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
style={actionButtonStyle}
|
||||
type="primary"
|
||||
onClick={primaryAction.onClick}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
<Button
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
style={{ ...actionButtonStyle, width: 36, minWidth: 36, paddingInline: 0 }}
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onDismiss}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SecurityUpdateBannerProps };
|
||||
export default SecurityUpdateBanner;
|
||||
133
frontend/src/components/SecurityUpdateIntroModal.tsx
Normal file
133
frontend/src/components/SecurityUpdateIntroModal.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Button, Modal } from 'antd';
|
||||
import { SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||
SECURITY_UPDATE_MODAL_CLASS,
|
||||
getSecurityUpdateActionButtonStyle,
|
||||
getSecurityUpdateShellSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateIntroModalProps {
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
onStart: () => void;
|
||||
onPostpone: () => void;
|
||||
onViewDetails: () => void;
|
||||
}
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
...getSecurityUpdateActionButtonStyle(),
|
||||
height: 38,
|
||||
paddingInline: 18,
|
||||
};
|
||||
|
||||
const SecurityUpdateIntroModal = ({
|
||||
open,
|
||||
loading = false,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
onStart,
|
||||
onPostpone,
|
||||
onViewDetails,
|
||||
}: SecurityUpdateIntroModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
|
||||
title={(
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: overlayTheme.iconBg,
|
||||
color: overlayTheme.iconColor,
|
||||
fontSize: 18,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
|
||||
已保存配置安全更新
|
||||
</div>
|
||||
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
|
||||
使用新的安全存储方式前,需要先完成一次本地配置更新。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
open={open}
|
||||
closable={!loading}
|
||||
maskClosable={!loading}
|
||||
keyboard={!loading}
|
||||
onCancel={onPostpone}
|
||||
width={560}
|
||||
styles={{
|
||||
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||
body: { paddingTop: 8 },
|
||||
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
|
||||
}}
|
||||
footer={[
|
||||
<Button
|
||||
key="details"
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
type="primary"
|
||||
ghost
|
||||
style={actionButtonStyle}
|
||||
onClick={onViewDetails}
|
||||
disabled={loading}
|
||||
>
|
||||
查看详情
|
||||
</Button>,
|
||||
<Button
|
||||
key="later"
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
type="primary"
|
||||
ghost
|
||||
style={actionButtonStyle}
|
||||
onClick={onPostpone}
|
||||
disabled={loading}
|
||||
>
|
||||
稍后提醒我
|
||||
</Button>,
|
||||
<Button
|
||||
key="start"
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
type="primary"
|
||||
style={actionButtonStyle}
|
||||
loading={loading}
|
||||
onClick={onStart}
|
||||
>
|
||||
立即更新
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 0 6px',
|
||||
color: darkMode ? 'rgba(255,255,255,0.82)' : '#2f3b52',
|
||||
lineHeight: 1.8,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
为了让已保存的连接、代理和相关服务配置使用新的安全存储方式,本次更新需要进行一次本地配置更新。
|
||||
更新前会自动创建本地备份;如果本次未完成,系统会保留当前可用配置,你也可以稍后继续。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SecurityUpdateIntroModalProps };
|
||||
export default SecurityUpdateIntroModal;
|
||||
69
frontend/src/components/SecurityUpdateProgressModal.tsx
Normal file
69
frontend/src/components/SecurityUpdateProgressModal.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Modal, Spin } from 'antd';
|
||||
import { SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SECURITY_UPDATE_MODAL_CLASS,
|
||||
getSecurityUpdateShellSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateProgressModalProps {
|
||||
open: boolean;
|
||||
stageText: string;
|
||||
detailText?: string;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
}
|
||||
|
||||
const SecurityUpdateProgressModal = ({
|
||||
open,
|
||||
stageText,
|
||||
detailText,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
}: SecurityUpdateProgressModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
|
||||
open={open}
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
footer={null}
|
||||
width={420}
|
||||
centered
|
||||
styles={{
|
||||
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
header: { display: 'none' },
|
||||
body: { padding: 28 },
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 18,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: overlayTheme.iconBg,
|
||||
color: overlayTheme.iconColor,
|
||||
fontSize: 22,
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||
{stageText}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
{detailText ?? '更新过程中会保留当前可用配置,请稍候。'}
|
||||
</div>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SecurityUpdateProgressModalProps };
|
||||
export default SecurityUpdateProgressModal;
|
||||
337
frontend/src/components/SecurityUpdateSettingsModal.tsx
Normal file
337
frontend/src/components/SecurityUpdateSettingsModal.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Empty, Modal, Tag } from 'antd';
|
||||
import { SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
|
||||
import {
|
||||
getSecurityUpdateIssueActionMeta,
|
||||
getSecurityUpdateIssueSeverityMeta,
|
||||
getSecurityUpdateItemStatusMeta,
|
||||
getSecurityUpdateStatusMeta,
|
||||
sortSecurityUpdateIssues,
|
||||
} from '../utils/securityUpdatePresentation';
|
||||
import {
|
||||
hasSecurityUpdateRecentResult,
|
||||
resolveSecurityUpdateFocusState,
|
||||
type SecurityUpdateFocusState,
|
||||
type SecurityUpdateSettingsFocusTarget,
|
||||
} from '../utils/securityUpdateRepairFlow';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import {
|
||||
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
|
||||
SECURITY_UPDATE_MODAL_CLASS,
|
||||
SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS,
|
||||
SECURITY_UPDATE_RESULT_CARD_CLASS,
|
||||
getSecurityUpdateActionButtonStyle,
|
||||
getSecurityUpdateSectionSurfaceStyle,
|
||||
getSecurityUpdateShellSurfaceStyle,
|
||||
} from '../utils/securityUpdateVisuals';
|
||||
|
||||
interface SecurityUpdateSettingsModalProps {
|
||||
open: boolean;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
surfaceOpacity?: number;
|
||||
status: SecurityUpdateStatus;
|
||||
focusTarget?: SecurityUpdateSettingsFocusTarget | null;
|
||||
focusRequest?: number;
|
||||
onClose: () => void;
|
||||
onStart: () => void;
|
||||
onRetry: () => void;
|
||||
onRestart: () => void;
|
||||
onIssueAction: (issue: SecurityUpdateIssue) => void;
|
||||
}
|
||||
|
||||
const sectionStyle = (
|
||||
overlayTheme: OverlayWorkbenchTheme,
|
||||
surfaceOpacity: number,
|
||||
options?: { emphasized?: boolean },
|
||||
) => ({
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, {
|
||||
...options,
|
||||
surfaceOpacity,
|
||||
}),
|
||||
});
|
||||
|
||||
const EMPTY_FOCUS_STATE: SecurityUpdateFocusState = {
|
||||
target: null,
|
||||
pulseKey: null,
|
||||
};
|
||||
|
||||
const SecurityUpdateSettingsModal = ({
|
||||
open,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
surfaceOpacity = 1,
|
||||
status,
|
||||
focusTarget = null,
|
||||
focusRequest = 0,
|
||||
onClose,
|
||||
onStart,
|
||||
onRetry,
|
||||
onRestart,
|
||||
onIssueAction,
|
||||
}: SecurityUpdateSettingsModalProps) => {
|
||||
const statusMeta = getSecurityUpdateStatusMeta(status);
|
||||
const sortedIssues = sortSecurityUpdateIssues(status.issues);
|
||||
const showRecentResult = hasSecurityUpdateRecentResult(status);
|
||||
const showStart = status.overallStatus === 'pending' || status.overallStatus === 'postponed';
|
||||
const showRetry = status.overallStatus === 'needs_attention';
|
||||
const showRestart = status.overallStatus === 'needs_attention' || status.overallStatus === 'rolled_back';
|
||||
const actionButtonStyle = getSecurityUpdateActionButtonStyle();
|
||||
const [activeFocus, setActiveFocus] = useState<SecurityUpdateFocusState>(EMPTY_FOCUS_STATE);
|
||||
const statusSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const recentResultRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextFocus = resolveSecurityUpdateFocusState(open, focusTarget, focusRequest);
|
||||
if (!nextFocus.target || !nextFocus.pulseKey) {
|
||||
setActiveFocus(EMPTY_FOCUS_STATE);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const targetNode = nextFocus.target === 'recent_result'
|
||||
? recentResultRef.current
|
||||
: statusSectionRef.current;
|
||||
if (!targetNode) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setActiveFocus(EMPTY_FOCUS_STATE);
|
||||
const animationFrame = window.requestAnimationFrame(() => {
|
||||
targetNode.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
targetNode.focus({ preventScroll: true });
|
||||
setActiveFocus(nextFocus);
|
||||
});
|
||||
const highlightTimer = window.setTimeout(() => {
|
||||
setActiveFocus((current) => (
|
||||
current.pulseKey === nextFocus.pulseKey ? EMPTY_FOCUS_STATE : current
|
||||
));
|
||||
}, 1800);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
window.clearTimeout(highlightTimer);
|
||||
};
|
||||
}, [focusRequest, focusTarget, open]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
rootClassName={SECURITY_UPDATE_MODAL_CLASS}
|
||||
title={(
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: overlayTheme.iconBg,
|
||||
color: overlayTheme.iconColor,
|
||||
fontSize: 18,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SafetyCertificateOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>
|
||||
安全更新
|
||||
</div>
|
||||
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
|
||||
管理已保存配置的安全更新状态与待处理项。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
showRetry ? (
|
||||
<Button key="retry" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRetry}>
|
||||
重新检查
|
||||
</Button>
|
||||
) : null,
|
||||
showRestart ? (
|
||||
<Button key="restart" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onRestart}>
|
||||
重新开始更新
|
||||
</Button>
|
||||
) : null,
|
||||
showStart ? (
|
||||
<Button
|
||||
key="start"
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
style={actionButtonStyle}
|
||||
type="primary"
|
||||
onClick={onStart}
|
||||
>
|
||||
开始更新
|
||||
</Button>
|
||||
) : null,
|
||||
<Button key="close" className={SECURITY_UPDATE_ACTION_BUTTON_CLASS} style={actionButtonStyle} onClick={onClose}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={760}
|
||||
styles={{
|
||||
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||
body: { paddingTop: 8, maxHeight: 640, overflowY: 'auto' },
|
||||
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 14, padding: '12px 0' }}>
|
||||
<div
|
||||
ref={statusSectionRef}
|
||||
tabIndex={-1}
|
||||
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'status' })}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||
当前状态:{statusMeta.label}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
{statusMeta.description}
|
||||
</div>
|
||||
</div>
|
||||
<Tag color={
|
||||
statusMeta.tone === 'success'
|
||||
? 'success'
|
||||
: statusMeta.tone === 'error'
|
||||
? 'error'
|
||||
: statusMeta.tone === 'processing'
|
||||
? 'processing'
|
||||
: statusMeta.tone === 'warning'
|
||||
? 'warning'
|
||||
: 'default'
|
||||
}>
|
||||
{statusMeta.label}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
|
||||
影响范围
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10 }}>
|
||||
{[
|
||||
{ label: '总计', value: status.summary.total },
|
||||
{ label: '已更新', value: status.summary.updated },
|
||||
{ label: '待处理', value: status.summary.pending },
|
||||
{ label: '已跳过', value: status.summary.skipped },
|
||||
{ label: '失败', value: status.summary.failed },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
|
||||
borderRadius: 12,
|
||||
padding: '12px 10px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}>{item.label}</div>
|
||||
<div style={{ marginTop: 6, fontSize: 20, fontWeight: 700, color: overlayTheme.titleText }}>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
|
||||
待处理清单
|
||||
</div>
|
||||
{sortedIssues.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="当前没有待处理项"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{sortedIssues.map((issue) => {
|
||||
const actionMeta = getSecurityUpdateIssueActionMeta(issue);
|
||||
const itemStatusMeta = getSecurityUpdateItemStatusMeta(issue.status);
|
||||
const issueSeverityMeta = getSecurityUpdateIssueSeverityMeta(issue.severity);
|
||||
return (
|
||||
<div
|
||||
key={issue.id}
|
||||
style={{
|
||||
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||
{issue.title || issue.message || issue.id}
|
||||
</div>
|
||||
<Tag color={itemStatusMeta.color}>
|
||||
状态:{itemStatusMeta.label}
|
||||
</Tag>
|
||||
<Tag color={issueSeverityMeta.color}>
|
||||
级别:{issueSeverityMeta.label}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
{issue.message || '当前项需要进一步处理后才能完成安全更新。'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className={SECURITY_UPDATE_ACTION_BUTTON_CLASS}
|
||||
style={actionButtonStyle}
|
||||
type={actionMeta.emphasis === 'primary' ? 'primary' : 'default'}
|
||||
onClick={() => onIssueAction(issue)}
|
||||
>
|
||||
{actionMeta.label}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRecentResult ? (
|
||||
<div
|
||||
ref={recentResultRef}
|
||||
tabIndex={-1}
|
||||
className={[
|
||||
SECURITY_UPDATE_RESULT_CARD_CLASS,
|
||||
activeFocus.target === 'recent_result' ? SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'recent_result' })}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 8 }}>
|
||||
最近一次结果
|
||||
</div>
|
||||
{status.backupPath ? (
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
备份位置:<span style={{ color: overlayTheme.titleText }}>{status.backupPath}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{status.lastError ? (
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#ff7875', lineHeight: 1.7 }}>
|
||||
最近错误:{status.lastError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SecurityUpdateSettingsModalProps };
|
||||
export default SecurityUpdateSettingsModal;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,46 +12,44 @@ import QueryEditor from './QueryEditor';
|
||||
import TableDesigner from './TableDesigner';
|
||||
import RedisViewer from './RedisViewer';
|
||||
import RedisCommandEditor from './RedisCommandEditor';
|
||||
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>
|
||||
);
|
||||
@@ -197,25 +195,39 @@ 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') {
|
||||
content = <QueryEditor tab={tab} />;
|
||||
content = <QueryEditor tab={tab} isActive={tabIsActive} />;
|
||||
} else if (tab.type === 'table') {
|
||||
content = <DataViewer tab={tab} />;
|
||||
content = <DataViewer tab={tab} isActive={tabIsActive} />;
|
||||
} else if (tab.type === 'design') {
|
||||
content = <TableDesigner tab={tab} />;
|
||||
} else if (tab.type === 'redis-keys') {
|
||||
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'redis-command') {
|
||||
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'redis-monitor') {
|
||||
content = <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'trigger') {
|
||||
content = <TriggerViewer tab={tab} />;
|
||||
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
|
||||
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'] = [
|
||||
@@ -251,12 +263,13 @@ const TabManager: React.FC = () => {
|
||||
<SortableTabLabel
|
||||
displayTitle={displayTitle}
|
||||
menuItems={menuItems}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
),
|
||||
key: tab.id,
|
||||
children: content,
|
||||
};
|
||||
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
||||
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -315,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;
|
||||
@@ -333,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,6 +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, 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;
|
||||
@@ -217,14 +231,6 @@ const COMMON_DEFAULTS = [
|
||||
{ value: "''" },
|
||||
];
|
||||
|
||||
const MYSQL_INDEX_TYPE_OPTIONS = [
|
||||
{ label: '默认', value: 'DEFAULT' },
|
||||
{ label: 'BTREE', value: 'BTREE' },
|
||||
{ label: 'HASH', value: 'HASH' },
|
||||
{ label: 'FULLTEXT', value: 'FULLTEXT' },
|
||||
{ label: 'SPATIAL', value: 'SPATIAL' },
|
||||
{ label: 'RTREE', value: 'RTREE' },
|
||||
];
|
||||
|
||||
const PGLIKE_INDEX_TYPE_OPTIONS = [
|
||||
{ label: '默认', value: 'DEFAULT' },
|
||||
@@ -545,6 +551,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
// Initial Columns Definition
|
||||
useEffect(() => {
|
||||
const columnTypeOptions = resolveColumnTypeOptions(getDbType());
|
||||
const initialCols = [
|
||||
{
|
||||
title: '名',
|
||||
@@ -552,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" />
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -561,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" />
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -641,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;
|
||||
@@ -759,14 +766,14 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
};
|
||||
|
||||
const promises: Promise<any>[] = [
|
||||
DBGetColumns(config as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetIndexes(config as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetForeignKeys(config as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetTriggers(config as any, tab.dbName || '', tab.tableName || '')
|
||||
DBGetColumns(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetIndexes(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetForeignKeys(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''),
|
||||
DBGetTriggers(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || '')
|
||||
];
|
||||
|
||||
if (!isNewTable) {
|
||||
promises.push(DBShowCreateTable(config as any, tab.dbName || '', tab.tableName || ''));
|
||||
promises.push(DBShowCreateTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''));
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
@@ -852,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 as any)?.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 => {
|
||||
@@ -870,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
|
||||
@@ -902,6 +904,7 @@ BEGIN
|
||||
-- 触发器逻辑
|
||||
END;`;
|
||||
case 'oracle':
|
||||
case 'dameng':
|
||||
case 'dm':
|
||||
return `CREATE OR REPLACE TRIGGER trigger_name
|
||||
BEFORE INSERT ON "${tblName}"
|
||||
@@ -927,6 +930,8 @@ END;`;
|
||||
|
||||
switch (dbType) {
|
||||
case 'mysql':
|
||||
case 'mariadb':
|
||||
case 'diros':
|
||||
return `DROP TRIGGER IF EXISTS \`${triggerName}\``;
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
@@ -936,6 +941,7 @@ END;`;
|
||||
case 'sqlserver':
|
||||
return `DROP TRIGGER IF EXISTS [${triggerName}]`;
|
||||
case 'oracle':
|
||||
case 'dameng':
|
||||
case 'dm':
|
||||
return `DROP TRIGGER "${triggerName}"`;
|
||||
case 'sqlite':
|
||||
@@ -1001,7 +1007,7 @@ ${selectedTrigger.statement}`;
|
||||
const dropSql = buildDropTriggerSql(selectedTrigger.name);
|
||||
|
||||
try {
|
||||
const res = await DBQuery(config as any, tab.dbName || '', dropSql);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql);
|
||||
if (res.success) {
|
||||
message.success('触发器删除成功');
|
||||
setSelectedTrigger(null);
|
||||
@@ -1038,7 +1044,7 @@ ${selectedTrigger.statement}`;
|
||||
// 如果是编辑模式,先删除旧触发器
|
||||
if (triggerEditMode === 'edit' && selectedTrigger) {
|
||||
const dropSql = buildDropTriggerSql(selectedTrigger.name);
|
||||
const dropRes = await DBQuery(config as any, tab.dbName || '', dropSql);
|
||||
const dropRes = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql);
|
||||
if (!dropRes.success) {
|
||||
message.error('删除旧触发器失败: ' + dropRes.message);
|
||||
setTriggerExecuting(false);
|
||||
@@ -1047,7 +1053,7 @@ ${selectedTrigger.statement}`;
|
||||
}
|
||||
|
||||
// 执行创建语句
|
||||
const res = await DBQuery(config as any, tab.dbName || '', triggerEditSql);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', triggerEditSql);
|
||||
if (res.success) {
|
||||
message.success(triggerEditMode === 'create' ? '触发器创建成功' : '触发器修改成功');
|
||||
setIsTriggerEditModalOpen(false);
|
||||
@@ -1339,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 = () => {
|
||||
@@ -1401,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;
|
||||
@@ -1441,28 +1444,45 @@ ${selectedTrigger.statement}`;
|
||||
];
|
||||
};
|
||||
|
||||
const getIndexTypeOptions = () => {
|
||||
const getIndexTypeOptions = (kind?: IndexKind) => {
|
||||
const dbType = getDbType();
|
||||
if (isMysqlLikeDialect(dbType)) return MYSQL_INDEX_TYPE_OPTIONS;
|
||||
if (isPgLikeDialect(dbType)) return PGLIKE_INDEX_TYPE_OPTIONS;
|
||||
const k = kind || 'NORMAL';
|
||||
if (isMysqlLikeDialect(dbType)) {
|
||||
// MySQL InnoDB: 所有索引均为固定方法类型
|
||||
if (k === 'FULLTEXT') return [{ label: 'FULLTEXT', value: 'FULLTEXT' }];
|
||||
if (k === 'SPATIAL') return [{ label: 'RTREE', value: 'RTREE' }];
|
||||
return [{ label: 'BTREE', value: 'BTREE' }];
|
||||
}
|
||||
if (isPgLikeDialect(dbType)) {
|
||||
if (k === 'PRIMARY' || k === 'UNIQUE') return [{ label: 'BTREE', value: 'BTREE' }];
|
||||
return PGLIKE_INDEX_TYPE_OPTIONS;
|
||||
}
|
||||
if (isSqlServerDialect(dbType)) return SQLSERVER_INDEX_TYPE_OPTIONS;
|
||||
return [{ label: '默认', value: 'DEFAULT' }];
|
||||
};
|
||||
|
||||
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 || '')}'`;
|
||||
});
|
||||
const pks = targetColumns.filter(c => c.key === 'PRI').map(c => `\`${escapeBacktickIdentifier(c.name)}\``);
|
||||
if (pks.length > 0) {
|
||||
colDefs.push(`PRIMARY KEY (${pks.join(', ')})`);
|
||||
/** 根据索引类别返回固定的索引方法类型,可选类别返回 undefined */
|
||||
const getFixedIndexType = (kind: IndexKind): string | undefined => {
|
||||
const dbType = getDbType();
|
||||
if (isMysqlLikeDialect(dbType)) {
|
||||
if (kind === 'PRIMARY') return 'BTREE';
|
||||
if (kind === 'FULLTEXT') return 'FULLTEXT';
|
||||
if (kind === 'SPATIAL') return 'RTREE';
|
||||
}
|
||||
return `CREATE TABLE ${tableName} (\n ${colDefs.join(",\n ")}\n) ENGINE=InnoDB DEFAULT CHARSET=${targetCharset} COLLATE=${targetCollation};`;
|
||||
if (isPgLikeDialect(dbType)) {
|
||||
if (kind === 'PRIMARY') return 'BTREE';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => {
|
||||
return buildCreateTablePreviewSql({
|
||||
dbType: getDbType(),
|
||||
tableName: targetTableName,
|
||||
columns: targetColumns,
|
||||
charset: targetCharset,
|
||||
collation: targetCollation,
|
||||
});
|
||||
};
|
||||
|
||||
const openCopySelectedColumnsModal = () => {
|
||||
@@ -1507,7 +1527,7 @@ ${selectedTrigger.statement}`;
|
||||
const sql = buildCreateTableSql(copyTableName.trim(), selectedColumns, copyCharset, copyCollation);
|
||||
setCopyExecuting(true);
|
||||
try {
|
||||
const res = await DBQuery(config as any, tab.dbName || '', sql);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
|
||||
if (res.success) {
|
||||
message.success(`已将 ${selectedColumns.length} 个字段复制到新表 ${copyTableName.trim()}`);
|
||||
setIsCopyColumnsModalOpen(false);
|
||||
@@ -1536,7 +1556,7 @@ ${selectedTrigger.statement}`;
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
let stmt = statements[i];
|
||||
if (!stmt.endsWith(';')) stmt += ';';
|
||||
const res = await DBQuery(config as any, tab.dbName || '', stmt);
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', stmt);
|
||||
if (!res.success) {
|
||||
const prefix = statements.length > 1 ? `第 ${i + 1}/${statements.length} 条语句执行失败: ` : '执行失败: ';
|
||||
return {
|
||||
@@ -2102,105 +2122,62 @@ END;`;
|
||||
return;
|
||||
}
|
||||
|
||||
const tableName = `\`${isNewTable ? newTableName : tab.tableName}\``;
|
||||
|
||||
if (isNewTable) {
|
||||
// CREATE TABLE
|
||||
const sql = buildCreateTableSql(isNewTable ? newTableName : tab.tableName || '', columns, charset, collation);
|
||||
setPreviewSql(sql);
|
||||
setIsPreviewOpen(true);
|
||||
} else {
|
||||
// ALTER TABLE (Existing logic)
|
||||
const alters: string[] = [];
|
||||
|
||||
originalColumns.forEach(orig => {
|
||||
if (!columns.find(c => c._key === orig._key)) {
|
||||
alters.push(`DROP COLUMN \`${orig.name}\``);
|
||||
}
|
||||
const tableInfo = resolveTableInfo();
|
||||
const sql = buildAlterTablePreviewSql({
|
||||
dbType: tableInfo.dbType,
|
||||
tableName: tableInfo.qualifiedName,
|
||||
originalColumns,
|
||||
columns,
|
||||
});
|
||||
|
||||
columns.forEach((curr, index) => {
|
||||
const orig = originalColumns.find(c => c._key === curr._key);
|
||||
const prevCol = index > 0 ? columns[index - 1] : null;
|
||||
const positionSql = prevCol ? `AFTER \`${prevCol.name}\`` : 'FIRST';
|
||||
|
||||
let extra = curr.extra || "";
|
||||
if (curr.isAutoIncrement) {
|
||||
if (!extra.toLowerCase().includes('auto_increment')) extra += " AUTO_INCREMENT";
|
||||
} else {
|
||||
extra = extra.replace(/auto_increment/gi, "").trim();
|
||||
}
|
||||
|
||||
const colDef = `\`${curr.name}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${curr.default}'` : ''} ${extra} COMMENT '${curr.comment}'`;
|
||||
|
||||
if (!orig) {
|
||||
alters.push(`ADD COLUMN ${colDef} ${positionSql}`);
|
||||
} else {
|
||||
const origIndex = originalColumns.findIndex(c => c._key === curr._key);
|
||||
const origPrevCol = origIndex > 0 ? originalColumns[origIndex - 1] : null;
|
||||
|
||||
let positionChanged = false;
|
||||
if (index === 0 && origIndex !== 0) positionChanged = true;
|
||||
if (index > 0 && (!origPrevCol || origPrevCol._key !== prevCol?._key)) positionChanged = true;
|
||||
|
||||
const isNameChanged = orig.name !== curr.name;
|
||||
const isTypeChanged = orig.type !== curr.type;
|
||||
const isNullableChanged = orig.nullable !== curr.nullable;
|
||||
const isDefaultChanged = orig.default !== curr.default;
|
||||
const isCommentChanged = orig.comment !== curr.comment;
|
||||
const isAIChanged = orig.isAutoIncrement !== curr.isAutoIncrement;
|
||||
|
||||
if (isNameChanged || isTypeChanged || isNullableChanged || isDefaultChanged || isCommentChanged || positionChanged || isAIChanged) {
|
||||
if (isNameChanged) {
|
||||
alters.push(`CHANGE COLUMN \`${orig.name}\` ${colDef} ${positionSql}`);
|
||||
} else {
|
||||
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const origPKKeys = originalColumns.filter(c => c.key === 'PRI').map(c => c._key);
|
||||
const newPKKeys = columns.filter(c => c.key === 'PRI').map(c => c._key);
|
||||
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every(k => newPKKeys.includes(k));
|
||||
|
||||
if (keysChanged) {
|
||||
if (origPKKeys.length > 0) alters.push(`DROP PRIMARY KEY`);
|
||||
if (newPKKeys.length > 0) {
|
||||
const pkNames = columns.filter(c => c.key === 'PRI').map(c => `\`${c.name}\``).join(', ');
|
||||
alters.push(`ADD PRIMARY KEY (${pkNames})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (alters.length === 0) {
|
||||
if (!sql.trim()) {
|
||||
message.info("没有检测到变更");
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = `ALTER TABLE ${tableName}\n` + alters.join(",\n");
|
||||
setPreviewSql(sql);
|
||||
setIsPreviewOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshDesigner = () => {
|
||||
if (!hasUnsavedDraftChanges) {
|
||||
void fetchData();
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '存在未保存的字段变更',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '刷新后会丢失当前尚未保存的字段调整,是否仍要刷新并覆盖当前草稿?',
|
||||
okText: '仍然刷新',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await fetchData();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExecuteSave = async () => {
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) return;
|
||||
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
|
||||
const res = await DBQuery(config as any, tab.dbName || '', previewSql);
|
||||
if (res.success) {
|
||||
message.success(isNewTable ? "表创建成功!" : "表结构修改成功!");
|
||||
setIsPreviewOpen(false);
|
||||
if (!isNewTable) {
|
||||
const result = await executeSchemaStatements(previewSql);
|
||||
if (!result.ok) {
|
||||
message.error(result.message || "执行失败");
|
||||
return;
|
||||
}
|
||||
message.success(isNewTable ? "表创建成功!" : "表结构修改成功!");
|
||||
setIsPreviewOpen(false);
|
||||
if (!isNewTable) {
|
||||
fetchData();
|
||||
} else {
|
||||
// TODO: Close tab or reload sidebar?
|
||||
// Ideally, refresh sidebar node.
|
||||
}
|
||||
} else {
|
||||
message.error("执行失败: " + res.message);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Merge columns with resize handler
|
||||
const resizableColumns = useMemo(() => tableColumns.map((col, index) => ({
|
||||
@@ -2536,6 +2513,7 @@ END;`;
|
||||
{isNewTable && (
|
||||
<>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="请输入表名"
|
||||
value={newTableName}
|
||||
onChange={e => setNewTableName(e.target.value)}
|
||||
@@ -2561,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>
|
||||
)}
|
||||
@@ -2849,6 +2827,7 @@ END;`;
|
||||
已选择字段:{selectedColumns.length}
|
||||
</div>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="请输入目标表名"
|
||||
value={copyTableName}
|
||||
onChange={e => setCopyTableName(e.target.value)}
|
||||
@@ -2909,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 }))}
|
||||
@@ -2928,20 +2908,34 @@ END;`;
|
||||
<Select
|
||||
value={indexForm.kind}
|
||||
options={getIndexKindOptions()}
|
||||
onChange={(val: IndexKind) =>
|
||||
setIndexForm(prev => ({
|
||||
...prev,
|
||||
kind: val,
|
||||
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
|
||||
indexType: val === 'NORMAL' || val === 'UNIQUE' ? (prev.indexType || 'DEFAULT') : 'DEFAULT',
|
||||
}))
|
||||
}
|
||||
onChange={(val: IndexKind) => {
|
||||
const fixedType = getFixedIndexType(val);
|
||||
if (fixedType) {
|
||||
// 固定类型(PRIMARY/FULLTEXT/SPATIAL)直接设置对应的索引方法
|
||||
setIndexForm(prev => ({
|
||||
...prev,
|
||||
kind: val,
|
||||
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
|
||||
indexType: fixedType,
|
||||
}));
|
||||
} else {
|
||||
const nextTypeOptions = getIndexTypeOptions(val);
|
||||
const currentType = indexForm.indexType || 'DEFAULT';
|
||||
const isCurrentTypeValid = nextTypeOptions.some(opt => opt.value === currentType);
|
||||
setIndexForm(prev => ({
|
||||
...prev,
|
||||
kind: val,
|
||||
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
|
||||
indexType: isCurrentTypeValid ? currentType : 'DEFAULT',
|
||||
}));
|
||||
}
|
||||
}}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
value={indexForm.indexType}
|
||||
onChange={(val) => setIndexForm(prev => ({ ...prev, indexType: val }))}
|
||||
options={getIndexTypeOptions()}
|
||||
options={getIndexTypeOptions(indexForm.kind)}
|
||||
style={{ width: 160 }}
|
||||
disabled={indexForm.kind === 'PRIMARY' || indexForm.kind === 'FULLTEXT' || indexForm.kind === 'SPATIAL'}
|
||||
/>
|
||||
@@ -2964,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 }))}
|
||||
@@ -2979,6 +2974,7 @@ END;`;
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="参考表(支持 db.table)"
|
||||
value={foreignKeyForm.refTableName}
|
||||
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, refTableName: e.target.value }))}
|
||||
@@ -3007,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,9 +1,22 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd';
|
||||
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined } from '@ant-design/icons';
|
||||
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;
|
||||
@@ -20,8 +33,9 @@ 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 => {
|
||||
if (!bytes || bytes <= 0) return '—';
|
||||
@@ -51,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':
|
||||
@@ -146,8 +173,17 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
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 () => {
|
||||
if (!connection) return;
|
||||
@@ -161,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 as any)?.driver);
|
||||
const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
|
||||
const res = await DBQuery(config as any, tab.dbName || '', sql);
|
||||
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 || '未知错误'));
|
||||
}
|
||||
@@ -174,25 +209,30 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [connection, tab.dbName]);
|
||||
}, [connection, metadataDialect, tab.dbName]);
|
||||
|
||||
useEffect(() => { loadData(); }, [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));
|
||||
useEffect(() => {
|
||||
if (!autoFetchVisible) {
|
||||
return;
|
||||
}
|
||||
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]);
|
||||
void loadData();
|
||||
}, [autoFetchVisible, loadData]);
|
||||
|
||||
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;
|
||||
@@ -237,7 +277,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const handleCopyStructure = useCallback(async (tableName: string) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
const res = await DBShowCreateTable(config as any, tab.dbName || '', tableName);
|
||||
const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName);
|
||||
if (res.success) {
|
||||
navigator.clipboard.writeText(res.data as string);
|
||||
message.success('表结构已复制到剪贴板');
|
||||
@@ -250,7 +290,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
const hide = message.loading(`正在导出 ${tableName} 为 ${format.toUpperCase()}...`, 0);
|
||||
const res = await ExportTable(config as any, tab.dbName || '', tableName, format);
|
||||
const res = await ExportTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, format);
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success('导出成功');
|
||||
@@ -267,7 +307,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const res = await DropTable(config as any, tab.dbName || '', tableName);
|
||||
const res = await DropTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName);
|
||||
if (res.success) {
|
||||
message.success('表删除成功');
|
||||
loadData();
|
||||
@@ -278,6 +318,40 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
});
|
||||
}, [buildConfig, tab.dbName, loadData]);
|
||||
|
||||
const handleTableDataDangerAction = useCallback((tableName: string, action: TableDataDangerActionKind) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
|
||||
const { label, progressLabel } = getTableDataDangerActionMeta(action);
|
||||
Modal.confirm({
|
||||
title: `确认${label}`,
|
||||
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
|
||||
okText: '继续',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const app = (window as any).go.app.App;
|
||||
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
|
||||
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
|
||||
try {
|
||||
const res = await app[methodName](buildRpcConnectionConfig(config) as any, tab.dbName || '', [tableName]);
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success(`${progressLabel}成功`);
|
||||
loadData();
|
||||
} else {
|
||||
message.error(`${progressLabel}失败: ${res.message}`);
|
||||
return Promise.reject();
|
||||
}
|
||||
} catch (e: any) {
|
||||
hide();
|
||||
message.error(`${progressLabel}失败: ${e?.message || String(e)}`);
|
||||
return Promise.reject();
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [buildConfig, tab.dbName, loadData]);
|
||||
|
||||
const handleRenameTable = useCallback((tableName: string) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
@@ -286,6 +360,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
title: '重命名表',
|
||||
content: (
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
defaultValue={tableName}
|
||||
onChange={e => { newName = e.target.value; }}
|
||||
placeholder="输入新表名"
|
||||
@@ -297,7 +372,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed) { message.error('表名不能为空'); return Promise.reject(); }
|
||||
if (trimmed === tableName) { message.warning('新旧表名相同'); return; }
|
||||
const res = await RenameTable(config as any, tab.dbName || '', tableName, trimmed);
|
||||
const res = await RenameTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, trimmed);
|
||||
if (res.success) {
|
||||
message.success('表重命名成功');
|
||||
loadData();
|
||||
@@ -333,8 +408,12 @@ 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 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), [sortedFiltered]);
|
||||
const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -355,6 +434,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="搜索表名或注释..."
|
||||
prefix={<SearchOutlined style={{ color: textMuted }} />}
|
||||
value={searchText}
|
||||
@@ -366,20 +446,74 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
<Dropdown menu={{ items: sortMenuItems }} trigger={['click']}>
|
||||
<Tooltip title="排序"><SortAscendingOutlined style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
|
||||
</Dropdown>
|
||||
<div style={{ display: 'flex', gap: 2, padding: 2, borderRadius: 6, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
|
||||
<Tooltip title="卡片视图">
|
||||
<div
|
||||
onClick={() => setViewMode('card')}
|
||||
style={{
|
||||
padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s',
|
||||
background: viewMode === 'card' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent',
|
||||
boxShadow: viewMode === 'card' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
color: viewMode === 'card' ? accentColor : textMuted,
|
||||
}}
|
||||
>
|
||||
<AppstoreOutlined style={{ fontSize: 14 }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip title="列表视图">
|
||||
<div
|
||||
onClick={() => setViewMode('list')}
|
||||
style={{
|
||||
padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s',
|
||||
background: viewMode === 'list' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent',
|
||||
boxShadow: viewMode === 'list' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
color: viewMode === 'list' ? accentColor : textMuted,
|
||||
}}
|
||||
>
|
||||
<UnorderedListOutlined style={{ fontSize: 14 }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip title="刷新"><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
{/* 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' ? (
|
||||
/* ========== 卡片视图 ========== */
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: 12,
|
||||
}}>
|
||||
{sortedFiltered.map(t => (
|
||||
{visibleTables.map(t => (
|
||||
<Dropdown
|
||||
key={t.name}
|
||||
trigger={['contextMenu']}
|
||||
@@ -393,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' },
|
||||
@@ -401,7 +535,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
|
||||
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
|
||||
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
|
||||
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) },
|
||||
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
|
||||
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
|
||||
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
|
||||
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
|
||||
]},
|
||||
{ type: 'divider' },
|
||||
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
|
||||
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
|
||||
@@ -451,6 +589,157 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</Dropdown>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* ========== 行视图 ========== */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{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%';
|
||||
const fillColor = darkMode ? 'rgba(22,119,255,0.18)' : 'rgba(22,119,255,0.12)';
|
||||
const rowSecondary = t.comment || (t.engine ? `${t.engine} 表` : '双击打开数据,右键查看更多操作');
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
key={t.name}
|
||||
trigger={['contextMenu']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
|
||||
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: '新建查询',
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
query: buildTableSelectQuery(metadataDialect, t.name),
|
||||
});
|
||||
}},
|
||||
{ type: 'divider' },
|
||||
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
|
||||
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
|
||||
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
|
||||
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
|
||||
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
|
||||
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
|
||||
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
|
||||
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
|
||||
]},
|
||||
{ type: 'divider' },
|
||||
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
|
||||
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
|
||||
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
|
||||
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
|
||||
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
|
||||
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
|
||||
]},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onDoubleClick={() => openTable(t.name)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
background: cardBg,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: fillWidth,
|
||||
background: fillColor,
|
||||
pointerEvents: 'none',
|
||||
transition: 'width 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
padding: '14px 16px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: '1 1 320px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<TableOutlined style={{ fontSize: 13, color: accentColor, flexShrink: 0 }} />
|
||||
<Tooltip title={t.name} mouseEnterDelay={0.4}>
|
||||
<span style={{ color: textPrimary, fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{t.engine && (
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
color: textMuted,
|
||||
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
|
||||
}}
|
||||
>
|
||||
{t.engine}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip title={rowSecondary} mouseEnterDelay={0.4}>
|
||||
<div style={{ marginTop: 6, color: textSecondary, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{rowSecondary}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap', fontSize: 12 }}>
|
||||
<div style={{ minWidth: 96, textAlign: 'right' }}>
|
||||
<div style={{ color: textMuted }}>行数</div>
|
||||
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatRows(t.rows)}</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 110, textAlign: 'right' }}>
|
||||
<div style={{ color: textMuted }}>数据大小</div>
|
||||
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.dataSize)}</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 110, textAlign: 'right' }}>
|
||||
<div style={{ color: textMuted }}>索引大小</div>
|
||||
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.indexSize)}</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 96, textAlign: 'right' }}>
|
||||
<div style={{ color: textMuted }}>相对大小</div>
|
||||
<div style={{ color: textPrimary, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{maxCombinedSize > 0 ? `${Math.round(sizeRatio * 100)}%` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Spin, Alert } from 'antd';
|
||||
import { TabData } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery } from '../../wailsjs/go/app/App';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
|
||||
interface TriggerViewerProps {
|
||||
tab: TabData;
|
||||
@@ -100,7 +101,7 @@ LIMIT 1`];
|
||||
const sql = String(query || '').trim();
|
||||
if (!sql) continue;
|
||||
try {
|
||||
const result = await DBQuery(config as any, dbName, sql);
|
||||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
|
||||
if (!result.success || !Array.isArray(result.data)) {
|
||||
lastMessage = result.message || lastMessage;
|
||||
continue;
|
||||
@@ -126,7 +127,7 @@ LIMIT 1`];
|
||||
];
|
||||
for (const query of candidates) {
|
||||
try {
|
||||
const result = await DBQuery(config as any, dbName, query);
|
||||
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
|
||||
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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,9 +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;
|
||||
@@ -20,6 +24,7 @@ interface AIChatInputProps {
|
||||
activeProvider: any;
|
||||
dynamicModels: string[];
|
||||
loadingModels: boolean;
|
||||
sendShortcutBinding: ShortcutBinding;
|
||||
composerNotice?: AIComposerNotice | null;
|
||||
onModelChange: (val: string) => void;
|
||||
onFetchModels: () => void;
|
||||
@@ -35,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
|
||||
}) => {
|
||||
@@ -124,7 +129,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
setContextLoading(true);
|
||||
setSelectedDbName(dbName);
|
||||
try {
|
||||
const res = await DBGetTables(connConfig, dbName);
|
||||
const res = await DBGetTables(buildRpcConnectionConfig(connConfig), dbName);
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
setContextTables(res.data.map(r => ({ name: Object.values(r)[0] as string })));
|
||||
} else {
|
||||
@@ -155,7 +160,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
|
||||
try {
|
||||
// Fetch databases
|
||||
const dbRes = await DBGetDatabases(conn.config as any);
|
||||
const dbRes = await DBGetDatabases(buildRpcConnectionConfig(conn.config) as any);
|
||||
if (dbRes.success && Array.isArray(dbRes.data)) {
|
||||
const databases = dbRes.data.map((r: any) => Object.values(r)[0] as string);
|
||||
setDbList(databases);
|
||||
@@ -164,7 +169,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
// Fetch tables for the active contextual database
|
||||
const initDbName = activeContext.dbName || '';
|
||||
setSelectedDbName(initDbName);
|
||||
const tablesRes = await DBGetTables(conn.config as any, initDbName);
|
||||
const tablesRes = await DBGetTables(buildRpcConnectionConfig(conn.config) as any, initDbName);
|
||||
if (tablesRes.success && Array.isArray(tablesRes.data)) {
|
||||
setContextTables(tablesRes.data.map((r: any) => ({ name: Object.values(r)[0] as string })));
|
||||
} else {
|
||||
@@ -201,24 +206,21 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
if (activeContextItems.find(c => c.dbName === dbName && c.tableName === tableName)) {
|
||||
continue;
|
||||
}
|
||||
const res = await DBShowCreateTable(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++;
|
||||
}
|
||||
@@ -380,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 }}>
|
||||
|
||||
46
frontend/src/components/dataGridAutoWidth.test.ts
Normal file
46
frontend/src/components/dataGridAutoWidth.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
calculateAutoFitColumnWidth,
|
||||
normalizeAutoFitCellText,
|
||||
} from './dataGridAutoWidth';
|
||||
|
||||
const measure = (text: string) => text.length * 8;
|
||||
|
||||
describe('dataGridAutoWidth helpers', () => {
|
||||
it('prefers the widest header or sampled value and adds padding', () => {
|
||||
const width = calculateAutoFitColumnWidth({
|
||||
headerTexts: ['user_name'],
|
||||
valueTexts: ['alice', 'very_long_username_value'],
|
||||
measureHeaderText: measure,
|
||||
measureCellText: measure,
|
||||
padding: 32,
|
||||
minWidth: 80,
|
||||
maxWidth: 720,
|
||||
defaultWidth: 140,
|
||||
});
|
||||
|
||||
expect(width).toBe('very_long_username_value'.length * 8 + 32);
|
||||
});
|
||||
|
||||
it('measures multiline content by the longest visible line and clamps to max width', () => {
|
||||
const width = calculateAutoFitColumnWidth({
|
||||
headerTexts: ['notes'],
|
||||
valueTexts: ['short\nmuch much longer line here'],
|
||||
measureHeaderText: measure,
|
||||
measureCellText: measure,
|
||||
padding: 24,
|
||||
minWidth: 80,
|
||||
maxWidth: 160,
|
||||
defaultWidth: 140,
|
||||
});
|
||||
|
||||
expect(width).toBe(160);
|
||||
});
|
||||
|
||||
it('normalizes null and oversized object values into stable preview text', () => {
|
||||
expect(normalizeAutoFitCellText(null)).toBe('NULL');
|
||||
expect(normalizeAutoFitCellText({ a: 1, b: 2 })).toBe('{"a":1,"b":2}');
|
||||
expect(normalizeAutoFitCellText(Array.from({ length: 81 }, (_, index) => index))).toBe('[Array(81)]');
|
||||
});
|
||||
});
|
||||
108
frontend/src/components/dataGridAutoWidth.ts
Normal file
108
frontend/src/components/dataGridAutoWidth.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
const AUTO_FIT_DEFAULT_MIN_WIDTH = 80;
|
||||
const AUTO_FIT_DEFAULT_MAX_WIDTH = 720;
|
||||
const AUTO_FIT_DEFAULT_PADDING = 40;
|
||||
const AUTO_FIT_DEFAULT_SAMPLE_LIMIT = 200;
|
||||
const AUTO_FIT_MAX_PREVIEW_CHARS = 120;
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
return Object.prototype.toString.call(value) === '[object Object]';
|
||||
};
|
||||
|
||||
const clampWidth = (value: number, minWidth: number, maxWidth: number) => {
|
||||
const safeMin = Math.max(1, Math.floor(minWidth));
|
||||
const safeMax = Math.max(safeMin, Math.floor(maxWidth));
|
||||
return Math.min(safeMax, Math.max(safeMin, Math.ceil(value)));
|
||||
};
|
||||
|
||||
const normalizePreviewLine = (value: string): string => {
|
||||
const normalized = String(value ?? '').replace(/\r\n/g, '\n');
|
||||
if (normalized.length <= AUTO_FIT_MAX_PREVIEW_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, AUTO_FIT_MAX_PREVIEW_CHARS)}…`;
|
||||
};
|
||||
|
||||
const splitPreviewLines = (value: string): string[] => {
|
||||
return normalizePreviewLine(value)
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.length > 0);
|
||||
};
|
||||
|
||||
export const normalizeAutoFitCellText = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return normalizePreviewLine(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 80) {
|
||||
return `[Array(${value.length})]`;
|
||||
}
|
||||
try {
|
||||
return normalizePreviewLine(JSON.stringify(value));
|
||||
} catch {
|
||||
return '[Array]';
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
const topLevelSize = Object.keys(value).length;
|
||||
if (topLevelSize > 80) {
|
||||
return `{Object(${topLevelSize})}`;
|
||||
}
|
||||
try {
|
||||
return normalizePreviewLine(JSON.stringify(value));
|
||||
} catch {
|
||||
return '[Object]';
|
||||
}
|
||||
}
|
||||
|
||||
return normalizePreviewLine(String(value));
|
||||
};
|
||||
|
||||
export const calculateAutoFitColumnWidth = ({
|
||||
headerTexts,
|
||||
valueTexts,
|
||||
measureHeaderText,
|
||||
measureCellText,
|
||||
minWidth = AUTO_FIT_DEFAULT_MIN_WIDTH,
|
||||
maxWidth = AUTO_FIT_DEFAULT_MAX_WIDTH,
|
||||
padding = AUTO_FIT_DEFAULT_PADDING,
|
||||
sampleLimit = AUTO_FIT_DEFAULT_SAMPLE_LIMIT,
|
||||
defaultWidth,
|
||||
}: {
|
||||
headerTexts: Array<string | null | undefined>;
|
||||
valueTexts: unknown[];
|
||||
measureHeaderText: (text: string) => number;
|
||||
measureCellText: (text: string) => number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
padding?: number;
|
||||
sampleLimit?: number;
|
||||
defaultWidth: number;
|
||||
}): number => {
|
||||
const safePadding = Math.max(0, Math.ceil(padding));
|
||||
let widestTextWidth = Math.max(0, Number(defaultWidth) - safePadding);
|
||||
|
||||
headerTexts.forEach((text) => {
|
||||
splitPreviewLines(normalizeAutoFitCellText(text ?? '')).forEach((line) => {
|
||||
widestTextWidth = Math.max(widestTextWidth, measureHeaderText(line));
|
||||
});
|
||||
});
|
||||
|
||||
valueTexts.slice(0, Math.max(1, sampleLimit)).forEach((value) => {
|
||||
splitPreviewLines(normalizeAutoFitCellText(value)).forEach((line) => {
|
||||
widestTextWidth = Math.max(widestTextWidth, measureCellText(line));
|
||||
});
|
||||
});
|
||||
|
||||
return clampWidth(widestTextWidth + safePadding, minWidth, maxWidth);
|
||||
};
|
||||
189
frontend/src/components/dataGridCopyInsert.test.ts
Normal file
189
frontend/src/components/dataGridCopyInsert.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCopyDeleteSQL,
|
||||
buildCopyInsertSQL,
|
||||
buildCopyUpdateSQL,
|
||||
resolveUniqueKeyGroupsFromIndexes,
|
||||
} from './dataGridCopyInsert';
|
||||
|
||||
describe('buildCopyInsertSQL', () => {
|
||||
it('normalizes PostgreSQL timestamp values for copy-as-insert and uses PostgreSQL identifier quoting', () => {
|
||||
const sql = buildCopyInsertSQL({
|
||||
dbType: 'postgres',
|
||||
tableName: 'public.OrderLog',
|
||||
orderedCols: ['CreatedAt', 'note'],
|
||||
record: {
|
||||
CreatedAt: '2026-01-21T18:32:26+08:00',
|
||||
note: "O'Brien",
|
||||
},
|
||||
columnTypesByLowerName: {
|
||||
createdat: 'timestamp without time zone',
|
||||
note: 'text',
|
||||
},
|
||||
});
|
||||
|
||||
expect(sql).toBe(
|
||||
`INSERT INTO public."OrderLog" ("CreatedAt", note) VALUES ('2026-01-21 18:32:26', 'O''Brien');`,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps timezone offsets for timezone-aware PostgreSQL columns while still removing the T separator', () => {
|
||||
const sql = buildCopyInsertSQL({
|
||||
dbType: 'postgres',
|
||||
tableName: 'public.audit_log',
|
||||
orderedCols: ['created_at'],
|
||||
record: {
|
||||
created_at: '2026-01-21T18:32:26+08:00',
|
||||
},
|
||||
columnTypesByLowerName: {
|
||||
created_at: 'timestamp with time zone',
|
||||
},
|
||||
});
|
||||
|
||||
expect(sql).toBe(
|
||||
`INSERT INTO public.audit_log (created_at) VALUES ('2026-01-21 18:32:26+08:00');`,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps RFC3339-looking text unchanged for non-temporal columns', () => {
|
||||
const sql = buildCopyInsertSQL({
|
||||
dbType: 'postgres',
|
||||
tableName: 'public.audit_log',
|
||||
orderedCols: ['payload'],
|
||||
record: {
|
||||
payload: '2026-01-21T18:32:26+08:00',
|
||||
},
|
||||
columnTypesByLowerName: {
|
||||
payload: 'text',
|
||||
},
|
||||
});
|
||||
|
||||
expect(sql).toBe(
|
||||
`INSERT INTO public.audit_log (payload) VALUES ('2026-01-21T18:32:26+08:00');`,
|
||||
);
|
||||
});
|
||||
|
||||
it('groups composite unique indexes by name and sequence order', () => {
|
||||
expect(resolveUniqueKeyGroupsFromIndexes([
|
||||
{ name: 'PRIMARY', columnName: 'id', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' },
|
||||
{ name: 'uk_order_code', columnName: 'code', nonUnique: 0, seqInIndex: 2, indexType: 'BTREE' },
|
||||
{ name: 'uk_order_code', columnName: 'tenant_id', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' },
|
||||
{ name: 'idx_note', columnName: 'note', nonUnique: 1, seqInIndex: 1, indexType: 'BTREE' },
|
||||
])).toEqual([
|
||||
['id'],
|
||||
['tenant_id', 'code'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds UPDATE SQL with a primary-key WHERE clause and keeps literal formatting aligned with INSERT', () => {
|
||||
const result = buildCopyUpdateSQL({
|
||||
dbType: 'mysql',
|
||||
tableName: 'orders',
|
||||
orderedCols: ['id', 'note', 'deleted_at'],
|
||||
record: {
|
||||
id: 7,
|
||||
note: "O'Brien",
|
||||
deleted_at: null,
|
||||
},
|
||||
pkColumns: ['id'],
|
||||
columnTypesByLowerName: {
|
||||
deleted_at: 'datetime',
|
||||
},
|
||||
allTableColumns: ['id', 'note', 'deleted_at'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
whereStrategy: 'primary-key',
|
||||
sql: `UPDATE \`orders\` SET \`id\` = '7', \`note\` = 'O''Brien', \`deleted_at\` = NULL WHERE (\`id\` = '7');`,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds DELETE SQL with a composite unique-key WHERE clause when no primary key is available', () => {
|
||||
const result = buildCopyDeleteSQL({
|
||||
dbType: 'postgres',
|
||||
tableName: 'public.audit_log',
|
||||
orderedCols: ['tenant_id', 'code', 'payload'],
|
||||
record: {
|
||||
tenant_id: 'acme',
|
||||
code: 'evt-7',
|
||||
payload: '{"ok":true}',
|
||||
},
|
||||
uniqueKeyGroups: [['tenant_id', 'code']],
|
||||
allTableColumns: ['tenant_id', 'code', 'payload'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
whereStrategy: 'unique-key',
|
||||
sql: `DELETE FROM public.audit_log WHERE (tenant_id = 'acme' AND code = 'evt-7');`,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to all-column matching and uses IS NULL for null values', () => {
|
||||
const result = buildCopyDeleteSQL({
|
||||
dbType: 'sqlserver',
|
||||
tableName: 'dbo.OrderLog',
|
||||
orderedCols: ['id', 'deleted_at', 'flag'],
|
||||
allTableColumns: ['id', 'deleted_at', 'flag'],
|
||||
record: {
|
||||
id: 5,
|
||||
deleted_at: null,
|
||||
flag: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
whereStrategy: 'all-columns',
|
||||
sql: `DELETE FROM [dbo].[OrderLog] WHERE ([id] = '5' AND [deleted_at] IS NULL AND [flag] = 'true');`,
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
tableName: 'orders',
|
||||
orderedCols: ['note'],
|
||||
allTableColumns: ['id', 'note', 'created_at'],
|
||||
record: {
|
||||
note: 'partial row',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error('expected buildCopyDeleteSQL to fail');
|
||||
}
|
||||
expect(result.error).toContain('主键');
|
||||
expect(result.error).toContain('全部字段');
|
||||
});
|
||||
});
|
||||
444
frontend/src/components/dataGridCopyInsert.ts
Normal file
444
frontend/src/components/dataGridCopyInsert.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
import type { IndexDefinition } from '../types';
|
||||
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
|
||||
import { isOracleLikeDialect } from '../utils/sqlDialect';
|
||||
|
||||
type BuildCopyInsertSQLParams = {
|
||||
dbType: string;
|
||||
tableName?: string;
|
||||
orderedCols: string[];
|
||||
record: Record<string, any>;
|
||||
columnTypesByLowerName?: Record<string, string>;
|
||||
};
|
||||
|
||||
type BuildCopyMutationSQLParams = BuildCopyInsertSQLParams & {
|
||||
pkColumns?: string[];
|
||||
uniqueKeyGroups?: string[][];
|
||||
allTableColumns?: string[];
|
||||
};
|
||||
|
||||
type CopySqlWhereStrategy = 'primary-key' | 'unique-key' | 'all-columns';
|
||||
|
||||
export type CopyMutationSQLResult =
|
||||
| { ok: true; sql: string; whereStrategy: CopySqlWhereStrategy }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type CopyMutationWhereClauseResult =
|
||||
| { ok: true; clause: string; whereStrategy: CopySqlWhereStrategy }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const looksLikeDateTimeText = (val: string): boolean => {
|
||||
if (!val) return false;
|
||||
const len = val.length;
|
||||
if (len < 19 || len > 64) return false;
|
||||
const charCode0 = val.charCodeAt(0);
|
||||
if (charCode0 < 48 || charCode0 > 57) return false;
|
||||
return (
|
||||
val[4] === '-' &&
|
||||
val[7] === '-' &&
|
||||
(val[10] === ' ' || val[10] === 'T') &&
|
||||
val[13] === ':' &&
|
||||
val[16] === ':'
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeDateTimeString = (val: string): string => {
|
||||
if (!looksLikeDateTimeText(val)) {
|
||||
return val;
|
||||
}
|
||||
|
||||
if (/^0{4}-0{2}-0{2}/.test(val)) {
|
||||
return val;
|
||||
}
|
||||
|
||||
const match = val.match(
|
||||
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||
);
|
||||
return match ? `${match[1]} ${match[2]}` : val;
|
||||
};
|
||||
|
||||
const normalizeTimezoneAwareDateTimeString = (val: string): string => {
|
||||
if (!looksLikeDateTimeText(val)) {
|
||||
return val;
|
||||
}
|
||||
|
||||
if (/^0{4}-0{2}-0{2}/.test(val)) {
|
||||
return val;
|
||||
}
|
||||
|
||||
const match = val.match(
|
||||
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
|
||||
);
|
||||
if (!match) {
|
||||
return val;
|
||||
}
|
||||
const suffix = match[3] || '';
|
||||
return `${match[1]} ${match[2]}${suffix}`;
|
||||
};
|
||||
|
||||
const isTemporalColumnType = (columnType?: string): boolean => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return false;
|
||||
if (raw.includes('datetime') || raw.includes('timestamp') || raw.includes('timestamptz')) return true;
|
||||
const base = raw.split(/[ (]/)[0];
|
||||
return base === 'date' || base === 'time' || base === 'timetz' || base === 'year';
|
||||
};
|
||||
|
||||
const isTimezoneAwareColumnType = (columnType?: string): boolean => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return false;
|
||||
return (
|
||||
raw.includes('with time zone') ||
|
||||
raw.includes('with timezone') ||
|
||||
raw.includes('datetimeoffset') ||
|
||||
raw.includes('timestamptz') ||
|
||||
raw.includes('timetz')
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeTemporalLiteralText = (
|
||||
value: string,
|
||||
columnType?: string,
|
||||
normalizeWhenTypeMissing = false,
|
||||
): string => {
|
||||
const rawType = String(columnType || '').trim();
|
||||
if (!rawType) {
|
||||
return normalizeWhenTypeMissing ? normalizeDateTimeString(value) : value;
|
||||
}
|
||||
if (!isTemporalColumnType(rawType)) {
|
||||
return value;
|
||||
}
|
||||
return isTimezoneAwareColumnType(rawType)
|
||||
? normalizeTimezoneAwareDateTimeString(value)
|
||||
: normalizeDateTimeString(value);
|
||||
};
|
||||
|
||||
export const formatLocalDateTimeLiteral = (value: Date): string => {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
const hour = String(value.getHours()).padStart(2, '0');
|
||||
const minute = String(value.getMinutes()).padStart(2, '0');
|
||||
const second = String(value.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
};
|
||||
|
||||
const getColumnType = (columnTypesByLowerName: Record<string, string>, columnName: string): string | undefined => (
|
||||
columnTypesByLowerName[String(columnName || '').toLowerCase()]
|
||||
);
|
||||
|
||||
const getRecordValue = (
|
||||
record: Record<string, any>,
|
||||
columnName: string,
|
||||
): { exists: boolean; value: any } => {
|
||||
if (Object.prototype.hasOwnProperty.call(record || {}, columnName)) {
|
||||
return { exists: true, value: record?.[columnName] };
|
||||
}
|
||||
const loweredColumnName = String(columnName || '').toLowerCase();
|
||||
const matchedKey = Object.keys(record || {}).find((key) => key.toLowerCase() === loweredColumnName);
|
||||
if (!matchedKey) {
|
||||
return { exists: false, value: undefined };
|
||||
}
|
||||
return { exists: true, value: record?.[matchedKey] };
|
||||
};
|
||||
|
||||
const normalizeColumnList = (columns: string[] | undefined): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
(columns || []).forEach((column) => {
|
||||
const normalized = String(column || '').trim();
|
||||
if (!normalized) return;
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (seen.has(lowered)) return;
|
||||
seen.add(lowered);
|
||||
result.push(normalized);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const toNormalizedLiteralText = (value: any, columnType?: string): string => {
|
||||
if (typeof value === 'string') {
|
||||
return normalizeTemporalLiteralText(value, columnType, true);
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return formatLocalDateTimeLiteral(value);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
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))}'`;
|
||||
};
|
||||
|
||||
const doesResultCoverAllTableColumns = (orderedCols: string[], allTableColumns: string[]): boolean => {
|
||||
const normalizedOrderedCols = normalizeColumnList(orderedCols);
|
||||
const normalizedAllTableColumns = normalizeColumnList(allTableColumns);
|
||||
if (normalizedOrderedCols.length === 0 || normalizedOrderedCols.length !== normalizedAllTableColumns.length) {
|
||||
return false;
|
||||
}
|
||||
const orderedSet = new Set(normalizedOrderedCols.map((column) => column.toLowerCase()));
|
||||
return normalizedAllTableColumns.every((column) => orderedSet.has(column.toLowerCase()));
|
||||
};
|
||||
|
||||
const buildWhereClauseForColumns = ({
|
||||
dbType,
|
||||
columns,
|
||||
record,
|
||||
columnTypesByLowerName,
|
||||
requireNonNullValues,
|
||||
}: {
|
||||
dbType: string;
|
||||
columns: string[];
|
||||
record: Record<string, any>;
|
||||
columnTypesByLowerName: Record<string, string>;
|
||||
requireNonNullValues: boolean;
|
||||
}): string | null => {
|
||||
const predicates: string[] = [];
|
||||
for (const columnName of columns) {
|
||||
const { exists, value } = getRecordValue(record, columnName);
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
const quotedColumn = quoteIdentPart(dbType, columnName);
|
||||
if (value === null || value === undefined) {
|
||||
if (requireNonNullValues) {
|
||||
return null;
|
||||
}
|
||||
predicates.push(`${quotedColumn} IS NULL`);
|
||||
continue;
|
||||
}
|
||||
predicates.push(`${quotedColumn} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`);
|
||||
}
|
||||
if (predicates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return `(${predicates.join(' AND ')})`;
|
||||
};
|
||||
|
||||
const resolveMutationWhereClause = ({
|
||||
dbType,
|
||||
orderedCols,
|
||||
record,
|
||||
pkColumns = [],
|
||||
uniqueKeyGroups = [],
|
||||
allTableColumns = [],
|
||||
columnTypesByLowerName = {},
|
||||
}: BuildCopyMutationSQLParams): CopyMutationWhereClauseResult => {
|
||||
const normalizedPkColumns = normalizeColumnList(pkColumns);
|
||||
const pkWhereClause = buildWhereClauseForColumns({
|
||||
dbType,
|
||||
columns: normalizedPkColumns,
|
||||
record,
|
||||
columnTypesByLowerName,
|
||||
requireNonNullValues: true,
|
||||
});
|
||||
if (pkWhereClause) {
|
||||
return { ok: true, clause: pkWhereClause, whereStrategy: 'primary-key' };
|
||||
}
|
||||
|
||||
const normalizedUniqueKeyGroups = (uniqueKeyGroups || [])
|
||||
.map((group) => normalizeColumnList(group))
|
||||
.filter((group) => group.length > 0);
|
||||
for (const group of normalizedUniqueKeyGroups) {
|
||||
const uniqueWhereClause = buildWhereClauseForColumns({
|
||||
dbType,
|
||||
columns: group,
|
||||
record,
|
||||
columnTypesByLowerName,
|
||||
requireNonNullValues: true,
|
||||
});
|
||||
if (uniqueWhereClause) {
|
||||
return { ok: true, clause: uniqueWhereClause, whereStrategy: 'unique-key' };
|
||||
}
|
||||
}
|
||||
|
||||
if (doesResultCoverAllTableColumns(orderedCols, allTableColumns)) {
|
||||
const fullRowWhereClause = buildWhereClauseForColumns({
|
||||
dbType,
|
||||
columns: orderedCols,
|
||||
record,
|
||||
columnTypesByLowerName,
|
||||
requireNonNullValues: false,
|
||||
});
|
||||
if (fullRowWhereClause) {
|
||||
return { ok: true, clause: fullRowWhereClause, whereStrategy: 'all-columns' };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: '当前结果集缺少可安全定位行数据的主键/唯一键,且未覆盖表的全部字段,无法生成 WHERE 条件。',
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCopyInsertSQL = ({
|
||||
dbType,
|
||||
tableName,
|
||||
orderedCols,
|
||||
record,
|
||||
columnTypesByLowerName = {},
|
||||
}: BuildCopyInsertSQLParams): string => {
|
||||
const targetTable = quoteQualifiedIdent(dbType, tableName || 'table');
|
||||
const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col));
|
||||
const values = orderedCols.map((col) => {
|
||||
const { value } = getRecordValue(record, col);
|
||||
return formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, col), dbType);
|
||||
});
|
||||
|
||||
return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`;
|
||||
};
|
||||
|
||||
const buildCopyMutationSQL = (
|
||||
mode: 'update' | 'delete',
|
||||
{
|
||||
dbType,
|
||||
tableName,
|
||||
orderedCols,
|
||||
record,
|
||||
pkColumns = [],
|
||||
uniqueKeyGroups = [],
|
||||
allTableColumns = [],
|
||||
columnTypesByLowerName = {},
|
||||
}: BuildCopyMutationSQLParams,
|
||||
): CopyMutationSQLResult => {
|
||||
const normalizedTableName = String(tableName || '').trim();
|
||||
const normalizedOrderedCols = normalizeColumnList(orderedCols);
|
||||
if (!normalizedTableName) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `当前结果集未关联明确表名,无法生成 ${mode.toUpperCase()} SQL。`,
|
||||
};
|
||||
}
|
||||
if (normalizedOrderedCols.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: '当前结果集没有可复制的字段,无法生成 SQL。',
|
||||
};
|
||||
}
|
||||
|
||||
const whereClause = resolveMutationWhereClause({
|
||||
dbType,
|
||||
orderedCols: normalizedOrderedCols,
|
||||
record,
|
||||
pkColumns,
|
||||
uniqueKeyGroups,
|
||||
allTableColumns,
|
||||
columnTypesByLowerName,
|
||||
});
|
||||
if (whereClause.ok === false) {
|
||||
return { ok: false, error: whereClause.error };
|
||||
}
|
||||
|
||||
const targetTable = quoteQualifiedIdent(dbType, normalizedTableName);
|
||||
if (mode === 'delete') {
|
||||
return {
|
||||
ok: true,
|
||||
sql: `DELETE FROM ${targetTable} WHERE ${whereClause.clause};`,
|
||||
whereStrategy: whereClause.whereStrategy,
|
||||
};
|
||||
}
|
||||
|
||||
const assignments = normalizedOrderedCols.map((columnName) => {
|
||||
const { value } = getRecordValue(record, columnName);
|
||||
return `${quoteIdentPart(dbType, columnName)} = ${formatCopySqlLiteral(value, getColumnType(columnTypesByLowerName, columnName), dbType)}`;
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
sql: `UPDATE ${targetTable} SET ${assignments.join(', ')} WHERE ${whereClause.clause};`,
|
||||
whereStrategy: whereClause.whereStrategy,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCopyUpdateSQL = (params: BuildCopyMutationSQLParams): CopyMutationSQLResult => (
|
||||
buildCopyMutationSQL('update', params)
|
||||
);
|
||||
|
||||
export const buildCopyDeleteSQL = (params: BuildCopyMutationSQLParams): CopyMutationSQLResult => (
|
||||
buildCopyMutationSQL('delete', params)
|
||||
);
|
||||
|
||||
export const resolveUniqueKeyGroupsFromIndexes = (indexes: IndexDefinition[] | undefined): string[][] => {
|
||||
type IndexBucket = {
|
||||
order: number;
|
||||
columns: Array<{ columnName: string; seqInIndex: number; order: number }>;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, IndexBucket>();
|
||||
(indexes || []).forEach((index, order) => {
|
||||
if (index?.nonUnique !== 0) {
|
||||
return;
|
||||
}
|
||||
const name = String(index?.name || '').trim();
|
||||
const columnName = String(index?.columnName || '').trim();
|
||||
if (!name || !columnName) {
|
||||
return;
|
||||
}
|
||||
if (!buckets.has(name)) {
|
||||
buckets.set(name, { order, columns: [] });
|
||||
}
|
||||
const bucket = buckets.get(name);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
bucket.columns.push({
|
||||
columnName,
|
||||
seqInIndex: Number.isFinite(Number(index?.seqInIndex)) ? Number(index.seqInIndex) : 0,
|
||||
order,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(buckets.values())
|
||||
.sort((left, right) => left.order - right.order)
|
||||
.map((bucket) => {
|
||||
const seen = new Set<string>();
|
||||
return bucket.columns
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftSeq = left.seqInIndex > 0 ? left.seqInIndex : Number.MAX_SAFE_INTEGER;
|
||||
const rightSeq = right.seqInIndex > 0 ? right.seqInIndex : Number.MAX_SAFE_INTEGER;
|
||||
if (leftSeq !== rightSeq) {
|
||||
return leftSeq - rightSeq;
|
||||
}
|
||||
return left.order - right.order;
|
||||
})
|
||||
.map((item) => item.columnName)
|
||||
.filter((columnName) => {
|
||||
const lowered = columnName.toLowerCase();
|
||||
if (seen.has(lowered)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(lowered);
|
||||
return true;
|
||||
});
|
||||
})
|
||||
.filter((group) => group.length > 0);
|
||||
};
|
||||
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),
|
||||
}));
|
||||
43
frontend/src/components/dataGridSelectionCopy.test.ts
Normal file
43
frontend/src/components/dataGridSelectionCopy.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
|
||||
|
||||
describe('dataGridSelectionCopy helpers', () => {
|
||||
it('builds clipboard text in visible row and column order', () => {
|
||||
const text = buildSelectedCellClipboardText({
|
||||
selectedCells: [
|
||||
{ rowKey: 'row-2', colName: 'name' },
|
||||
{ rowKey: 'row-1', colName: 'id' },
|
||||
{ rowKey: 'row-1', colName: 'name' },
|
||||
{ rowKey: 'row-2', colName: 'id' },
|
||||
],
|
||||
rows: [
|
||||
{ __rowKey: 'row-1', id: 1, name: 'Alice' },
|
||||
{ __rowKey: 'row-2', id: 2, name: 'Bob' },
|
||||
],
|
||||
columnOrder: ['id', 'name', 'email'],
|
||||
rowKeyField: '__rowKey',
|
||||
});
|
||||
|
||||
expect(text).toBe('1\tAlice\n2\tBob');
|
||||
});
|
||||
|
||||
it('normalizes null, objects and multiline text for clipboard safety', () => {
|
||||
const text = buildSelectedCellClipboardText({
|
||||
selectedCells: [
|
||||
{ rowKey: 'row-1', colName: 'notes' },
|
||||
{ rowKey: 'row-1', colName: 'meta' },
|
||||
{ rowKey: 'row-2', colName: 'notes' },
|
||||
{ rowKey: 'row-2', colName: 'meta' },
|
||||
],
|
||||
rows: [
|
||||
{ __rowKey: 'row-1', notes: null, meta: { a: 1 } },
|
||||
{ __rowKey: 'row-2', notes: 'line1\nline2\tvalue', meta: [1, 2] },
|
||||
],
|
||||
columnOrder: ['notes', 'meta'],
|
||||
rowKeyField: '__rowKey',
|
||||
});
|
||||
|
||||
expect(text).toBe('NULL\t{"a":1}\nline1 line2 value\t[1,2]');
|
||||
});
|
||||
});
|
||||
65
frontend/src/components/dataGridSelectionCopy.ts
Normal file
65
frontend/src/components/dataGridSelectionCopy.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export interface SelectedGridCell {
|
||||
rowKey: string;
|
||||
colName: string;
|
||||
}
|
||||
|
||||
const normalizeClipboardCellValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.replace(/\r\n/g, '\n').replace(/[\t\n\r]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value).replace(/[\t\n\r]+/g, ' ').trim();
|
||||
} catch {
|
||||
return String(value).replace(/[\t\n\r]+/g, ' ').trim();
|
||||
}
|
||||
};
|
||||
|
||||
export const buildSelectedCellClipboardText = ({
|
||||
selectedCells,
|
||||
rows,
|
||||
columnOrder,
|
||||
rowKeyField,
|
||||
}: {
|
||||
selectedCells: SelectedGridCell[];
|
||||
rows: Array<Record<string, any>>;
|
||||
columnOrder: string[];
|
||||
rowKeyField: string;
|
||||
}): string => {
|
||||
if (!selectedCells.length || !rows.length || !columnOrder.length || !rowKeyField) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const selectedRowKeys = new Set(selectedCells.map((cell) => cell.rowKey));
|
||||
const selectedColumnKeys = new Set(selectedCells.map((cell) => cell.colName));
|
||||
const orderedRows = rows.filter((row) => selectedRowKeys.has(String(row?.[rowKeyField] ?? '')));
|
||||
const orderedColumns = columnOrder.filter((columnName) => selectedColumnKeys.has(columnName));
|
||||
|
||||
if (!orderedRows.length || !orderedColumns.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const selectedCellKeySet = new Set(selectedCells.map((cell) => `${cell.rowKey}::${cell.colName}`));
|
||||
|
||||
return orderedRows
|
||||
.map((row) => {
|
||||
const rowKey = String(row?.[rowKeyField] ?? '');
|
||||
return orderedColumns
|
||||
.map((columnName) => {
|
||||
if (!selectedCellKeySet.has(`${rowKey}::${columnName}`)) {
|
||||
return '';
|
||||
}
|
||||
return normalizeClipboardCellValue(row?.[columnName]);
|
||||
})
|
||||
.join('\t');
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
18
frontend/src/components/tableDataDangerActions.test.ts
Normal file
18
frontend/src/components/tableDataDangerActions.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { supportsTableTruncateAction } from './tableDataDangerActions';
|
||||
|
||||
describe('tableDataDangerActions', () => {
|
||||
it('supports native truncate for known relational dialects', () => {
|
||||
expect(supportsTableTruncateAction('mysql')).toBe(true);
|
||||
expect(supportsTableTruncateAction('postgres')).toBe(true);
|
||||
expect(supportsTableTruncateAction('custom', 'postgresql')).toBe(true);
|
||||
expect(supportsTableTruncateAction('custom', 'kingbase8')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects truncate for unsupported or document-style backends', () => {
|
||||
expect(supportsTableTruncateAction('sqlite')).toBe(false);
|
||||
expect(supportsTableTruncateAction('mongodb')).toBe(false);
|
||||
expect(supportsTableTruncateAction('custom', 'sqlite3')).toBe(false);
|
||||
});
|
||||
});
|
||||
82
frontend/src/components/tableDataDangerActions.ts
Normal file
82
frontend/src/components/tableDataDangerActions.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export type TableDataDangerActionKind = 'truncate' | 'clear';
|
||||
|
||||
const resolveCustomDriverDialect = (driver: string): string => {
|
||||
const normalized = String(driver || '').trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'postgresql':
|
||||
case 'postgres':
|
||||
case 'pg':
|
||||
case 'pq':
|
||||
case 'pgx':
|
||||
return 'postgres';
|
||||
case 'dm':
|
||||
case 'dameng':
|
||||
case 'dm8':
|
||||
return 'dameng';
|
||||
case 'sqlite3':
|
||||
case 'sqlite':
|
||||
return 'sqlite';
|
||||
case 'sphinxql':
|
||||
return 'sphinx';
|
||||
case 'diros':
|
||||
case 'doris':
|
||||
return 'diros';
|
||||
case 'kingbase':
|
||||
case 'kingbase8':
|
||||
case 'kingbasees':
|
||||
case 'kingbasev8':
|
||||
return 'kingbase';
|
||||
case 'highgo':
|
||||
return 'highgo';
|
||||
case 'vastbase':
|
||||
return 'vastbase';
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (normalized.includes('postgres')) return 'postgres';
|
||||
if (normalized.includes('kingbase')) return 'kingbase';
|
||||
if (normalized.includes('highgo')) return 'highgo';
|
||||
if (normalized.includes('vastbase')) return 'vastbase';
|
||||
if (normalized.includes('sqlite')) return 'sqlite';
|
||||
if (normalized.includes('sphinx')) return 'sphinx';
|
||||
if (normalized.includes('diros') || normalized.includes('doris')) return 'diros';
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const resolveTableDataActionDBType = (type: string, driver?: string): string => {
|
||||
const normalizedType = String(type || '').trim().toLowerCase();
|
||||
if (normalizedType !== 'custom') {
|
||||
return normalizedType;
|
||||
}
|
||||
return resolveCustomDriverDialect(driver || '');
|
||||
};
|
||||
|
||||
export const supportsTableTruncateAction = (type: string, driver?: string): boolean => {
|
||||
switch (resolveTableDataActionDBType(type, driver)) {
|
||||
case 'mysql':
|
||||
case 'mariadb':
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase':
|
||||
case 'sqlserver':
|
||||
case 'oracle':
|
||||
case 'dameng':
|
||||
case 'clickhouse':
|
||||
case 'duckdb':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTableDataDangerActionMeta = (action: TableDataDangerActionKind): {
|
||||
label: string;
|
||||
progressLabel: string;
|
||||
} => {
|
||||
if (action === 'truncate') {
|
||||
return { label: '截断表', progressLabel: '截断' };
|
||||
}
|
||||
return { label: '清空表', progressLabel: '清空' };
|
||||
};
|
||||
216
frontend/src/components/tableDesignerSchemaSql.test.ts
Normal file
216
frontend/src/components/tableDesignerSchemaSql.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCreateTablePreviewSql,
|
||||
buildAlterTablePreviewSql,
|
||||
hasAlterTableDraftChanges,
|
||||
type BuildAlterTablePreviewInput,
|
||||
type EditableColumnSnapshot,
|
||||
} from './tableDesignerSchemaSql';
|
||||
|
||||
const baseColumn = (overrides: Partial<EditableColumnSnapshot>): EditableColumnSnapshot => ({
|
||||
_key: overrides._key || 'col',
|
||||
name: overrides.name || 'id',
|
||||
type: overrides.type || 'int',
|
||||
nullable: overrides.nullable || 'NO',
|
||||
default: overrides.default || '',
|
||||
extra: overrides.extra || '',
|
||||
comment: overrides.comment || '',
|
||||
key: overrides.key || '',
|
||||
isAutoIncrement: overrides.isAutoIncrement || false,
|
||||
});
|
||||
|
||||
const buildInput = (overrides: Partial<BuildAlterTablePreviewInput>): BuildAlterTablePreviewInput => ({
|
||||
dbType: overrides.dbType || 'mysql',
|
||||
tableName: overrides.tableName || 'users',
|
||||
originalColumns: overrides.originalColumns || [baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' })],
|
||||
columns: overrides.columns || [
|
||||
baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' }),
|
||||
baseColumn({ _key: 'age', name: 'age', nullable: 'YES', comment: '年龄' }),
|
||||
],
|
||||
});
|
||||
|
||||
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' }));
|
||||
|
||||
expect(sql).toContain('ALTER TABLE `users`');
|
||||
expect(sql).toContain('ADD COLUMN `age` int NULL');
|
||||
expect(sql).toContain("COMMENT '年龄'");
|
||||
expect(sql).toContain('AFTER `id`');
|
||||
});
|
||||
|
||||
it('builds kingbase alter preview without mysql-only syntax', () => {
|
||||
const sql = buildAlterTablePreviewSql(buildInput({
|
||||
dbType: 'kingbase',
|
||||
tableName: 'public.users',
|
||||
}));
|
||||
|
||||
expect(sql).toContain('ALTER TABLE public.users');
|
||||
expect(sql).toContain('ADD COLUMN age int');
|
||||
expect(sql).toContain("COMMENT ON COLUMN public.users.age IS '年龄';");
|
||||
expect(sql).not.toContain('`');
|
||||
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('`');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user