- ) : (
- results.map((item, index) => (
-
-
- > {item.command}
+ {/* Resizer Handle */}
+
+
+ {/* Results Terminal Bottom Pane */}
+
+
+ Execution Output
+ } onClick={handleClear} style={{ color: '#aaa' }}>清空控制台
+
+
+ {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 (
+
+
+
+
+
+ Redis 实例监控
+
+
+ {connection.name}
+ {currentInfo.redis_version && ` • Redis ${currentInfo.redis_version}`}
+ {currentInfo.os && ` • ${currentInfo.os}`}
+
+
+
+ {error &&
{error} }
+ {loading && !error &&
}
+
+
:
}
+ onClick={() => setIsRunning(!isRunning)}
+ >
+ {isRunning ? '暂停刷新' : '恢复刷新'}
+
+
} onClick={fetchMetrics}>
+ 立即刷新
+
+
+
+
+
+
+
+ 已用内存 (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("连接未打开")