mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-10 06:52:18 +08:00
✨ feat(redis/monitor/oracle/data-viewer): 新增 Redis 实例监控并优化 Oracle 大表预览体验
- 新增 RedisMonitor 面板,展示 QPS、内存、CPU、连接数和键数量趋势图 - 引入 recharts 依赖并补齐监控图表所需前端包与锁文件 - Sidebar 新增 Redis 实例监控入口,TabManager 与 TabData 接入 redis-monitor 页签类型 - RedisCommandEditor 支持多行脚本块解析、选区执行、耗时记录与终端化结果展示 - Oracle 表预览移除自动精确 COUNT(*),避免打开大表时额外阻塞 - 无筛选整表预览接入 ALL_TABLES.NUM_ROWS 近似总数展示,并拆分近似总数与近似总页数语义
This commit is contained in:
@@ -216,7 +216,7 @@ External contributors should open pull requests directly against `main`.
|
|||||||
|
|
||||||
- [linux.do](https://linux.do/)
|
- [linux.do](https://linux.do/)
|
||||||
- [AIBook](https://aibook.ren/)
|
- [AIBook](https://aibook.ren/)
|
||||||
-
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Licensed under [Apache-2.0](LICENSE).
|
Licensed under [Apache-2.0](LICENSE).
|
||||||
|
|||||||
189
frontend/package-lock.json
generated
189
frontend/package-lock.json
generated
@@ -23,6 +23,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable": "^3.1.3",
|
"react-resizable": "^3.1.3",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sql-formatter": "^15.7.0",
|
"sql-formatter": "^15.7.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
@@ -1210,6 +1211,42 @@
|
|||||||
"react-dom": ">=16.9.0"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1567,6 +1604,18 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -2001,6 +2050,12 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/uuid": {
|
||||||
"version": "9.0.8",
|
"version": "9.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
||||||
@@ -3046,6 +3101,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": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||||
@@ -3130,6 +3191,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@@ -3211,6 +3282,12 @@
|
|||||||
"@types/estree": "^1.0.0"
|
"@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": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -3404,6 +3481,16 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/inline-style-parser": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||||
@@ -5511,6 +5598,29 @@
|
|||||||
"react": ">=18"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -5555,6 +5665,51 @@
|
|||||||
"react": ">= 0.14.0"
|
"react": ">= 0.14.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": {
|
"node_modules/refractor": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
|
||||||
@@ -5637,6 +5792,12 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/resize-observer-polyfill": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||||
@@ -5888,6 +6049,12 @@
|
|||||||
"node": ">=12.22"
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -6178,6 +6345,28 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable": "^3.1.3",
|
"react-resizable": "^3.1.3",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sql-formatter": "^15.7.0",
|
"sql-formatter": "^15.7.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
6ba85e4f456d2c0d230cab198c7dc02b
|
f697e821b4acd5cf614d63d46453e8a4
|
||||||
@@ -32,6 +32,7 @@ import 'react-resizable/css/styles.css';
|
|||||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||||
|
import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination';
|
||||||
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
|
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
|
||||||
|
|
||||||
// --- Error Boundary ---
|
// --- Error Boundary ---
|
||||||
@@ -818,6 +819,7 @@ interface DataGridProps {
|
|||||||
total: number,
|
total: number,
|
||||||
totalKnown?: boolean,
|
totalKnown?: boolean,
|
||||||
totalApprox?: boolean,
|
totalApprox?: boolean,
|
||||||
|
approximateTotal?: number,
|
||||||
totalCountLoading?: boolean,
|
totalCountLoading?: boolean,
|
||||||
totalCountCancelled?: boolean,
|
totalCountCancelled?: boolean,
|
||||||
};
|
};
|
||||||
@@ -995,7 +997,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
const selectionColumnWidth = 46;
|
const selectionColumnWidth = 46;
|
||||||
const currentConnConfig = connections.find(c => c.id === connectionId)?.config;
|
const currentConnConfig = connections.find(c => c.id === connectionId)?.config;
|
||||||
const dataSourceCaps = getDataSourceCapabilities(currentConnConfig);
|
const dataSourceCaps = getDataSourceCapabilities(currentConnConfig);
|
||||||
const isDuckDBConnection = dataSourceCaps.type === 'duckdb';
|
const prefersManualTotalCount = dataSourceCaps.preferManualTotalCount;
|
||||||
|
const supportsApproximateTableCount = dataSourceCaps.supportsApproximateTableCount;
|
||||||
|
const supportsApproximateTotalPages = dataSourceCaps.supportsApproximateTotalPages;
|
||||||
const supportsCopyInsert = dataSourceCaps.supportsCopyInsert;
|
const supportsCopyInsert = dataSourceCaps.supportsCopyInsert;
|
||||||
const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport;
|
const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport;
|
||||||
const isQueryResultExport = exportScope === 'queryResult';
|
const isQueryResultExport = exportScope === 'queryResult';
|
||||||
@@ -4483,37 +4487,20 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
|
|
||||||
const paginationSummaryText = useMemo(() => {
|
const paginationSummaryText = useMemo(() => {
|
||||||
if (!pagination) return '';
|
if (!pagination) return '';
|
||||||
const total = Number.isFinite(pagination.total) ? pagination.total : 0;
|
return resolvePaginationSummaryText({
|
||||||
const rangeStart = Math.max(0, (pagination.current - 1) * pagination.pageSize + (total > 0 ? 1 : 0));
|
pagination,
|
||||||
const hasValidRange = total > 0 && rangeStart > 0;
|
prefersManualTotalCount,
|
||||||
const rangeEnd = hasValidRange ? Math.min(total, rangeStart + pagination.pageSize - 1) : 0;
|
supportsApproximateTableCount,
|
||||||
const currentCount = hasValidRange ? Math.max(0, rangeEnd - rangeStart + 1) : 0;
|
});
|
||||||
|
}, [pagination, prefersManualTotalCount, supportsApproximateTableCount]);
|
||||||
if (pagination.totalKnown === false) {
|
|
||||||
if (isDuckDBConnection) {
|
|
||||||
if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数…`;
|
|
||||||
if (pagination.totalApprox && Number.isFinite(total) && total > 0) return `当前 ${currentCount} 条 / 约 ${total} 条`;
|
|
||||||
if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`;
|
|
||||||
return `当前 ${currentCount} 条 / 总数未统计`;
|
|
||||||
}
|
|
||||||
return `当前 ${currentCount} 条 / 正在统计总数…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDuckDBConnection && (!Number.isFinite(total) || total <= 0)) {
|
|
||||||
return '当前 0 条 / 共 0 条';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `当前 ${currentCount} 条 / 共 ${total} 条`;
|
|
||||||
}, [pagination, isDuckDBConnection]);
|
|
||||||
|
|
||||||
const paginationPageText = useMemo(() => {
|
const paginationPageText = useMemo(() => {
|
||||||
if (!pagination) return '';
|
if (!pagination) return '';
|
||||||
const total = Number.isFinite(pagination.total) ? pagination.total : 0;
|
return resolvePaginationPageText({
|
||||||
const canShowTotalPages = pagination.totalKnown !== false || (isDuckDBConnection && pagination.totalApprox && total > 0);
|
pagination,
|
||||||
if (!canShowTotalPages || total <= 0) return `第 ${pagination.current} 页`;
|
supportsApproximateTotalPages,
|
||||||
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pagination.pageSize)));
|
});
|
||||||
return `第 ${pagination.current} / ${totalPages} 页`;
|
}, [pagination, supportsApproximateTotalPages]);
|
||||||
}, [pagination, isDuckDBConnection]);
|
|
||||||
|
|
||||||
const handlePageSizeChange = useCallback((value: string) => {
|
const handlePageSizeChange = useCallback((value: string) => {
|
||||||
if (!pagination || !onPageChange) return;
|
if (!pagination || !onPageChange) return;
|
||||||
@@ -4680,7 +4667,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
{isDuckDBConnection && onRequestTotalCount && (
|
{prefersManualTotalCount && onRequestTotalCount && (
|
||||||
<>
|
<>
|
||||||
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
|
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
|
||||||
<Tooltip title={pagination?.totalCountLoading ? '取消本次精确总数统计(不会影响当前浏览)' : '按当前筛选统计精确总数'}>
|
<Tooltip title={pagination?.totalCountLoading ? '取消本次精确总数统计(不会影响当前浏览)' : '按当前筛选统计精确总数'}>
|
||||||
@@ -5539,7 +5526,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
<Pagination
|
<Pagination
|
||||||
current={pagination.current}
|
current={pagination.current}
|
||||||
pageSize={pagination.pageSize}
|
pageSize={pagination.pageSize}
|
||||||
total={pagination.total}
|
total={resolvePaginationTotalForControl({
|
||||||
|
pagination,
|
||||||
|
supportsApproximateTotalPages,
|
||||||
|
})}
|
||||||
showSizeChanger={false}
|
showSizeChanger={false}
|
||||||
onChange={onPageChange}
|
onChange={onPageChange}
|
||||||
showTitle={false}
|
showTitle={false}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
|||||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||||
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
|
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
|
||||||
|
import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveApproximateTableCountStrategy } from '../utils/approximateTableCount';
|
||||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||||
|
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
|
||||||
|
|
||||||
type ViewerPaginationState = {
|
type ViewerPaginationState = {
|
||||||
current: number;
|
current: number;
|
||||||
@@ -14,6 +16,7 @@ type ViewerPaginationState = {
|
|||||||
total: number;
|
total: number;
|
||||||
totalKnown: boolean;
|
totalKnown: boolean;
|
||||||
totalApprox: boolean;
|
totalApprox: boolean;
|
||||||
|
approximateTotal?: number;
|
||||||
totalCountLoading: boolean;
|
totalCountLoading: boolean;
|
||||||
totalCountCancelled: boolean;
|
totalCountCancelled: boolean;
|
||||||
};
|
};
|
||||||
@@ -70,30 +73,6 @@ const parseTotalFromCountRow = (row: any): number | null => {
|
|||||||
return 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 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 normalizeDuckDBIdentifier = (raw: string): string => {
|
const normalizeDuckDBIdentifier = (raw: string): string => {
|
||||||
const text = String(raw || '').trim();
|
const text = String(raw || '').trim();
|
||||||
if (text.length >= 2) {
|
if (text.length >= 2) {
|
||||||
@@ -201,7 +180,7 @@ 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 initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||||
@@ -214,6 +193,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const countKeyRef = useRef<string>('');
|
const countKeyRef = useRef<string>('');
|
||||||
const duckdbApproxSeqRef = useRef(0);
|
const duckdbApproxSeqRef = useRef(0);
|
||||||
const duckdbApproxKeyRef = useRef<string>('');
|
const duckdbApproxKeyRef = useRef<string>('');
|
||||||
|
const oracleApproxSeqRef = useRef(0);
|
||||||
|
const oracleApproxKeyRef = useRef<string>('');
|
||||||
const manualCountSeqRef = useRef(0);
|
const manualCountSeqRef = useRef(0);
|
||||||
const manualCountKeyRef = useRef<string>('');
|
const manualCountKeyRef = useRef<string>('');
|
||||||
const pkSeqRef = useRef(0);
|
const pkSeqRef = useRef(0);
|
||||||
@@ -228,6 +209,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
left: initialViewerSnapshot.scrollLeft,
|
left: initialViewerSnapshot.scrollLeft,
|
||||||
});
|
});
|
||||||
const initialLoadRef = useRef(false);
|
const initialLoadRef = useRef(false);
|
||||||
|
const skipNextAutoFetchRef = useRef(false);
|
||||||
|
|
||||||
const [pagination, setPagination] = useState<ViewerPaginationState>({
|
const [pagination, setPagination] = useState<ViewerPaginationState>({
|
||||||
current: initialViewerSnapshot.currentPage,
|
current: initialViewerSnapshot.currentPage,
|
||||||
@@ -246,8 +228,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
|
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
|
||||||
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
|
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
|
||||||
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
|
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
|
||||||
const currentConnType = currentConnCaps.type;
|
|
||||||
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
|
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 persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial<ViewerFilterSnapshot>) => {
|
||||||
const normalizedTabId = String(tabId || '').trim();
|
const normalizedTabId = String(tabId || '').trim();
|
||||||
if (!normalizedTabId) return;
|
if (!normalizedTabId) return;
|
||||||
@@ -288,6 +272,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
pkKeyRef.current = '';
|
pkKeyRef.current = '';
|
||||||
countKeyRef.current = '';
|
countKeyRef.current = '';
|
||||||
duckdbApproxKeyRef.current = '';
|
duckdbApproxKeyRef.current = '';
|
||||||
|
oracleApproxKeyRef.current = '';
|
||||||
manualCountKeyRef.current = '';
|
manualCountKeyRef.current = '';
|
||||||
duckdbSafeSelectCacheRef.current = {};
|
duckdbSafeSelectCacheRef.current = {};
|
||||||
latestConfigRef.current = null;
|
latestConfigRef.current = null;
|
||||||
@@ -297,6 +282,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
latestCountKeyRef.current = '';
|
latestCountKeyRef.current = '';
|
||||||
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
|
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
|
||||||
initialLoadRef.current = false;
|
initialLoadRef.current = false;
|
||||||
|
skipNextAutoFetchRef.current = true;
|
||||||
setPagination(prev => ({
|
setPagination(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
current: snapshot.currentPage,
|
current: snapshot.currentPage,
|
||||||
@@ -304,6 +290,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
total: 0,
|
total: 0,
|
||||||
totalKnown: false,
|
totalKnown: false,
|
||||||
totalApprox: false,
|
totalApprox: false,
|
||||||
|
approximateTotal: undefined,
|
||||||
totalCountLoading: false,
|
totalCountLoading: false,
|
||||||
totalCountCancelled: false,
|
totalCountCancelled: false,
|
||||||
}));
|
}));
|
||||||
@@ -317,10 +304,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
});
|
});
|
||||||
}, [tab.id, persistViewerSnapshot]);
|
}, [tab.id, persistViewerSnapshot]);
|
||||||
|
|
||||||
const handleDuckDBManualCount = useCallback(async () => {
|
const handleManualTotalCount = useCallback(async () => {
|
||||||
if (latestDbTypeRef.current !== 'duckdb') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const config = latestConfigRef.current;
|
const config = latestConfigRef.current;
|
||||||
const dbName = latestDbNameRef.current;
|
const dbName = latestDbNameRef.current;
|
||||||
const countSql = latestCountSqlRef.current;
|
const countSql = latestCountSqlRef.current;
|
||||||
@@ -341,7 +325,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const resCount = await DBQuery(countConfig as any, dbName, countSql);
|
const resCount = await DBQuery(countConfig as any, dbName, countSql);
|
||||||
const countDuration = Date.now() - countStart;
|
const countDuration = Date.now() - countStart;
|
||||||
addSqlLog({
|
addSqlLog({
|
||||||
id: `log-${Date.now()}-duckdb-manual-count`,
|
id: `log-${Date.now()}-manual-count`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
sql: countSql,
|
sql: countSql,
|
||||||
status: resCount?.success ? 'success' : 'error',
|
status: resCount?.success ? 'success' : 'error',
|
||||||
@@ -375,6 +359,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
total,
|
total,
|
||||||
totalKnown: true,
|
totalKnown: true,
|
||||||
totalApprox: false,
|
totalApprox: false,
|
||||||
|
approximateTotal: undefined,
|
||||||
totalCountLoading: false,
|
totalCountLoading: false,
|
||||||
totalCountCancelled: false,
|
totalCountCancelled: false,
|
||||||
}));
|
}));
|
||||||
@@ -386,7 +371,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
}
|
}
|
||||||
}, [addSqlLog]);
|
}, [addSqlLog]);
|
||||||
|
|
||||||
const handleDuckDBCancelManualCount = useCallback(() => {
|
const handleCancelManualTotalCount = useCallback(() => {
|
||||||
manualCountSeqRef.current++;
|
manualCountSeqRef.current++;
|
||||||
setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true }));
|
setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true }));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -438,7 +423,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const totalRows = Number(pagination.total);
|
const totalRows = Number(pagination.total);
|
||||||
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
|
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
|
||||||
const totalKnown = pagination.totalKnown && hasFiniteTotal;
|
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 currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page);
|
||||||
const offset = (currentPage - 1) * size;
|
const offset = (currentPage - 1) * size;
|
||||||
const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse';
|
const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse';
|
||||||
@@ -632,6 +625,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
total: derivedTotal,
|
total: derivedTotal,
|
||||||
totalKnown: true,
|
totalKnown: true,
|
||||||
totalApprox: false,
|
totalApprox: false,
|
||||||
|
approximateTotal: undefined,
|
||||||
totalCountLoading: false,
|
totalCountLoading: false,
|
||||||
totalCountCancelled: false,
|
totalCountCancelled: false,
|
||||||
};
|
};
|
||||||
@@ -647,13 +641,20 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey;
|
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 {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
current: currentPage,
|
current: currentPage,
|
||||||
pageSize: size,
|
pageSize: size,
|
||||||
|
total: derivedTotal,
|
||||||
totalKnown: false,
|
totalKnown: false,
|
||||||
totalApprox: true,
|
totalApprox: true,
|
||||||
|
approximateTotal: prev.approximateTotal,
|
||||||
totalCountLoading: keepManualCounting,
|
totalCountLoading: keepManualCounting,
|
||||||
totalCountCancelled: false,
|
totalCountCancelled: false,
|
||||||
};
|
};
|
||||||
@@ -665,12 +666,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
total: derivedTotal,
|
total: derivedTotal,
|
||||||
totalKnown: false,
|
totalKnown: false,
|
||||||
totalApprox: false,
|
totalApprox: false,
|
||||||
|
approximateTotal: undefined,
|
||||||
totalCountLoading: keepManualCounting,
|
totalCountLoading: keepManualCounting,
|
||||||
totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled,
|
totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldRunAsyncCount = !derivedTotalKnown && !isDuckDB;
|
const shouldRunAsyncCount = !derivedTotalKnown && !preferManualTotalCount;
|
||||||
if (shouldRunAsyncCount) {
|
if (shouldRunAsyncCount) {
|
||||||
if (countKeyRef.current !== countKey) {
|
if (countKeyRef.current !== countKey) {
|
||||||
countKeyRef.current = countKey;
|
countKeyRef.current = countKey;
|
||||||
@@ -695,7 +697,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (countSeqRef.current !== countSeq) return;
|
if (countSeqRef.current !== countSeq) return;
|
||||||
if (countKeyRef.current !== countKey) return;
|
if (latestCountKeyRef.current !== countKey) return;
|
||||||
|
|
||||||
if (!resCount.success) return;
|
if (!resCount.success) return;
|
||||||
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
|
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
|
||||||
@@ -708,6 +710,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
total,
|
total,
|
||||||
totalKnown: true,
|
totalKnown: true,
|
||||||
totalApprox: false,
|
totalApprox: false,
|
||||||
|
approximateTotal: undefined,
|
||||||
totalCountLoading: false,
|
totalCountLoading: false,
|
||||||
totalCountCancelled: false,
|
totalCountCancelled: false,
|
||||||
}));
|
}));
|
||||||
@@ -720,48 +723,88 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDuckDB && !derivedTotalKnown && whereSQL.trim() === '' && duckdbApproxKeyRef.current !== countKey) {
|
if (!derivedTotalKnown) {
|
||||||
duckdbApproxKeyRef.current = countKey;
|
const approximateCountStrategy = supportsApproximateTableCount
|
||||||
const approxSeq = ++duckdbApproxSeqRef.current;
|
? resolveApproximateTableCountStrategy({ dbType: dbTypeLower, whereSQL })
|
||||||
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
|
: 'none';
|
||||||
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`,
|
|
||||||
];
|
|
||||||
|
|
||||||
(async () => {
|
if (approximateCountStrategy === 'duckdb-estimated-size' && duckdbApproxKeyRef.current !== countKey) {
|
||||||
for (const approxSql of approxSqlCandidates) {
|
duckdbApproxKeyRef.current = countKey;
|
||||||
try {
|
const approxSeq = ++duckdbApproxSeqRef.current;
|
||||||
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
|
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
|
||||||
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
const escapedSchema = escapeSQLLiteral(schemaName);
|
||||||
if (countKeyRef.current !== countKey) return;
|
const escapedTable = escapeSQLLiteral(pureTableName);
|
||||||
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
|
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`,
|
||||||
|
];
|
||||||
|
|
||||||
const approxTotal = parseDuckDBApproxTotalRow(approxRes.data[0]);
|
(async () => {
|
||||||
if (approxTotal === null) continue;
|
for (const approxSql of approxSqlCandidates) {
|
||||||
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
|
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: any = { ...(config as any), 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 => {
|
setPagination(prev => {
|
||||||
if (countKeyRef.current !== countKey) return prev;
|
if (latestCountKeyRef.current !== countKey) return prev;
|
||||||
if (prev.totalKnown) return prev;
|
if (prev.totalKnown) return prev;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
total: approxTotal,
|
|
||||||
totalKnown: false,
|
totalKnown: false,
|
||||||
totalApprox: true,
|
totalApprox: true,
|
||||||
|
approximateTotal: approxTotal,
|
||||||
totalCountCancelled: false,
|
totalCountCancelled: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return;
|
})
|
||||||
} catch {
|
.catch(() => {
|
||||||
if (duckdbApproxSeqRef.current !== approxSeq) return;
|
if (oracleApproxSeqRef.current !== approxSeq) return;
|
||||||
if (countKeyRef.current !== countKey) return;
|
if (latestCountKeyRef.current !== countKey) return;
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
message.error(String(resData.message || '查询失败'));
|
message.error(String(resData.message || '查询失败'));
|
||||||
@@ -780,7 +823,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (fetchSeqRef.current === seq) setLoading(false);
|
if (fetchSeqRef.current === seq) setLoading(false);
|
||||||
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown]);
|
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
|
||||||
// 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。
|
// 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。
|
||||||
// 主键信息只会在首次加载后更新一次,避免循环查询。
|
// 主键信息只会在首次加载后更新一次,避免循环查询。
|
||||||
|
|
||||||
@@ -828,7 +871,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
|
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
initialLoadRef.current = true;
|
||||||
fetchData(pagination.current, pagination.pageSize);
|
fetchData(pagination.current, pagination.pageSize);
|
||||||
return;
|
return;
|
||||||
@@ -851,8 +902,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
onRequestTotalCount={currentConnType === 'duckdb' ? handleDuckDBManualCount : undefined}
|
onRequestTotalCount={preferManualTotalCount ? handleManualTotalCount : undefined}
|
||||||
onCancelTotalCount={currentConnType === 'duckdb' ? handleDuckDBCancelManualCount : undefined}
|
onCancelTotalCount={preferManualTotalCount ? handleCancelManualTotalCount : undefined}
|
||||||
showFilter={showFilter}
|
showFilter={showFilter}
|
||||||
onToggleFilter={handleToggleFilter}
|
onToggleFilter={handleToggleFilter}
|
||||||
onApplyFilter={handleApplyFilter}
|
onApplyFilter={handleApplyFilter}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ let sharedAllColumnsData: {dbName: string, tableName: string, name: string, type
|
|||||||
let sharedVisibleDbs: string[] = [];
|
let sharedVisibleDbs: string[] = [];
|
||||||
let sharedColumnsCacheData: Record<string, any[]> = {};
|
let sharedColumnsCacheData: Record<string, any[]> = {};
|
||||||
|
|
||||||
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
|
||||||
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
|
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
|
||||||
|
|
||||||
type ResultSet = {
|
type ResultSet = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback, useRef } from 'react';
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { Button, Space, message } from 'antd';
|
import { Button, Space, message } from 'antd';
|
||||||
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
|
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
@@ -14,6 +14,67 @@ interface CommandResult {
|
|||||||
result: any;
|
result: any;
|
||||||
error?: string;
|
error?: string;
|
||||||
timestamp: number;
|
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 }) => {
|
const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, redisDB }) => {
|
||||||
@@ -23,6 +84,13 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
|||||||
const [command, setCommand] = useState('');
|
const [command, setCommand] = useState('');
|
||||||
const [results, setResults] = useState<CommandResult[]>([]);
|
const [results, setResults] = useState<CommandResult[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
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 editorRef = useRef<any>(null);
|
||||||
|
|
||||||
const getConfig = useCallback(() => {
|
const getConfig = useCallback(() => {
|
||||||
@@ -37,77 +105,173 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
|||||||
};
|
};
|
||||||
}, [connection, redisDB]);
|
}, [connection, redisDB]);
|
||||||
|
|
||||||
const handleEditorMount: OnMount = (editor) => {
|
const handleEditorMount: OnMount = (editor, monaco) => {
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
// Add keyboard shortcut for execute
|
|
||||||
editor.addCommand(
|
editor.addCommand(
|
||||||
// Ctrl/Cmd + Enter
|
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
|
||||||
2048 | 3, // KeyMod.CtrlCmd | KeyCode.Enter
|
|
||||||
() => handleExecute()
|
() => 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 handleExecute = async () => {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
if (!config) return;
|
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) {
|
if (!cmdToExecute) {
|
||||||
message.warning('请输入命令');
|
message.warning('请输入要执行的命令');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Support multiple commands separated by newlines
|
// 2. 智能解析多行命令
|
||||||
const commands = cmdToExecute.split('\n').filter(c => c.trim() && !c.trim().startsWith('//') && !c.trim().startsWith('#'));
|
const commands = parseRedisScriptBlocks(cmdToExecute);
|
||||||
|
if (commands.length === 0) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const newResults: CommandResult[] = [];
|
const newResults: CommandResult[] = [];
|
||||||
|
|
||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
const trimmedCmd = cmd.trim();
|
const start = Date.now();
|
||||||
if (!trimmedCmd) continue;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await (window as any).go.app.App.RedisExecuteCommand(config, trimmedCmd);
|
const res = await (window as any).go.app.App.RedisExecuteCommand(config, cmd);
|
||||||
newResults.push({
|
newResults.push({
|
||||||
command: trimmedCmd,
|
command: cmd,
|
||||||
result: res.success ? res.data : null,
|
result: res.success ? res.data : null,
|
||||||
error: res.success ? undefined : res.message,
|
error: res.success ? undefined : res.message,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now(),
|
||||||
|
durationMs: Date.now() - start
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
newResults.push({
|
newResults.push({
|
||||||
command: trimmedCmd,
|
command: cmd,
|
||||||
result: null,
|
result: null,
|
||||||
error: e?.message || String(e),
|
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);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto scroll to bottom when new results arrive
|
||||||
|
useEffect(() => {
|
||||||
|
if (resultsEndRef.current) {
|
||||||
|
resultsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatResult = (result: any): string => {
|
const formatResult = (result: any): React.ReactNode => {
|
||||||
if (result === null || result === undefined) {
|
if (result === null || result === undefined) {
|
||||||
return '(nil)';
|
return <span style={{ color: '#569cd6' }}>(nil)</span>;
|
||||||
}
|
}
|
||||||
if (typeof result === 'string') {
|
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') {
|
if (typeof result === 'number') {
|
||||||
return `(integer) ${result}`;
|
return <span style={{ color: '#b5cea8' }}>(integer) {result}</span>;
|
||||||
}
|
}
|
||||||
if (Array.isArray(result)) {
|
if (Array.isArray(result)) {
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
return '(empty array)';
|
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') {
|
if (typeof result === 'object') {
|
||||||
return JSON.stringify(result, null, 2);
|
return JSON.stringify(result, null, 2);
|
||||||
@@ -115,18 +279,56 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
|||||||
return String(result);
|
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) {
|
if (!connection) {
|
||||||
return <div style={{ padding: 20 }}>连接不存在</div>;
|
return <div style={{ padding: 20 }}>连接不存在</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', background: '#fff' }}>
|
||||||
{/* Command Input */}
|
{/* Editor Top Pane */}
|
||||||
<div style={{ borderBottom: '1px solid #f0f0f0' }}>
|
<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' }}>
|
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#fdfdfd' }}>
|
||||||
<Space>
|
<Space>
|
||||||
<span style={{ fontWeight: 500 }}>Redis 命令</span>
|
<span style={{ fontWeight: 600 }}>Redis Console</span>
|
||||||
<span style={{ color: '#999', fontSize: 12 }}>db{redisDB}</span>
|
<span style={{ color: '#888', fontSize: 13, background: '#f0f0f0', padding: '2px 8px', borderRadius: 12 }}>db{redisDB}</span>
|
||||||
</Space>
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
@@ -135,68 +337,89 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
|
|||||||
onClick={handleExecute}
|
onClick={handleExecute}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
执行 (Ctrl+Enter)
|
执行 (Cmd+Enter)
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<ClearOutlined />} onClick={handleClear}>清空结果</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<Editor
|
<div style={{ flex: 1, position: 'relative' }}>
|
||||||
height="150px"
|
<Editor
|
||||||
defaultLanguage="plaintext"
|
defaultLanguage="redis"
|
||||||
value={command}
|
language="redis"
|
||||||
onChange={(value) => setCommand(value || '')}
|
value={command}
|
||||||
onMount={handleEditorMount}
|
onChange={(value) => setCommand(value || '')}
|
||||||
options={{
|
onMount={handleEditorMount}
|
||||||
minimap: { enabled: false },
|
options={{
|
||||||
lineNumbers: 'on',
|
minimap: { enabled: false },
|
||||||
fontSize: 14,
|
lineNumbers: 'on',
|
||||||
wordWrap: 'on',
|
fontSize: 14,
|
||||||
scrollBeyondLastLine: false,
|
wordWrap: 'on',
|
||||||
automaticLayout: true,
|
scrollBeyondLastLine: false,
|
||||||
tabSize: 2
|
automaticLayout: true,
|
||||||
}}
|
tabSize: 4,
|
||||||
/>
|
padding: { top: 10, bottom: 10 }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Resizer Handle */}
|
||||||
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: 'monospace' }}>
|
<div
|
||||||
{results.length === 0 ? (
|
className="horizontal-resizer"
|
||||||
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>
|
onMouseDown={handleDragStart}
|
||||||
输入 Redis 命令并按 Ctrl+Enter 执行
|
style={{
|
||||||
<br />
|
height: 8,
|
||||||
<span style={{ fontSize: 12 }}>支持多行命令,每行一个命令</span>
|
cursor: 'row-resize',
|
||||||
</div>
|
background: '#f0f0f0',
|
||||||
) : (
|
borderTop: '1px solid #e0e0e0',
|
||||||
results.map((item, index) => (
|
borderBottom: '1px solid #e0e0e0',
|
||||||
<div key={item.timestamp + index} style={{ padding: '8px 12px', borderBottom: '1px solid #333' }}>
|
display: 'flex',
|
||||||
<div style={{ color: '#569cd6', marginBottom: 4 }}>
|
justifyContent: 'center',
|
||||||
> {item.command}
|
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>
|
</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>
|
||||||
))
|
) : (
|
||||||
)}
|
results.map((item, index) => (
|
||||||
</div>
|
<div key={item.timestamp + index} style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ color: '#569cd6', marginBottom: 6, fontWeight: 'bold' }}>
|
||||||
{/* Common Commands Help */}
|
<span style={{ color: '#4CAF50', marginRight: 8 }}>➜</span>
|
||||||
<div style={{ padding: '8px 12px', borderTop: '1px solid #f0f0f0', background: '#fafafa', fontSize: 12, color: '#666' }}>
|
{item.command}
|
||||||
常用命令:
|
<span style={{ color: '#666', fontSize: 11, marginLeft: 12, fontWeight: 'normal' }}>[{item.durationMs}ms]</span>
|
||||||
<span style={{ marginLeft: 8 }}>
|
</div>
|
||||||
<code>KEYS *</code> |
|
|
||||||
<code style={{ marginLeft: 8 }}>GET key</code> |
|
<div style={{ paddingLeft: 20 }}>
|
||||||
<code style={{ marginLeft: 8 }}>SET key value</code> |
|
{item.error ? (
|
||||||
<code style={{ marginLeft: 8 }}>HGETALL key</code> |
|
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
|
||||||
<code style={{ marginLeft: 8 }}>INFO</code> |
|
(error) {item.error}
|
||||||
<code style={{ marginLeft: 8 }}>DBSIZE</code>
|
</div>
|
||||||
</span>
|
) : (
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{formatResult(item.result)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={resultsEndRef} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
378
frontend/src/components/RedisMonitor.tsx
Normal file
378
frontend/src/components/RedisMonitor.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
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 { 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<NodeJS.Timeout | 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 = { ...connection.config, redisDB } as any;
|
||||||
|
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;
|
||||||
@@ -30,7 +30,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
|
|||||||
CodeOutlined,
|
CodeOutlined,
|
||||||
TagOutlined,
|
TagOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
FilterOutlined
|
FilterOutlined,
|
||||||
|
DashboardOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||||
@@ -3098,6 +3099,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'open-monitor',
|
||||||
|
label: 'Redis 实例监控',
|
||||||
|
icon: <DashboardOutlined />,
|
||||||
|
onClick: () => {
|
||||||
|
addTab({
|
||||||
|
id: `redis-monitor-${node.key}-${Date.now()}`,
|
||||||
|
title: `监控: ${node.title}`,
|
||||||
|
type: 'redis-monitor',
|
||||||
|
connectionId: node.key,
|
||||||
|
redisDB: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
@@ -3309,6 +3324,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
redisDB: redisDB
|
redisDB: redisDB
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'open-monitor',
|
||||||
|
label: 'Redis 实例监控',
|
||||||
|
icon: <DashboardOutlined />,
|
||||||
|
onClick: () => {
|
||||||
|
addTab({
|
||||||
|
id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`,
|
||||||
|
title: `监控: ${connections.find(c => c.id === id)?.name || id}`,
|
||||||
|
type: 'redis-monitor',
|
||||||
|
connectionId: id,
|
||||||
|
redisDB: redisDB
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
} else if (node.type === 'database') {
|
} else if (node.type === 'database') {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import QueryEditor from './QueryEditor';
|
|||||||
import TableDesigner from './TableDesigner';
|
import TableDesigner from './TableDesigner';
|
||||||
import RedisViewer from './RedisViewer';
|
import RedisViewer from './RedisViewer';
|
||||||
import RedisCommandEditor from './RedisCommandEditor';
|
import RedisCommandEditor from './RedisCommandEditor';
|
||||||
|
import RedisMonitor from './RedisMonitor';
|
||||||
import TriggerViewer from './TriggerViewer';
|
import TriggerViewer from './TriggerViewer';
|
||||||
import DefinitionViewer from './DefinitionViewer';
|
import DefinitionViewer from './DefinitionViewer';
|
||||||
import TableOverview from './TableOverview';
|
import TableOverview from './TableOverview';
|
||||||
@@ -199,17 +200,20 @@ const TabManager: React.FC = () => {
|
|||||||
const items = useMemo(() => tabs.map((tab, index) => {
|
const items = useMemo(() => tabs.map((tab, index) => {
|
||||||
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
|
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
|
||||||
const displayTitle = buildTabDisplayTitle(tab, connectionName);
|
const displayTitle = buildTabDisplayTitle(tab, connectionName);
|
||||||
|
const tabIsActive = tab.id === activeTabId;
|
||||||
let content;
|
let content;
|
||||||
if (tab.type === 'query') {
|
if (tab.type === 'query') {
|
||||||
content = <QueryEditor tab={tab} />;
|
content = <QueryEditor tab={tab} isActive={tabIsActive} />;
|
||||||
} else if (tab.type === 'table') {
|
} else if (tab.type === 'table') {
|
||||||
content = <DataViewer tab={tab} />;
|
content = <DataViewer tab={tab} isActive={tabIsActive} />;
|
||||||
} else if (tab.type === 'design') {
|
} else if (tab.type === 'design') {
|
||||||
content = <TableDesigner tab={tab} />;
|
content = <TableDesigner tab={tab} />;
|
||||||
} else if (tab.type === 'redis-keys') {
|
} else if (tab.type === 'redis-keys') {
|
||||||
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||||
} else if (tab.type === 'redis-command') {
|
} else if (tab.type === 'redis-command') {
|
||||||
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
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') {
|
} else if (tab.type === 'trigger') {
|
||||||
content = <TriggerViewer tab={tab} />;
|
content = <TriggerViewer tab={tab} />;
|
||||||
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
|
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
|
||||||
@@ -256,7 +260,7 @@ const TabManager: React.FC = () => {
|
|||||||
key: tab.id,
|
key: tab.id,
|
||||||
children: content,
|
children: content,
|
||||||
};
|
};
|
||||||
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export interface TriggerDefinition {
|
|||||||
export interface TabData {
|
export interface TabData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
|
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'redis-monitor' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
dbName?: string;
|
dbName?: string;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
|
|||||||
28
frontend/src/utils/approximateTableCount.test.ts
Normal file
28
frontend/src/utils/approximateTableCount.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildOracleApproximateTotalSql,
|
||||||
|
parseApproximateTableCountRow,
|
||||||
|
resolveApproximateTableCountStrategy,
|
||||||
|
} from './approximateTableCount';
|
||||||
|
|
||||||
|
describe('approximateTableCount', () => {
|
||||||
|
it('uses oracle metadata approximate total only for unfiltered full-table preview', () => {
|
||||||
|
expect(resolveApproximateTableCountStrategy({ dbType: 'oracle', whereSQL: '' })).toBe('oracle-num-rows');
|
||||||
|
expect(resolveApproximateTableCountStrategy({ dbType: 'oracle', whereSQL: 'WHERE id = 1' })).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps duckdb approximate count on unfiltered previews', () => {
|
||||||
|
expect(resolveApproximateTableCountStrategy({ dbType: 'duckdb', whereSQL: '' })).toBe('duckdb-estimated-size');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds Oracle approx count SQL from owner and table name', () => {
|
||||||
|
expect(buildOracleApproximateTotalSql({ dbName: 'HR', tableName: 'HR.EMPLOYEES' })).toContain("owner = 'HR'");
|
||||||
|
expect(buildOracleApproximateTotalSql({ dbName: 'HR', tableName: 'HR.EMPLOYEES' })).toContain("table_name = 'EMPLOYEES'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses approximate total rows using preferred keys', () => {
|
||||||
|
expect(parseApproximateTableCountRow({ NUM_ROWS: '1234' }, ['num_rows'])).toBe(1234);
|
||||||
|
expect(parseApproximateTableCountRow({ approx_total: 5678 }, ['approx_total'])).toBe(5678);
|
||||||
|
});
|
||||||
|
});
|
||||||
106
frontend/src/utils/approximateTableCount.ts
Normal file
106
frontend/src/utils/approximateTableCount.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
export type ApproximateTableCountStrategy = 'none' | 'duckdb-estimated-size' | 'oracle-num-rows';
|
||||||
|
|
||||||
|
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
|
const toNonNegativeFiniteNumber = (value: unknown): number | null => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
|
||||||
|
}
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value >= 0n && value <= MAX_SAFE_BIGINT ? Number(value) : null;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) return null;
|
||||||
|
if (/^[+-]?\d+$/.test(text)) {
|
||||||
|
try {
|
||||||
|
const parsed = BigInt(text);
|
||||||
|
return parsed >= 0n && parsed <= MAX_SAFE_BIGINT ? Number(parsed) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parsed = Number(text);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 && parsed <= Number.MAX_SAFE_INTEGER ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripOuterQuotes = (value: string): string => {
|
||||||
|
const trimmed = String(value || '').trim();
|
||||||
|
if (trimmed.length < 2) return trimmed;
|
||||||
|
const first = trimmed[0];
|
||||||
|
const last = trimmed[trimmed.length - 1];
|
||||||
|
if ((first === '"' && last === '"') || (first === '`' && last === '`') || (first === '[' && last === ']')) {
|
||||||
|
return trimmed.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''");
|
||||||
|
|
||||||
|
const resolveOracleOwnerAndTable = (params: { dbName: string; tableName: string }) => {
|
||||||
|
const rawTable = String(params.tableName || '').trim();
|
||||||
|
const parts = rawTable.split('.').map(stripOuterQuotes).filter(Boolean);
|
||||||
|
const tableName = String(parts[parts.length - 1] || rawTable || '').trim();
|
||||||
|
const ownerCandidate = parts.length >= 2 ? parts[parts.length - 2] : String(params.dbName || '').trim();
|
||||||
|
return {
|
||||||
|
owner: ownerCandidate.toUpperCase(),
|
||||||
|
tableName: tableName.toUpperCase(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveApproximateTableCountStrategy = (params: {
|
||||||
|
dbType: string;
|
||||||
|
whereSQL: string;
|
||||||
|
}): ApproximateTableCountStrategy => {
|
||||||
|
const dbType = String(params.dbType || '').trim().toLowerCase();
|
||||||
|
const whereSQL = String(params.whereSQL || '').trim();
|
||||||
|
if (whereSQL) return 'none';
|
||||||
|
if (dbType === 'duckdb') return 'duckdb-estimated-size';
|
||||||
|
if (dbType === 'oracle') return 'oracle-num-rows';
|
||||||
|
return 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildOracleApproximateTotalSql = (params: { dbName: string; tableName: string }): string => {
|
||||||
|
const { owner, tableName } = resolveOracleOwnerAndTable(params);
|
||||||
|
const escapedTable = escapeSQLLiteral(tableName);
|
||||||
|
if (!owner) {
|
||||||
|
return `SELECT num_rows AS approx_total FROM user_tables WHERE table_name = '${escapedTable}' AND ROWNUM = 1`;
|
||||||
|
}
|
||||||
|
return `SELECT num_rows AS approx_total FROM all_tables WHERE owner = '${escapeSQLLiteral(owner)}' AND table_name = '${escapedTable}' AND ROWNUM = 1`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseApproximateTableCountRow = (
|
||||||
|
row: unknown,
|
||||||
|
preferredKeys: string[] = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'num_rows', 'count', 'total'],
|
||||||
|
): number | null => {
|
||||||
|
if (!row || typeof row !== 'object') return null;
|
||||||
|
const entries = Object.entries(row as Record<string, unknown>);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
for (const preferredKey of preferredKeys) {
|
||||||
|
const normalizedPreferred = String(preferredKey || '').trim().toLowerCase();
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
if (String(key || '').trim().toLowerCase() !== normalizedPreferred) continue;
|
||||||
|
const parsed = toNonNegativeFiniteNumber(value);
|
||||||
|
if (parsed !== null) return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
const normalizedKey = String(key || '').trim().toLowerCase();
|
||||||
|
if (!normalizedKey.includes('estimate') && !normalizedKey.includes('row') && !normalizedKey.includes('count') && !normalizedKey.includes('total')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = toNonNegativeFiniteNumber(value);
|
||||||
|
if (parsed !== null) return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, value] of entries) {
|
||||||
|
const parsed = toNonNegativeFiniteNumber(value);
|
||||||
|
if (parsed !== null) return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
57
frontend/src/utils/dataGridPagination.test.ts
Normal file
57
frontend/src/utils/dataGridPagination.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolvePaginationPageText,
|
||||||
|
resolvePaginationSummaryText,
|
||||||
|
resolvePaginationTotalForControl,
|
||||||
|
} from './dataGridPagination';
|
||||||
|
|
||||||
|
describe('dataGridPagination', () => {
|
||||||
|
it('shows Oracle approximate total in summary but not in total-page chip', () => {
|
||||||
|
const pagination = {
|
||||||
|
current: 3,
|
||||||
|
pageSize: 100,
|
||||||
|
total: 301,
|
||||||
|
totalKnown: false,
|
||||||
|
totalApprox: true,
|
||||||
|
approximateTotal: 1832451,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolvePaginationSummaryText({
|
||||||
|
pagination,
|
||||||
|
prefersManualTotalCount: true,
|
||||||
|
supportsApproximateTableCount: true,
|
||||||
|
})).toContain('约 1832451 条');
|
||||||
|
|
||||||
|
expect(resolvePaginationPageText({
|
||||||
|
pagination,
|
||||||
|
supportsApproximateTotalPages: false,
|
||||||
|
})).toBe('第 3 页');
|
||||||
|
|
||||||
|
expect(resolvePaginationTotalForControl({
|
||||||
|
pagination,
|
||||||
|
supportsApproximateTotalPages: false,
|
||||||
|
})).toBe(301);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still allows DuckDB to use approximate totals for page counts', () => {
|
||||||
|
const pagination = {
|
||||||
|
current: 2,
|
||||||
|
pageSize: 100,
|
||||||
|
total: 201,
|
||||||
|
totalKnown: false,
|
||||||
|
totalApprox: true,
|
||||||
|
approximateTotal: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolvePaginationPageText({
|
||||||
|
pagination,
|
||||||
|
supportsApproximateTotalPages: true,
|
||||||
|
})).toBe('第 2 / 10 页');
|
||||||
|
|
||||||
|
expect(resolvePaginationTotalForControl({
|
||||||
|
pagination,
|
||||||
|
supportsApproximateTotalPages: true,
|
||||||
|
})).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
92
frontend/src/utils/dataGridPagination.ts
Normal file
92
frontend/src/utils/dataGridPagination.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
export type PaginationStateLike = {
|
||||||
|
current: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalKnown?: boolean;
|
||||||
|
totalApprox?: boolean;
|
||||||
|
approximateTotal?: number;
|
||||||
|
totalCountLoading?: boolean;
|
||||||
|
totalCountCancelled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toFiniteNonNegativeNumber = (value: unknown): number | null => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveApproximateTotal = (pagination: PaginationStateLike): number | null => {
|
||||||
|
if (!pagination.totalApprox) return null;
|
||||||
|
const approximateTotal = toFiniteNonNegativeNumber(pagination.approximateTotal);
|
||||||
|
return approximateTotal !== null && approximateTotal > 0 ? approximateTotal : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCurrentCount = (pagination: PaginationStateLike): number => {
|
||||||
|
const total = toFiniteNonNegativeNumber(pagination.total) ?? 0;
|
||||||
|
const rangeStart = Math.max(0, (pagination.current - 1) * pagination.pageSize + (total > 0 ? 1 : 0));
|
||||||
|
const hasValidRange = total > 0 && rangeStart > 0;
|
||||||
|
if (!hasValidRange) return 0;
|
||||||
|
const rangeEnd = Math.min(total, rangeStart + pagination.pageSize - 1);
|
||||||
|
return Math.max(0, rangeEnd - rangeStart + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolvePaginationSummaryText = (params: {
|
||||||
|
pagination: PaginationStateLike;
|
||||||
|
prefersManualTotalCount: boolean;
|
||||||
|
supportsApproximateTableCount: boolean;
|
||||||
|
}): string => {
|
||||||
|
const { pagination, prefersManualTotalCount, supportsApproximateTableCount } = params;
|
||||||
|
const currentCount = resolveCurrentCount(pagination);
|
||||||
|
const total = toFiniteNonNegativeNumber(pagination.total) ?? 0;
|
||||||
|
const approximateTotal = resolveApproximateTotal(pagination);
|
||||||
|
|
||||||
|
if (pagination.totalKnown === false) {
|
||||||
|
if (prefersManualTotalCount) {
|
||||||
|
if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数…`;
|
||||||
|
if (supportsApproximateTableCount && approximateTotal !== null) return `当前 ${currentCount} 条 / 约 ${approximateTotal} 条`;
|
||||||
|
if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`;
|
||||||
|
return `当前 ${currentCount} 条 / 总数未统计`;
|
||||||
|
}
|
||||||
|
return `当前 ${currentCount} 条 / 正在统计总数…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(total) || total <= 0) {
|
||||||
|
return '当前 0 条 / 共 0 条';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `当前 ${currentCount} 条 / 共 ${total} 条`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolvePaginationPageText = (params: {
|
||||||
|
pagination: PaginationStateLike;
|
||||||
|
supportsApproximateTotalPages: boolean;
|
||||||
|
}): string => {
|
||||||
|
const { pagination, supportsApproximateTotalPages } = params;
|
||||||
|
const exactTotal = toFiniteNonNegativeNumber(pagination.total) ?? 0;
|
||||||
|
const approximateTotal = resolveApproximateTotal(pagination);
|
||||||
|
const effectiveTotal =
|
||||||
|
pagination.totalKnown !== false
|
||||||
|
? exactTotal
|
||||||
|
: supportsApproximateTotalPages && approximateTotal !== null
|
||||||
|
? approximateTotal
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (effectiveTotal <= 0) return `第 ${pagination.current} 页`;
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(effectiveTotal / Math.max(1, pagination.pageSize)));
|
||||||
|
if (pagination.totalKnown === false && !(supportsApproximateTotalPages && approximateTotal !== null)) {
|
||||||
|
return `第 ${pagination.current} 页`;
|
||||||
|
}
|
||||||
|
return `第 ${pagination.current} / ${totalPages} 页`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolvePaginationTotalForControl = (params: {
|
||||||
|
pagination: PaginationStateLike;
|
||||||
|
supportsApproximateTotalPages: boolean;
|
||||||
|
}): number => {
|
||||||
|
const { pagination, supportsApproximateTotalPages } = params;
|
||||||
|
const exactTotal = toFiniteNonNegativeNumber(pagination.total) ?? 0;
|
||||||
|
const approximateTotal = resolveApproximateTotal(pagination);
|
||||||
|
if (pagination.totalKnown !== false) return exactTotal;
|
||||||
|
if (supportsApproximateTotalPages && approximateTotal !== null) return approximateTotal;
|
||||||
|
return exactTotal;
|
||||||
|
};
|
||||||
32
frontend/src/utils/dataSourceCapabilities.test.ts
Normal file
32
frontend/src/utils/dataSourceCapabilities.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { getDataSourceCapabilities } from './dataSourceCapabilities';
|
||||||
|
|
||||||
|
describe('dataSourceCapabilities', () => {
|
||||||
|
it('treats Oracle table preview totals as manual exact count plus approximate metadata count', () => {
|
||||||
|
expect(getDataSourceCapabilities({ type: 'oracle' })).toMatchObject({
|
||||||
|
type: 'oracle',
|
||||||
|
preferManualTotalCount: true,
|
||||||
|
supportsApproximateTableCount: true,
|
||||||
|
supportsApproximateTotalPages: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps DuckDB manual count and approximate total support', () => {
|
||||||
|
expect(getDataSourceCapabilities({ type: 'duckdb' })).toMatchObject({
|
||||||
|
type: 'duckdb',
|
||||||
|
preferManualTotalCount: true,
|
||||||
|
supportsApproximateTableCount: true,
|
||||||
|
supportsApproximateTotalPages: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps MySQL on automatic total count mode', () => {
|
||||||
|
expect(getDataSourceCapabilities({ type: 'mysql' })).toMatchObject({
|
||||||
|
type: 'mysql',
|
||||||
|
preferManualTotalCount: false,
|
||||||
|
supportsApproximateTableCount: false,
|
||||||
|
supportsApproximateTotalPages: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,6 +64,9 @@ const COPY_INSERT_TYPES = new Set([
|
|||||||
|
|
||||||
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
|
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
|
||||||
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']);
|
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']);
|
||||||
|
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle']);
|
||||||
|
const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']);
|
||||||
|
const APPROXIMATE_TOTAL_PAGE_TYPES = new Set(['duckdb']);
|
||||||
|
|
||||||
export type DataSourceCapabilities = {
|
export type DataSourceCapabilities = {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -71,6 +74,9 @@ export type DataSourceCapabilities = {
|
|||||||
supportsSqlQueryExport: boolean;
|
supportsSqlQueryExport: boolean;
|
||||||
supportsCopyInsert: boolean;
|
supportsCopyInsert: boolean;
|
||||||
forceReadOnlyQueryResult: boolean;
|
forceReadOnlyQueryResult: boolean;
|
||||||
|
preferManualTotalCount: boolean;
|
||||||
|
supportsApproximateTableCount: boolean;
|
||||||
|
supportsApproximateTotalPages: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
|
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
|
||||||
@@ -81,6 +87,8 @@ export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCap
|
|||||||
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
|
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
|
||||||
supportsCopyInsert: COPY_INSERT_TYPES.has(type),
|
supportsCopyInsert: COPY_INSERT_TYPES.has(type),
|
||||||
forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type),
|
forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type),
|
||||||
|
preferManualTotalCount: MANUAL_TOTAL_COUNT_TYPES.has(type),
|
||||||
|
supportsApproximateTableCount: APPROXIMATE_TABLE_COUNT_TYPES.has(type),
|
||||||
|
supportsApproximateTotalPages: APPROXIMATE_TOTAL_PAGE_TYPES.has(type),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
26
frontend/src/utils/dataViewerAutoFetch.test.ts
Normal file
26
frontend/src/utils/dataViewerAutoFetch.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveDataViewerAutoFetchAction } from './dataViewerAutoFetch';
|
||||||
|
|
||||||
|
describe('resolveDataViewerAutoFetchAction', () => {
|
||||||
|
it('skips one fetch while tab state is hydrating', () => {
|
||||||
|
expect(resolveDataViewerAutoFetchAction({
|
||||||
|
skipNextAutoFetch: true,
|
||||||
|
hasInitialLoad: false,
|
||||||
|
})).toBe('skip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads current page on the first real fetch', () => {
|
||||||
|
expect(resolveDataViewerAutoFetchAction({
|
||||||
|
skipNextAutoFetch: false,
|
||||||
|
hasInitialLoad: false,
|
||||||
|
})).toBe('load-current-page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads from first page after sort or filter changes', () => {
|
||||||
|
expect(resolveDataViewerAutoFetchAction({
|
||||||
|
skipNextAutoFetch: false,
|
||||||
|
hasInitialLoad: true,
|
||||||
|
})).toBe('reload-first-page');
|
||||||
|
});
|
||||||
|
});
|
||||||
16
frontend/src/utils/dataViewerAutoFetch.ts
Normal file
16
frontend/src/utils/dataViewerAutoFetch.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export type DataViewerAutoFetchAction = 'skip' | 'load-current-page' | 'reload-first-page';
|
||||||
|
|
||||||
|
export const resolveDataViewerAutoFetchAction = (params: {
|
||||||
|
skipNextAutoFetch: boolean;
|
||||||
|
hasInitialLoad: boolean;
|
||||||
|
}): DataViewerAutoFetchAction => {
|
||||||
|
if (params.skipNextAutoFetch) {
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params.hasInitialLoad) {
|
||||||
|
return 'load-current-page';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'reload-first-page';
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user