diff --git a/README.md b/README.md index c2ad1405..a7ccd2e4 100644 --- a/README.md +++ b/README.md @@ -53,19 +53,24 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience

📸 Screenshots

- image - image - image + image + image + image
- image - image - image + image + image + image
--- ## Key Features +### AI Assistant (New) +- **Multi-provider Support**: OpenAI, Google Gemini, Anthropic Claude, and custom API support. +- **Context-Aware Chat**: Attach table schemas to the AI context for accurate SQL generation and assistance. +- **Slash Commands**: Quick commands for generating SQL, explaining queries, optimizing performance, and reviewing schema designs. + ### Performance - **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets). - **Virtualized rendering**: keeps large result sets responsive. @@ -207,6 +212,11 @@ For the full workflow, branch model, and maintainer sync rules, see: External contributors should open pull requests directly against `main`. +## Links + +- [linux.do](https://linux.do/) +- [AIBook](https://aibook.ren/) + ## License Licensed under [Apache-2.0](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index 6c745664..d83d025f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -52,19 +52,24 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做

📸 项目截图

- image - image - image + image + image + image
- image - image - image + image + image + image
--- ## 核心特性 +### AI 智能助手 (New) +- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude,同时支持任意自定义兼容 OpenAI 格式的 API。 +- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI,让 SQL 生成、分析变得更精准。 +- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等)。 + ### 性能与交互 - 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。 - 虚拟滚动渲染,降低大结果集卡顿风险。 @@ -190,6 +195,11 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0 外部贡献者统一直接向 `main` 发起 Pull Request。 +## 友情链接 + +- [linux.do](https://linux.do/) +- [AI全书](https://aibook.ren/) + ## 开源协议 本项目采用 [Apache-2.0 协议](LICENSE)。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dd3c9b50..98891caf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "react-markdown": "^10.1.0", "react-resizable": "^3.1.3", "react-syntax-highlighter": "^16.1.1", + "recharts": "^3.8.1", "remark-gfm": "^4.0.1", "sql-formatter": "^15.7.0", "uuid": "^9.0.1", @@ -1210,6 +1211,42 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1567,6 +1604,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2001,6 +2050,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -3046,6 +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": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -3130,6 +3191,16 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -3211,6 +3282,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz", @@ -3404,6 +3481,16 @@ "node": ">=0.10.0" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -5511,6 +5598,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5555,6 +5665,51 @@ "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", @@ -5637,6 +5792,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -5888,6 +6049,12 @@ "node": ">=12.22" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", @@ -6178,6 +6345,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index 512b5ac1..904b7a2e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "react-markdown": "^10.1.0", "react-resizable": "^3.1.3", "react-syntax-highlighter": "^16.1.1", + "recharts": "^3.8.1", "remark-gfm": "^4.0.1", "sql-formatter": "^15.7.0", "uuid": "^9.0.1", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index efbd2b62..b8be944d 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -6ba85e4f456d2c0d230cab198c7dc02b \ No newline at end of file +f697e821b4acd5cf614d63d46453e8a4 \ No newline at end of file diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 669ea9a4..f90d89a0 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -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 { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; +import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination'; import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout'; import { buildCopyInsertSQL, normalizeTemporalLiteralText } from './dataGridCopyInsert'; @@ -819,6 +820,7 @@ interface DataGridProps { total: number, totalKnown?: boolean, totalApprox?: boolean, + approximateTotal?: number, totalCountLoading?: boolean, totalCountCancelled?: boolean, }; @@ -996,6 +998,9 @@ const DataGrid: React.FC = ({ const selectionColumnWidth = 46; const currentConnConfig = connections.find(c => c.id === connectionId)?.config; const dataSourceCaps = getDataSourceCapabilities(currentConnConfig); + const prefersManualTotalCount = dataSourceCaps.preferManualTotalCount; + const supportsApproximateTableCount = dataSourceCaps.supportsApproximateTableCount; + const supportsApproximateTotalPages = dataSourceCaps.supportsApproximateTotalPages; const dbType = dataSourceCaps.type; const isDuckDBConnection = dataSourceCaps.type === 'duckdb'; const supportsCopyInsert = dataSourceCaps.supportsCopyInsert; @@ -4493,37 +4498,20 @@ const DataGrid: React.FC = ({ const paginationSummaryText = useMemo(() => { if (!pagination) return ''; - const total = Number.isFinite(pagination.total) ? pagination.total : 0; - const rangeStart = Math.max(0, (pagination.current - 1) * pagination.pageSize + (total > 0 ? 1 : 0)); - const hasValidRange = total > 0 && rangeStart > 0; - const rangeEnd = hasValidRange ? Math.min(total, rangeStart + pagination.pageSize - 1) : 0; - const currentCount = hasValidRange ? Math.max(0, rangeEnd - rangeStart + 1) : 0; - - 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]); + return resolvePaginationSummaryText({ + pagination, + prefersManualTotalCount, + supportsApproximateTableCount, + }); + }, [pagination, prefersManualTotalCount, supportsApproximateTableCount]); const paginationPageText = useMemo(() => { if (!pagination) return ''; - const total = Number.isFinite(pagination.total) ? pagination.total : 0; - const canShowTotalPages = pagination.totalKnown !== false || (isDuckDBConnection && pagination.totalApprox && total > 0); - if (!canShowTotalPages || total <= 0) return `第 ${pagination.current} 页`; - const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pagination.pageSize))); - return `第 ${pagination.current} / ${totalPages} 页`; - }, [pagination, isDuckDBConnection]); + return resolvePaginationPageText({ + pagination, + supportsApproximateTotalPages, + }); + }, [pagination, supportsApproximateTotalPages]); const handlePageSizeChange = useCallback((value: string) => { if (!pagination || !onPageChange) return; @@ -4690,7 +4678,7 @@ const DataGrid: React.FC = ({ - {isDuckDBConnection && onRequestTotalCount && ( + {prefersManualTotalCount && onRequestTotalCount && ( <>
@@ -5549,7 +5537,10 @@ const DataGrid: React.FC = ({ { return null; }; -const parseDuckDBApproxTotalRow = (row: any): number | null => { - if (!row || typeof row !== 'object') return null; - const entries = Object.entries(row as Record); - 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 text = String(raw || '').trim(); 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 [data, setData] = useState([]); const [columnNames, setColumnNames] = useState([]); @@ -214,6 +193,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const countKeyRef = useRef(''); const duckdbApproxSeqRef = useRef(0); const duckdbApproxKeyRef = useRef(''); + const oracleApproxSeqRef = useRef(0); + const oracleApproxKeyRef = useRef(''); const manualCountSeqRef = useRef(0); const manualCountKeyRef = useRef(''); const pkSeqRef = useRef(0); @@ -228,6 +209,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { left: initialViewerSnapshot.scrollLeft, }); const initialLoadRef = useRef(false); + const skipNextAutoFetchRef = useRef(false); const [pagination, setPagination] = useState({ current: initialViewerSnapshot.currentPage, @@ -246,8 +228,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const duckdbSafeSelectCacheRef = useRef>({}); const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config; const currentConnCaps = getDataSourceCapabilities(currentConnConfig); - const currentConnType = currentConnCaps.type; const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult; + const preferManualTotalCount = currentConnCaps.preferManualTotalCount; + const supportsApproximateTableCount = currentConnCaps.supportsApproximateTableCount; + const supportsApproximateTotalPages = currentConnCaps.supportsApproximateTotalPages; const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial) => { const normalizedTabId = String(tabId || '').trim(); if (!normalizedTabId) return; @@ -288,6 +272,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { pkKeyRef.current = ''; countKeyRef.current = ''; duckdbApproxKeyRef.current = ''; + oracleApproxKeyRef.current = ''; manualCountKeyRef.current = ''; duckdbSafeSelectCacheRef.current = {}; latestConfigRef.current = null; @@ -297,6 +282,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { latestCountKeyRef.current = ''; scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft }; initialLoadRef.current = false; + skipNextAutoFetchRef.current = true; setPagination(prev => ({ ...prev, current: snapshot.currentPage, @@ -304,6 +290,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { total: 0, totalKnown: false, totalApprox: false, + approximateTotal: undefined, totalCountLoading: false, totalCountCancelled: false, })); @@ -317,10 +304,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }); }, [tab.id, persistViewerSnapshot]); - const handleDuckDBManualCount = useCallback(async () => { - if (latestDbTypeRef.current !== 'duckdb') { - return; - } + const handleManualTotalCount = useCallback(async () => { const config = latestConfigRef.current; const dbName = latestDbNameRef.current; const countSql = latestCountSqlRef.current; @@ -341,7 +325,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const resCount = await DBQuery(countConfig as any, dbName, countSql); const countDuration = Date.now() - countStart; addSqlLog({ - id: `log-${Date.now()}-duckdb-manual-count`, + id: `log-${Date.now()}-manual-count`, timestamp: Date.now(), sql: countSql, status: resCount?.success ? 'success' : 'error', @@ -375,6 +359,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { total, totalKnown: true, totalApprox: false, + approximateTotal: undefined, totalCountLoading: false, totalCountCancelled: false, })); @@ -386,7 +371,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { } }, [addSqlLog]); - const handleDuckDBCancelManualCount = useCallback(() => { + const handleCancelManualTotalCount = useCallback(() => { manualCountSeqRef.current++; setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true })); }, []); @@ -438,7 +423,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const totalRows = Number(pagination.total); const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0; const totalKnown = pagination.totalKnown && hasFiniteTotal; - const totalPages = hasFiniteTotal ? Math.max(1, Math.ceil(totalRows / size)) : 0; + const approximateTotalRows = Number(pagination.approximateTotal); + const hasApproximateTotalPages = + !totalKnown && + supportsApproximateTotalPages && + pagination.totalApprox && + Number.isFinite(approximateTotalRows) && + approximateTotalRows > 0; + const effectiveTotalRows = hasApproximateTotalPages ? approximateTotalRows : totalRows; + const totalPages = Number.isFinite(effectiveTotalRows) && effectiveTotalRows > 0 ? Math.max(1, Math.ceil(effectiveTotalRows / size)) : 0; const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page); const offset = (currentPage - 1) * size; const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse'; @@ -632,6 +625,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { total: derivedTotal, totalKnown: true, totalApprox: false, + approximateTotal: undefined, totalCountLoading: false, totalCountCancelled: false, }; @@ -647,13 +641,20 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { } } const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey; - if (isDuckDB && prev.totalApprox && duckdbApproxKeyRef.current === countKey && Number.isFinite(prev.total) && prev.total >= minExpectedTotal) { + const hasApproximateTotalForCurrentKey = + prev.totalApprox && + (duckdbApproxKeyRef.current === countKey || oracleApproxKeyRef.current === countKey) && + Number.isFinite(prev.approximateTotal) && + Number(prev.approximateTotal) >= minExpectedTotal; + if (hasApproximateTotalForCurrentKey) { return { ...prev, current: currentPage, pageSize: size, + total: derivedTotal, totalKnown: false, totalApprox: true, + approximateTotal: prev.approximateTotal, totalCountLoading: keepManualCounting, totalCountCancelled: false, }; @@ -665,12 +666,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { total: derivedTotal, totalKnown: false, totalApprox: false, + approximateTotal: undefined, totalCountLoading: keepManualCounting, totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled, }; }); - const shouldRunAsyncCount = !derivedTotalKnown && !isDuckDB; + const shouldRunAsyncCount = !derivedTotalKnown && !preferManualTotalCount; if (shouldRunAsyncCount) { if (countKeyRef.current !== countKey) { countKeyRef.current = countKey; @@ -695,7 +697,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }); if (countSeqRef.current !== countSeq) return; - if (countKeyRef.current !== countKey) return; + if (latestCountKeyRef.current !== countKey) return; if (!resCount.success) return; if (!Array.isArray(resCount.data) || resCount.data.length === 0) return; @@ -708,6 +710,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { total, totalKnown: true, totalApprox: false, + approximateTotal: undefined, totalCountLoading: false, totalCountCancelled: false, })); @@ -720,48 +723,88 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { } } - if (isDuckDB && !derivedTotalKnown && whereSQL.trim() === '' && duckdbApproxKeyRef.current !== countKey) { - duckdbApproxKeyRef.current = countKey; - const approxSeq = ++duckdbApproxSeqRef.current; - const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName); - const escapedSchema = escapeSQLLiteral(schemaName); - const escapedTable = escapeSQLLiteral(pureTableName); - const approxConfig: any = { ...(config as any), timeout: 3 }; - const approxSqlCandidates = [ - `SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`, - `SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`, - ]; + if (!derivedTotalKnown) { + const approximateCountStrategy = supportsApproximateTableCount + ? resolveApproximateTableCountStrategy({ dbType: dbTypeLower, whereSQL }) + : 'none'; - (async () => { - for (const approxSql of approxSqlCandidates) { - try { - const approxRes = await DBQuery(approxConfig as any, dbName, approxSql); - if (duckdbApproxSeqRef.current !== approxSeq) return; - if (countKeyRef.current !== countKey) return; - if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue; + if (approximateCountStrategy === 'duckdb-estimated-size' && duckdbApproxKeyRef.current !== countKey) { + duckdbApproxKeyRef.current = countKey; + const approxSeq = ++duckdbApproxSeqRef.current; + const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName); + const escapedSchema = escapeSQLLiteral(schemaName); + const escapedTable = escapeSQLLiteral(pureTableName); + const approxConfig: 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]); - if (approxTotal === null) continue; - if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue; + (async () => { + for (const approxSql of approxSqlCandidates) { + try { + const approxRes = await DBQuery(approxConfig as any, dbName, approxSql); + if (duckdbApproxSeqRef.current !== approxSeq) return; + if (latestCountKeyRef.current !== countKey) return; + if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue; + + const approxTotal = parseApproximateTableCountRow(approxRes.data[0]); + if (approxTotal === null) continue; + if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue; + + setPagination(prev => { + if (latestCountKeyRef.current !== countKey) return prev; + if (prev.totalKnown) return prev; + return { + ...prev, + totalKnown: false, + totalApprox: true, + approximateTotal: approxTotal, + totalCountCancelled: false, + }; + }); + return; + } catch { + if (duckdbApproxSeqRef.current !== approxSeq) return; + if (latestCountKeyRef.current !== countKey) return; + } + } + })(); + } + + if (approximateCountStrategy === 'oracle-num-rows' && oracleApproxKeyRef.current !== countKey) { + oracleApproxKeyRef.current = countKey; + const approxSeq = ++oracleApproxSeqRef.current; + const approxConfig: 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 => { - if (countKeyRef.current !== countKey) return prev; + if (latestCountKeyRef.current !== countKey) return prev; if (prev.totalKnown) return prev; return { ...prev, - total: approxTotal, totalKnown: false, totalApprox: true, + approximateTotal: approxTotal, totalCountCancelled: false, }; }); - return; - } catch { - if (duckdbApproxSeqRef.current !== approxSeq) return; - if (countKeyRef.current !== countKey) return; - } - } - })(); + }) + .catch(() => { + if (oracleApproxSeqRef.current !== approxSeq) return; + if (latestCountKeyRef.current !== countKey) return; + }); + } } } else { message.error(String(resData.message || '查询失败')); @@ -780,7 +823,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }); } 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:在无手动排序时可回退到主键稳定排序。 // 主键信息只会在首次加载后更新一次,避免循环查询。 @@ -828,7 +871,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]); useEffect(() => { - if (!initialLoadRef.current) { + const action = resolveDataViewerAutoFetchAction({ + skipNextAutoFetch: skipNextAutoFetchRef.current, + hasInitialLoad: initialLoadRef.current, + }); + if (action === 'skip') { + skipNextAutoFetchRef.current = false; + return; + } + if (action === 'load-current-page') { initialLoadRef.current = true; fetchData(pagination.current, pagination.pageSize); return; @@ -851,8 +902,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { onSort={handleSort} onPageChange={handlePageChange} pagination={pagination} - onRequestTotalCount={currentConnType === 'duckdb' ? handleDuckDBManualCount : undefined} - onCancelTotalCount={currentConnType === 'duckdb' ? handleDuckDBCancelManualCount : undefined} + onRequestTotalCount={preferManualTotalCount ? handleManualTotalCount : undefined} + onCancelTotalCount={preferManualTotalCount ? handleCancelManualTotalCount : undefined} showFilter={showFilter} onToggleFilter={handleToggleFilter} onApplyFilter={handleApplyFilter} diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index fbc0b524..1904e449 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -183,7 +183,7 @@ let sharedAllColumnsData: {dbName: string, tableName: string, name: string, type let sharedVisibleDbs: string[] = []; let sharedColumnsCacheData: Record = {}; -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 '); type ResultSet = { @@ -716,11 +716,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { // Prefer preloaded MySQL all-columns cache let cols: { name: string, type?: string, tableName?: string, dbName?: string }[]; if (sharedAllColumnsData.length > 0) { + const tiTableLower = (tableInfo.tableName || '').toLowerCase(); cols = sharedAllColumnsData - .filter(c => - (c.dbName || '').toLowerCase() === (tableInfo.dbName || '').toLowerCase() && - (c.tableName || '').toLowerCase() === (tableInfo.tableName || '').toLowerCase() - ) + .filter(c => { + if ((c.dbName || '').toLowerCase() !== (tableInfo.dbName || '').toLowerCase()) return false; + const cTableLower = (c.tableName || '').toLowerCase(); + if (cTableLower === tiTableLower) return true; + // schema.table 格式匹配纯表名 + const parsed = splitSchemaAndTable(c.tableName || ''); + return (parsed.table || '').toLowerCase() === tiTableLower; + }) .map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName })); } else { const dbCols = await getColumnsByDB(tableInfo.tableName); @@ -773,7 +778,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { .filter(c => { const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase(); const shortIdent = (c.tableName || '').toLowerCase(); - return (foundTables.has(fullIdent) || foundTables.has(shortIdent)) && startsWithPrefix(c.name || ''); + // 对 schema.table 格式,也用纯表名部分匹配(如 public.users → users) + const parsed = splitSchemaAndTable(c.tableName || ''); + const pureIdent = (parsed.table || '').toLowerCase(); + return (foundTables.has(fullIdent) || foundTables.has(shortIdent) || (pureIdent && foundTables.has(pureIdent))) && startsWithPrefix(c.name || ''); }) .map(c => { // 当前库的表字段优先级更高 @@ -788,24 +796,61 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }; }); - // 表提示:当前库显示表名,其他库显示 db.table 格式 + // 表提示:当前库智能处理 schema.table 格式 + // 1. 构建纯表名到 schema 列表的映射,检测同名表 + const currentDbTables = sharedTablesData.filter(t => + (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase() + ); + const tableNameToSchemas = new Map(); + for (const t of currentDbTables) { + const parsed = splitSchemaAndTable(t.tableName || ''); + const pureTable = (parsed.table || t.tableName || '').toLowerCase(); + const schemas = tableNameToSchemas.get(pureTable) || []; + schemas.push(parsed.schema || ''); + tableNameToSchemas.set(pureTable, schemas); + } + const tableSuggestions = sharedTablesData .filter(t => { const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); - const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; - return startsWithPrefix(label || ''); + if (!isCurrentDb) { + // 跨库:用 db.table 格式匹配 + return startsWithPrefix(`${t.dbName}.${t.tableName}`); + } + // 当前库:同时用完整名和纯表名匹配 + const parsed = splitSchemaAndTable(t.tableName || ''); + const pureTable = parsed.table || t.tableName || ''; + return startsWithPrefix(t.tableName || '') || startsWithPrefix(pureTable); }) .map(t => { const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); - const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; - const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; + if (!isCurrentDb) { + const label = `${t.dbName}.${t.tableName}`; + return { + label, + kind: monaco.languages.CompletionItemKind.Class, + insertText: label, + detail: `Table (${t.dbName})`, + range, + sortText: sortGroups.tableOther + t.tableName, + }; + } + // 当前库:检查是否有跨 schema 同名表 + const parsed = splitSchemaAndTable(t.tableName || ''); + const pureTable = parsed.table || t.tableName || ''; + const schemas = tableNameToSchemas.get(pureTable.toLowerCase()) || []; + const hasDuplicate = schemas.length > 1; + // 同名表存在于多个 schema → 显示 schema.table;否则只显示纯表名 + const label = hasDuplicate ? t.tableName : pureTable; + const insertText = hasDuplicate ? t.tableName : pureTable; + const schemaInfo = parsed.schema ? ` (${parsed.schema})` : ''; return { label, kind: monaco.languages.CompletionItemKind.Class, insertText, - detail: `Table (${t.dbName})`, + detail: `Table${schemaInfo}`, range, - sortText: isCurrentDb ? sortGroups.tableCurrent + t.tableName : sortGroups.tableOther + t.tableName, + sortText: sortGroups.tableCurrent + pureTable, }; }); diff --git a/frontend/src/components/RedisCommandEditor.tsx b/frontend/src/components/RedisCommandEditor.tsx index 0ca83664..7cc7b284 100644 --- a/frontend/src/components/RedisCommandEditor.tsx +++ b/frontend/src/components/RedisCommandEditor.tsx @@ -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 { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons'; import { useStore } from '../store'; @@ -14,6 +14,67 @@ interface CommandResult { result: any; error?: string; timestamp: number; + durationMs: number; +} + +// 智能解析 Redis 脚本块,保护多行引号内的换行符 +function parseRedisScriptBlocks(script: string): string[] { + const blocks: string[] = []; + let currentBlock = ""; + let inQuote: string | null = null; + let isEscaping = false; + + const lines = script.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!inQuote && (trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('#'))) { + continue; + } + + for (let j = 0; j < line.length; j++) { + const char = line[j]; + + if (isEscaping) { + isEscaping = false; + currentBlock += char; + continue; + } + + if (char === '\\') { + isEscaping = true; + currentBlock += char; + continue; + } + + if (char === '"' || char === "'") { + if (inQuote === char) { + inQuote = null; + } else if (!inQuote) { + inQuote = char; + } + } + + currentBlock += char; + } + + if (inQuote || (i < lines.length - 1 && currentBlock.trim() !== '')) { + if (!inQuote) { + blocks.push(currentBlock.trim()); + currentBlock = ""; + } else { + currentBlock += '\n'; + } + } + } + + if (currentBlock.trim() !== '') { + blocks.push(currentBlock.trim()); + } + + return blocks.filter(b => b.trim() !== ''); } const RedisCommandEditor: React.FC = ({ connectionId, redisDB }) => { @@ -23,6 +84,13 @@ const RedisCommandEditor: React.FC = ({ connectionId, r const [command, setCommand] = useState(''); const [results, setResults] = useState([]); 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(null); + const resultsEndRef = useRef(null); + const editorRef = useRef(null); const getConfig = useCallback(() => { @@ -37,77 +105,173 @@ const RedisCommandEditor: React.FC = ({ connectionId, r }; }, [connection, redisDB]); - const handleEditorMount: OnMount = (editor) => { + const handleEditorMount: OnMount = (editor, monaco) => { editorRef.current = editor; - // Add keyboard shortcut for execute editor.addCommand( - // Ctrl/Cmd + Enter - 2048 | 3, // KeyMod.CtrlCmd | KeyCode.Enter + monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => handleExecute() ); + + if (!(window as any).__redisCompletionRegistered) { + (window as any).__redisCompletionRegistered = true; + + const redisCommands = [ + "APPEND", "AUTH", "BGREWRITEAOF", "BGSAVE", "BITCOUNT", "BITFIELD", "BITOP", + "BITPOS", "BLPOP", "BRPOP", "BRPOPLPUSH", "BZMPOP", "BZPOPMIN", "BZPOPMAX", + "CLIENT", "CLUSTER", "COMMAND", "CONFIG", "DBSIZE", "DEBUG", "DECR", "DECRBY", + "DEL", "DISCARD", "DUMP", "ECHO", "EVAL", "EVALSHA", "EXEC", "EXISTS", "EXPIRE", + "EXPIREAT", "EXPIRETIME", "FLUSHALL", "FLUSHDB", "GEOADD", "GEODIST", "GEOHASH", + "GEOPOS", "GEORADIUS", "GEORADIUSBYMEMBER", "GEOSEARCH", "GEOSEARCHSTORE", + "GET", "GETBIT", "GETDEL", "GETEX", "GETRANGE", "GETSET", "HDEL", "HELLO", + "HEXISTS", "HGET", "HGETALL", "HINCRBY", "HINCRBYFLOAT", "HKEYS", "HLEN", + "HMGET", "HMSET", "HSCAN", "HSET", "HSETNX", "HSTRLEN", "HVALS", "INCR", + "INCRBY", "INCRBYFLOAT", "INFO", "KEYS", "LASTSAVE", "LCS", "LINDEX", "LINSERT", + "LLEN", "LMOVE", "LMPOP", "LPOP", "LPOS", "LPUSH", "LPUSHX", "LRANGE", "LREM", + "LSET", "LTRIM", "MEMORY", "MGET", "MIGRATE", "MODULE", "MONITOR", "MOVE", "MSET", + "MSETNX", "MULTI", "OBJECT", "PERSIST", "PEXPIRE", "PEXPIREAT", "PEXPIRETIME", + "PFADD", "PFCOUNT", "PFMERGE", "PING", "PSETEX", "PSUBSCRIBE", "PTTL", "PUBLISH", + "PUBSUB", "PUNSUBSCRIBE", "QUIT", "RANDOMKEY", "READONLY", "READWRITE", "RENAME", + "RENAMENX", "RESET", "RESTORE", "ROLE", "RPOP", "RPOPLPUSH", "RPUSH", "RPUSHX", + "SADD", "SAVE", "SCAN", "SCARD", "SCRIPT", "SDIFF", "SDIFFSTORE", "SELECT", + "SET", "SETBIT", "SETEX", "SETNX", "SETRANGE", "SHUTDOWN", "SINTER", "SINTERCARD", + "SINTERSTORE", "SISMEMBER", "SLAVEOF", "SLOWLOG", "SMEMBERS", "SMISMEMBER", + "SMOVE", "SORT", "SORT_RO", "SPOP", "SRANDMEMBER", "SREM", "SSCAN", "STRLEN", + "SUBSCRIBE", "SUNION", "SUNIONSTORE", "SWAPDB", "SYNC", "TIME", "TOUCH", "TTL", + "TYPE", "UNLINK", "UNSUBSCRIBE", "UNWATCH", "WAIT", "WATCH", "XACK", "XADD", + "XAUTOCLAIM", "XCLAIM", "XDEL", "XGROUP", "XINFO", "XLEN", "XPENDING", "XRANGE", + "XREAD", "XREADGROUP", "XREVRANGE", "XTRIM", "ZADD", "ZCARD", "ZCOUNT", "ZDIFF", + "ZDIFFSTORE", "ZINCRBY", "ZINTER", "ZINTERCARD", "ZINTERSTORE", "ZLEXCOUNT", + "ZMPOP", "ZMSCORE", "ZPOPMAX", "ZPOPMIN", "ZRANDMEMBER", "ZRANGE", "ZRANGEBYLEX", + "ZRANGEBYSCORE", "ZRANK", "ZREM", "ZREMRANGEBYLEX", "ZREMRANGEBYRANK", + "ZREMRANGEBYSCORE", "ZREVRANGE", "ZREVRANGEBYLEX", "ZREVRANGEBYSCORE", "ZREVRANK", + "ZSCAN", "ZSCORE", "ZUNION", "ZUNIONSTORE" + ]; + + monaco.languages.registerCompletionItemProvider('redis', { + provideCompletionItems: (model: any, position: any) => { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn + }; + return { + suggestions: redisCommands.map(cmd => ({ + label: cmd, + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: cmd, + range: range, + detail: "Redis Command" + })) + }; + } + }); + } }; const handleExecute = async () => { const config = getConfig(); if (!config) return; - const cmdToExecute = command.trim(); + let cmdToExecute = ''; + + // 1. 获取用户是否有高亮选中的文本 + const selection = editorRef.current?.getSelection(); + if (selection && !selection.isEmpty()) { + cmdToExecute = editorRef.current?.getModel()?.getValueInRange(selection) || ''; + } else { + // 没有选中则取全部文本 + cmdToExecute = editorRef.current?.getValue() || ''; + } + + cmdToExecute = cmdToExecute.trim(); if (!cmdToExecute) { - message.warning('请输入命令'); + message.warning('请输入要执行的命令'); return; } - // Support multiple commands separated by newlines - const commands = cmdToExecute.split('\n').filter(c => c.trim() && !c.trim().startsWith('//') && !c.trim().startsWith('#')); + // 2. 智能解析多行命令 + const commands = parseRedisScriptBlocks(cmdToExecute); + if (commands.length === 0) return; setLoading(true); const newResults: CommandResult[] = []; for (const cmd of commands) { - const trimmedCmd = cmd.trim(); - if (!trimmedCmd) continue; - + const start = Date.now(); try { - const res = await (window as any).go.app.App.RedisExecuteCommand(config, trimmedCmd); + const res = await (window as any).go.app.App.RedisExecuteCommand(config, cmd); newResults.push({ - command: trimmedCmd, + command: cmd, result: res.success ? res.data : null, error: res.success ? undefined : res.message, - timestamp: Date.now() + timestamp: Date.now(), + durationMs: Date.now() - start }); } catch (e: any) { newResults.push({ - command: trimmedCmd, + command: cmd, result: null, error: e?.message || String(e), - timestamp: Date.now() + timestamp: Date.now(), + durationMs: Date.now() - start }); } } - setResults(prev => [...newResults, ...prev]); + setResults(prev => [...prev, ...newResults]); setLoading(false); }; + + // Auto scroll to bottom when new results arrive + useEffect(() => { + if (resultsEndRef.current) { + resultsEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [results]); const handleClear = () => { setResults([]); }; - const formatResult = (result: any): string => { + const formatResult = (result: any): React.ReactNode => { if (result === null || result === undefined) { - return '(nil)'; + return (nil); } if (typeof result === 'string') { - return `"${result}"`; + // 尝试美化 JSON 字符串 + try { + const parsed = JSON.parse(result); + if (typeof parsed === 'object' && parsed !== null) { + return ( +
+ {JSON.stringify(parsed, null, 2)} +
+ ); + } + } catch (e) { + // not a valid json, just return string + } + return "{result}"; } if (typeof result === 'number') { - return `(integer) ${result}`; + return (integer) {result}; } if (Array.isArray(result)) { if (result.length === 0) { return '(empty array)'; } - return result.map((item, index) => `${index + 1}) ${formatResult(item)}`).join('\n'); + return ( +
+ {result.map((item, index) => ( +
+ {index + 1}) +
{formatResult(item)}
+
+ ))} +
+ ); } if (typeof result === 'object') { return JSON.stringify(result, null, 2); @@ -115,18 +279,56 @@ const RedisCommandEditor: React.FC = ({ connectionId, r return String(result); }; + // Resizing logic + const handleDragStart = (e: React.MouseEvent) => { + e.preventDefault(); + dragRef.current = { startY: e.clientY, startHeight: editorHeight }; + document.addEventListener('mousemove', handleDragMove); + document.addEventListener('mouseup', handleDragEnd); + document.body.style.cursor = 'row-resize'; + }; + + const handleDragMove = useCallback((e: MouseEvent) => { + if (!dragRef.current) return; + const delta = e.clientY - dragRef.current.startY; + let newHeight = dragRef.current.startHeight + delta; + + // 限制高度 + const minHeight = 100; + const maxHeight = containerRef.current ? containerRef.current.clientHeight - 100 : 800; + if (newHeight < minHeight) newHeight = minHeight; + if (newHeight > maxHeight) newHeight = maxHeight; + + setEditorHeight(newHeight); + + // 更新编辑器布局 + if (editorRef.current) { + editorRef.current.layout(); + } + }, []); + + const handleDragEnd = useCallback(() => { + dragRef.current = null; + document.removeEventListener('mousemove', handleDragMove); + document.removeEventListener('mouseup', handleDragEnd); + document.body.style.cursor = 'default'; + if (editorRef.current) { + editorRef.current.layout(); + } + }, [handleDragMove]); + if (!connection) { return
连接不存在
; } return ( -
- {/* Command Input */} -
-
+
+ {/* Editor Top Pane */} +
+
- Redis 命令 - db{redisDB} + Redis Console + db{redisDB} -
- setCommand(value || '')} - onMount={handleEditorMount} - options={{ - minimap: { enabled: false }, - lineNumbers: 'on', - fontSize: 14, - wordWrap: 'on', - scrollBeyondLastLine: false, - automaticLayout: true, - tabSize: 2 - }} - /> +
+ setCommand(value || '')} + onMount={handleEditorMount} + options={{ + minimap: { enabled: false }, + lineNumbers: 'on', + fontSize: 14, + wordWrap: 'on', + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 4, + padding: { top: 10, bottom: 10 } + }} + /> +
- {/* Results */} -
- {results.length === 0 ? ( -
- 输入 Redis 命令并按 Ctrl+Enter 执行 -
- 支持多行命令,每行一个命令 -
- ) : ( - results.map((item, index) => ( -
-
- > {item.command} + {/* Resizer Handle */} +
+
+
+ + {/* Results Terminal Bottom Pane */} +
+
+ Execution Output + +
+
+ {results.length === 0 ? ( +
+
在此终端执行命令,结果会以原样输出
+
+ Tips: 选中任意行Ctrl + Enter 仅执行选中段落
- {item.error ? ( -
- (error) {item.error} -
- ) : ( -
- {formatResult(item.result)} -
- )}
- )) - )} -
- - {/* Common Commands Help */} -
- 常用命令: - - KEYS * | - GET key | - SET key value | - HGETALL key | - INFO | - DBSIZE - + ) : ( + results.map((item, index) => ( +
+
+ + {item.command} + [{item.durationMs}ms] +
+ +
+ {item.error ? ( +
+ (error) {item.error} +
+ ) : ( +
+ {formatResult(item.result)} +
+ )} +
+
+ )) + )} +
+
); diff --git a/frontend/src/components/RedisMonitor.tsx b/frontend/src/components/RedisMonitor.tsx new file mode 100644 index 00000000..0e5cc258 --- /dev/null +++ b/frontend/src/components/RedisMonitor.tsx @@ -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 = ({ 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(null); + + const [history, setHistory] = useState([]); + const [currentInfo, setCurrentInfo] = useState>({}); + + // Ref to track if component is mounted to prevent state updates after unmount + const mountedRef = useRef(true); + // Interval ref + const intervalRef = useRef | 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; + 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
Connection not found.
; + } + + // 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 ( +
+
+
+ + <DashboardOutlined style={{ marginRight: 8, color: '#1677ff' }} /> + Redis 实例监控 + + + {connection.name} + {currentInfo.redis_version && ` • Redis ${currentInfo.redis_version}`} + {currentInfo.os && ` • ${currentInfo.os}`} + +
+
+ {error && {error}} + {loading && !error && } + + + +
+
+ + + + + 已用内存 (Used)} + value={getFormatMemoryString(currentInfo.used_memory || '0')} + valueStyle={{ color: '#eb2f96', fontWeight: 600 }} + suffix={Peak: {getFormatMemoryString(currentInfo.used_memory_peak || '0')}} + /> + + + + + 客户端数量 (Clients)} + value={currentInfo.connected_clients || '0'} + valueStyle={{ color: '#1677ff', fontWeight: 600 }} + suffix={Blocked: {currentInfo.blocked_clients || '0'}} + /> + + + + + 吞吐量 (OPS)} + value={currentInfo.instantaneous_ops_per_sec || '0'} + valueStyle={{ color: '#52c41a', fontWeight: 600 }} + suffix={cmds/s} + /> + + + + + 启动时长 (Uptime)} + value={getUptimeString(currentInfo.uptime_in_seconds || '0')} + valueStyle={{ color: '#fa8c16', fontWeight: 600 }} + suffix={Days: {currentInfo.uptime_in_days || '0'}} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [`${value} MB`]} + /> + + + + + + + + + + + + + + + + + + [`${value} s`]} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ {['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] ? ( +
+ {key} + {currentInfo[key]} +
+ ) : null + ))} +
+
+
+
+ ); +}; + +export default RedisMonitor; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index a15a62f7..58658f35 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -30,7 +30,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, CodeOutlined, TagOutlined, CheckOutlined, - FilterOutlined + FilterOutlined, + DashboardOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; @@ -1035,13 +1036,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title)); } - setTreeData(origin => updateTreeData(origin, node.key, dbs)); + if (dbs.length > 0) { + setTreeData(origin => updateTreeData(origin, node.key, dbs)); + } else { + // 空列表:清理 loadedKeys 以允许重新加载,不设置 children = [] + setLoadedKeys(prev => prev.filter(k => k !== node.key)); + message.warning({ content: '未获取到可见数据库/schema,请检查账号权限或右键刷新', key: `conn-${conn.id}-dbs` }); + } } else { setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); + setLoadedKeys(prev => prev.filter(k => k !== node.key)); message.error({ content: res.message, key: `conn-${conn.id}-dbs` }); } } catch (e: any) { setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); + setLoadedKeys(prev => prev.filter(k => k !== node.key)); message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-dbs` }); } finally { loadingNodesRef.current.delete(loadKey); @@ -3098,6 +3107,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }); } }, + { + key: 'open-monitor', + label: 'Redis 实例监控', + icon: , + onClick: () => { + addTab({ + id: `redis-monitor-${node.key}-${Date.now()}`, + title: `监控: ${node.title}`, + type: 'redis-monitor', + connectionId: node.key, + redisDB: 0 + }); + } + }, { type: 'divider' }, { key: 'edit', @@ -3309,6 +3332,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> redisDB: redisDB }); } + }, + { + key: 'open-monitor', + label: 'Redis 实例监控', + icon: , + 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') { diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 4c8cb73d..acf54394 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -12,6 +12,7 @@ import QueryEditor from './QueryEditor'; import TableDesigner from './TableDesigner'; import RedisViewer from './RedisViewer'; import RedisCommandEditor from './RedisCommandEditor'; +import RedisMonitor from './RedisMonitor'; import TriggerViewer from './TriggerViewer'; import DefinitionViewer from './DefinitionViewer'; import TableOverview from './TableOverview'; @@ -199,17 +200,20 @@ const TabManager: React.FC = () => { const items = useMemo(() => tabs.map((tab, index) => { const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name; const displayTitle = buildTabDisplayTitle(tab, connectionName); + const tabIsActive = tab.id === activeTabId; let content; if (tab.type === 'query') { - content = ; + content = ; } else if (tab.type === 'table') { - content = ; + content = ; } else if (tab.type === 'design') { content = ; } else if (tab.type === 'redis-keys') { content = ; } else if (tab.type === 'redis-command') { content = ; + } else if (tab.type === 'redis-monitor') { + content = ; } else if (tab.type === 'trigger') { content = ; } else if (tab.type === 'view-def' || tab.type === 'routine-def') { @@ -256,7 +260,7 @@ const TabManager: React.FC = () => { key: tab.id, children: content, }; - }), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]); + }), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]); return ( <> diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 78796680..88c2fb43 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -118,7 +118,7 @@ export interface TriggerDefinition { export interface TabData { id: 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; dbName?: string; tableName?: string; diff --git a/frontend/src/utils/approximateTableCount.test.ts b/frontend/src/utils/approximateTableCount.test.ts new file mode 100644 index 00000000..cc0a4ff3 --- /dev/null +++ b/frontend/src/utils/approximateTableCount.test.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/approximateTableCount.ts b/frontend/src/utils/approximateTableCount.ts new file mode 100644 index 00000000..c699692c --- /dev/null +++ b/frontend/src/utils/approximateTableCount.ts @@ -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); + 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; +}; diff --git a/frontend/src/utils/dataGridPagination.test.ts b/frontend/src/utils/dataGridPagination.test.ts new file mode 100644 index 00000000..97ef98ec --- /dev/null +++ b/frontend/src/utils/dataGridPagination.test.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/dataGridPagination.ts b/frontend/src/utils/dataGridPagination.ts new file mode 100644 index 00000000..96e2d714 --- /dev/null +++ b/frontend/src/utils/dataGridPagination.ts @@ -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; +}; diff --git a/frontend/src/utils/dataSourceCapabilities.test.ts b/frontend/src/utils/dataSourceCapabilities.test.ts new file mode 100644 index 00000000..c839c92d --- /dev/null +++ b/frontend/src/utils/dataSourceCapabilities.test.ts @@ -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, + }); + }); +}); diff --git a/frontend/src/utils/dataSourceCapabilities.ts b/frontend/src/utils/dataSourceCapabilities.ts index 8d308540..56331f41 100644 --- a/frontend/src/utils/dataSourceCapabilities.ts +++ b/frontend/src/utils/dataSourceCapabilities.ts @@ -64,6 +64,9 @@ const COPY_INSERT_TYPES = new Set([ const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']); 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 = { type: string; @@ -71,6 +74,9 @@ export type DataSourceCapabilities = { supportsSqlQueryExport: boolean; supportsCopyInsert: boolean; forceReadOnlyQueryResult: boolean; + preferManualTotalCount: boolean; + supportsApproximateTableCount: boolean; + supportsApproximateTotalPages: boolean; }; export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => { @@ -81,6 +87,8 @@ export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCap supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type), supportsCopyInsert: COPY_INSERT_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), }; }; - diff --git a/frontend/src/utils/dataViewerAutoFetch.test.ts b/frontend/src/utils/dataViewerAutoFetch.test.ts new file mode 100644 index 00000000..85b3af10 --- /dev/null +++ b/frontend/src/utils/dataViewerAutoFetch.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/utils/dataViewerAutoFetch.ts b/frontend/src/utils/dataViewerAutoFetch.ts new file mode 100644 index 00000000..c9e1860b --- /dev/null +++ b/frontend/src/utils/dataViewerAutoFetch.ts @@ -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'; +}; diff --git a/internal/db/dameng_metadata.go b/internal/db/dameng_metadata.go index b0f698b5..15d12ad2 100644 --- a/internal/db/dameng_metadata.go +++ b/internal/db/dameng_metadata.go @@ -9,9 +9,9 @@ import ( ) var damengDatabaseQueries = []string{ - // 优先使用达梦原生系统表 - "SELECT DISTINCT OBJECT_NAME AS DATABASE_NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH' AND OBJECT_NAME NOT IN ('SYS','SYSDBA','SYSAUDITOR','SYSSSO','CTISYS','__RECYCLE_USER__') ORDER BY OBJECT_NAME", - "SELECT SCHEMA_NAME AS DATABASE_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('SYS','SYSDBA','SYSAUDITOR','SYSSSO','CTISYS','INFORMATION_SCHEMA') ORDER BY SCHEMA_NAME", + // 优先使用达梦原生系统表(SYSDBA 保留:作为默认管理员 schema,大多数用户在此创建业务表) + "SELECT DISTINCT OBJECT_NAME AS DATABASE_NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH' AND OBJECT_NAME NOT IN ('SYS','SYSAUDITOR','SYSSSO','CTISYS','__RECYCLE_USER__') ORDER BY OBJECT_NAME", + "SELECT SCHEMA_NAME AS DATABASE_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('SYS','SYSAUDITOR','SYSSSO','CTISYS','INFORMATION_SCHEMA') ORDER BY SCHEMA_NAME", // Oracle 兼容层 "SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS DATABASE_NAME FROM DUAL", "SELECT SYS_CONTEXT('USERENV', 'CURRENT_USER') AS DATABASE_NAME FROM DUAL", @@ -21,6 +21,8 @@ var damengDatabaseQueries = []string{ "SELECT USERNAME AS DATABASE_NAME FROM SYS.DBA_USERS ORDER BY USERNAME", "SELECT DISTINCT OWNER AS DATABASE_NAME FROM ALL_OBJECTS ORDER BY OWNER", "SELECT DISTINCT OWNER AS DATABASE_NAME FROM ALL_TABLES ORDER BY OWNER", + // 最终兜底:获取当前连接用户作为 schema 名称 + "SELECT USER AS DATABASE_NAME FROM DUAL", } type damengQueryFunc func(query string) ([]map[string]interface{}, []string, error) diff --git a/internal/db/dameng_metadata_test.go b/internal/db/dameng_metadata_test.go index 53106793..b5b6557d 100644 --- a/internal/db/dameng_metadata_test.go +++ b/internal/db/dameng_metadata_test.go @@ -71,3 +71,50 @@ func TestCollectDamengDatabaseNames_ReturnsErrorWhenNoNameResolved(t *testing.T) t.Fatalf("错误不符合预期: %v", err) } } + +// TestCollectDamengDatabaseNames_IncludesSYSDBA 验证 SYSDBA(达梦默认管理员 schema) +// 不会被系统 schema 过滤排除。 +func TestCollectDamengDatabaseNames_IncludesSYSDBA(t *testing.T) { + t.Parallel() + + got, err := collectDamengDatabaseNames(func(query string) ([]map[string]interface{}, []string, error) { + switch query { + case damengDatabaseQueries[0]: + // 查询 0 返回 SYSDBA(之前会被排除,修复后应该返回) + return []map[string]interface{}{{"DATABASE_NAME": "SYSDBA"}}, nil, nil + default: + return nil, nil, errors.New("permission denied") + } + }) + if err != nil { + t.Fatalf("collectDamengDatabaseNames 返回错误: %v", err) + } + + want := []string{"SYSDBA"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("SYSDBA 应该包含在结果中, got=%v want=%v", got, want) + } +} + +// TestCollectDamengDatabaseNames_FallbackToCurrentUser 验证当所有查询都失败时 +// 兜底查询 SELECT USER FROM DUAL 能返回当前用户作为 schema。 +func TestCollectDamengDatabaseNames_FallbackToCurrentUser(t *testing.T) { + t.Parallel() + + lastQuery := damengDatabaseQueries[len(damengDatabaseQueries)-1] + got, err := collectDamengDatabaseNames(func(query string) ([]map[string]interface{}, []string, error) { + if query == lastQuery { + return []map[string]interface{}{{"DATABASE_NAME": "SYSDBA"}}, nil, nil + } + // 前面所有查询要么返回空要么报错 + return []map[string]interface{}{}, nil, nil + }) + if err != nil { + t.Fatalf("collectDamengDatabaseNames 返回错误: %v", err) + } + + want := []string{"SYSDBA"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("兜底查询应该返回当前用户, got=%v want=%v", got, want) + } +} diff --git a/internal/db/postgres_impl.go b/internal/db/postgres_impl.go index 727b3dca..59700033 100644 --- a/internal/db/postgres_impl.go +++ b/internal/db/postgres_impl.go @@ -166,6 +166,9 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error { logger.Infof("PostgreSQL 自动选择连接数据库:%s", dbName) } + // 设置 search_path,使所有用户 schema 下的表可以不带 schema 前缀访问 + p.ensureSearchPath(dsn) + cleanupOnFailure = false return nil } @@ -611,6 +614,101 @@ ORDER BY table_schema, table_name, ordinal_position` return cols, nil } +// ensureSearchPath 查询当前数据库中所有用户 schema,通过重建连接池将 search_path 写入 DSN。 +// 仅使用 SET search_path 只对连接池中的单个连接生效,后续查询可能拿到未设置的连接。 +// 将 search_path 写入 DSN (lib/pq 支持任意 PostgreSQL runtime parameter), +// 使连接池中每个连接建立时自动携带 search_path,与金仓行为一致。 +func (p *PostgresDB) ensureSearchPath(baseDSN string) { + if p.conn == nil { + return + } + + rawSchemas := p.queryUserSchemas() + if len(rawSchemas) == 0 { + return + } + + // 构建 search_path SQL 片段(带双引号转义),用于 SET 兜底 + searchPathSQL, normalizedSchemas := buildKingbaseSearchPathCommon(rawSchemas) + if strings.TrimSpace(searchPathSQL) == "" { + return + } + + // 策略 1:将 search_path 写入 DSN,重建连接池 + // lib/pq 支持在 URL 查参数中设置任意 PostgreSQL runtime parameter, + // 如 ?search_path=ce,public,每个新连接建立时会自动 SET search_path。 + searchPathDSNVal := strings.Join(normalizedSchemas, ",") + u, parseErr := url.Parse(baseDSN) + if parseErr == nil { + q := u.Query() + q.Set("search_path", searchPathDSNVal) + u.RawQuery = q.Encode() + newDSN := u.String() + + newDB, err := sql.Open("postgres", newDSN) + if err == nil { + newDB.SetConnMaxLifetime(5 * time.Minute) + oldConn := p.conn + p.conn = newDB + if err := p.Ping(); err == nil { + _ = oldConn.Close() + logger.Infof("PostgreSQL 已通过 DSN 配置 search_path:%s", searchPathDSNVal) + return + } + // DSN 方式失败,回滚 + _ = newDB.Close() + p.conn = oldConn + logger.Warnf("PostgreSQL DSN search_path 验证失败,回退至 SET 方式") + } + } + + // 策略 2 兜底:通过 SET search_path 设置(仅影响单个连接,但聊胜于无) + timeout := p.pingTimeout + if timeout <= 0 { + timeout = 5 * time.Second + } + ctx, cancel := utils.ContextWithTimeout(timeout) + defer cancel() + + if _, err := p.conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", searchPathSQL)); err != nil { + logger.Warnf("PostgreSQL 设置 search_path 失败:%v", err) + return + } + logger.Infof("PostgreSQL 已通过 SET 设置 search_path:%s", searchPathSQL) +} + +// queryUserSchemas 查询当前数据库中所有用户 schema。 +func (p *PostgresDB) queryUserSchemas() []string { + if p.conn == nil { + return nil + } + + query := `SELECT nspname FROM pg_namespace + WHERE nspname NOT IN ('pg_catalog', 'information_schema') + AND nspname NOT LIKE 'pg_%' + ORDER BY nspname` + + rows, err := p.conn.Query(query) + if err != nil { + logger.Warnf("PostgreSQL 查询用户 schema 失败:%v", err) + return nil + } + defer rows.Close() + + var schemas []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + continue + } + name = strings.TrimSpace(name) + if name != "" { + schemas = append(schemas, name) + } + } + return schemas +} + func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet) error { if p.conn == nil { return fmt.Errorf("连接未打开")