feat(redis/monitor/oracle/data-viewer): 新增 Redis 实例监控并优化 Oracle 大表预览体验

- 新增 RedisMonitor 面板,展示 QPS、内存、CPU、连接数和键数量趋势图
- 引入 recharts 依赖并补齐监控图表所需前端包与锁文件
- Sidebar 新增 Redis 实例监控入口,TabManager 与 TabData 接入 redis-monitor 页签类型
- RedisCommandEditor 支持多行脚本块解析、选区执行、耗时记录与终端化结果展示
- Oracle 表预览移除自动精确 COUNT(*),避免打开大表时额外阻塞
- 无筛选整表预览接入 ALL_TABLES.NUM_ROWS 近似总数展示,并拆分近似总数与近似总页数语义
This commit is contained in:
Syngnat
2026-03-30 16:48:19 +08:00
parent 6e55d63877
commit aa9d8d243a
20 changed files with 1424 additions and 194 deletions

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import {
buildOracleApproximateTotalSql,
parseApproximateTableCountRow,
resolveApproximateTableCountStrategy,
} from './approximateTableCount';
describe('approximateTableCount', () => {
it('uses oracle metadata approximate total only for unfiltered full-table preview', () => {
expect(resolveApproximateTableCountStrategy({ dbType: 'oracle', whereSQL: '' })).toBe('oracle-num-rows');
expect(resolveApproximateTableCountStrategy({ dbType: 'oracle', whereSQL: 'WHERE id = 1' })).toBe('none');
});
it('keeps duckdb approximate count on unfiltered previews', () => {
expect(resolveApproximateTableCountStrategy({ dbType: 'duckdb', whereSQL: '' })).toBe('duckdb-estimated-size');
});
it('builds Oracle approx count SQL from owner and table name', () => {
expect(buildOracleApproximateTotalSql({ dbName: 'HR', tableName: 'HR.EMPLOYEES' })).toContain("owner = 'HR'");
expect(buildOracleApproximateTotalSql({ dbName: 'HR', tableName: 'HR.EMPLOYEES' })).toContain("table_name = 'EMPLOYEES'");
});
it('parses approximate total rows using preferred keys', () => {
expect(parseApproximateTableCountRow({ NUM_ROWS: '1234' }, ['num_rows'])).toBe(1234);
expect(parseApproximateTableCountRow({ approx_total: 5678 }, ['approx_total'])).toBe(5678);
});
});

View File

@@ -0,0 +1,106 @@
export type ApproximateTableCountStrategy = 'none' | 'duckdb-estimated-size' | 'oracle-num-rows';
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
const toNonNegativeFiniteNumber = (value: unknown): number | null => {
if (typeof value === 'number') {
return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
}
if (typeof value === 'bigint') {
return value >= 0n && value <= MAX_SAFE_BIGINT ? Number(value) : null;
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) return null;
if (/^[+-]?\d+$/.test(text)) {
try {
const parsed = BigInt(text);
return parsed >= 0n && parsed <= MAX_SAFE_BIGINT ? Number(parsed) : null;
} catch {
return null;
}
}
const parsed = Number(text);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= Number.MAX_SAFE_INTEGER ? parsed : null;
}
return null;
};
const stripOuterQuotes = (value: string): string => {
const trimmed = String(value || '').trim();
if (trimmed.length < 2) return trimmed;
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if ((first === '"' && last === '"') || (first === '`' && last === '`') || (first === '[' && last === ']')) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
};
const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''");
const resolveOracleOwnerAndTable = (params: { dbName: string; tableName: string }) => {
const rawTable = String(params.tableName || '').trim();
const parts = rawTable.split('.').map(stripOuterQuotes).filter(Boolean);
const tableName = String(parts[parts.length - 1] || rawTable || '').trim();
const ownerCandidate = parts.length >= 2 ? parts[parts.length - 2] : String(params.dbName || '').trim();
return {
owner: ownerCandidate.toUpperCase(),
tableName: tableName.toUpperCase(),
};
};
export const resolveApproximateTableCountStrategy = (params: {
dbType: string;
whereSQL: string;
}): ApproximateTableCountStrategy => {
const dbType = String(params.dbType || '').trim().toLowerCase();
const whereSQL = String(params.whereSQL || '').trim();
if (whereSQL) return 'none';
if (dbType === 'duckdb') return 'duckdb-estimated-size';
if (dbType === 'oracle') return 'oracle-num-rows';
return 'none';
};
export const buildOracleApproximateTotalSql = (params: { dbName: string; tableName: string }): string => {
const { owner, tableName } = resolveOracleOwnerAndTable(params);
const escapedTable = escapeSQLLiteral(tableName);
if (!owner) {
return `SELECT num_rows AS approx_total FROM user_tables WHERE table_name = '${escapedTable}' AND ROWNUM = 1`;
}
return `SELECT num_rows AS approx_total FROM all_tables WHERE owner = '${escapeSQLLiteral(owner)}' AND table_name = '${escapedTable}' AND ROWNUM = 1`;
};
export const parseApproximateTableCountRow = (
row: unknown,
preferredKeys: string[] = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'num_rows', 'count', 'total'],
): number | null => {
if (!row || typeof row !== 'object') return null;
const entries = Object.entries(row as Record<string, unknown>);
if (entries.length === 0) return null;
for (const preferredKey of preferredKeys) {
const normalizedPreferred = String(preferredKey || '').trim().toLowerCase();
for (const [key, value] of entries) {
if (String(key || '').trim().toLowerCase() !== normalizedPreferred) continue;
const parsed = toNonNegativeFiniteNumber(value);
if (parsed !== null) return parsed;
}
}
for (const [key, value] of entries) {
const normalizedKey = String(key || '').trim().toLowerCase();
if (!normalizedKey.includes('estimate') && !normalizedKey.includes('row') && !normalizedKey.includes('count') && !normalizedKey.includes('total')) {
continue;
}
const parsed = toNonNegativeFiniteNumber(value);
if (parsed !== null) return parsed;
}
for (const [, value] of entries) {
const parsed = toNonNegativeFiniteNumber(value);
if (parsed !== null) return parsed;
}
return null;
};

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
resolvePaginationPageText,
resolvePaginationSummaryText,
resolvePaginationTotalForControl,
} from './dataGridPagination';
describe('dataGridPagination', () => {
it('shows Oracle approximate total in summary but not in total-page chip', () => {
const pagination = {
current: 3,
pageSize: 100,
total: 301,
totalKnown: false,
totalApprox: true,
approximateTotal: 1832451,
};
expect(resolvePaginationSummaryText({
pagination,
prefersManualTotalCount: true,
supportsApproximateTableCount: true,
})).toContain('约 1832451 条');
expect(resolvePaginationPageText({
pagination,
supportsApproximateTotalPages: false,
})).toBe('第 3 页');
expect(resolvePaginationTotalForControl({
pagination,
supportsApproximateTotalPages: false,
})).toBe(301);
});
it('still allows DuckDB to use approximate totals for page counts', () => {
const pagination = {
current: 2,
pageSize: 100,
total: 201,
totalKnown: false,
totalApprox: true,
approximateTotal: 1000,
};
expect(resolvePaginationPageText({
pagination,
supportsApproximateTotalPages: true,
})).toBe('第 2 / 10 页');
expect(resolvePaginationTotalForControl({
pagination,
supportsApproximateTotalPages: true,
})).toBe(1000);
});
});

View File

@@ -0,0 +1,92 @@
export type PaginationStateLike = {
current: number;
pageSize: number;
total: number;
totalKnown?: boolean;
totalApprox?: boolean;
approximateTotal?: number;
totalCountLoading?: boolean;
totalCountCancelled?: boolean;
};
const toFiniteNonNegativeNumber = (value: unknown): number | null => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
};
const resolveApproximateTotal = (pagination: PaginationStateLike): number | null => {
if (!pagination.totalApprox) return null;
const approximateTotal = toFiniteNonNegativeNumber(pagination.approximateTotal);
return approximateTotal !== null && approximateTotal > 0 ? approximateTotal : null;
};
const resolveCurrentCount = (pagination: PaginationStateLike): number => {
const total = toFiniteNonNegativeNumber(pagination.total) ?? 0;
const rangeStart = Math.max(0, (pagination.current - 1) * pagination.pageSize + (total > 0 ? 1 : 0));
const hasValidRange = total > 0 && rangeStart > 0;
if (!hasValidRange) return 0;
const rangeEnd = Math.min(total, rangeStart + pagination.pageSize - 1);
return Math.max(0, rangeEnd - rangeStart + 1);
};
export const resolvePaginationSummaryText = (params: {
pagination: PaginationStateLike;
prefersManualTotalCount: boolean;
supportsApproximateTableCount: boolean;
}): string => {
const { pagination, prefersManualTotalCount, supportsApproximateTableCount } = params;
const currentCount = resolveCurrentCount(pagination);
const total = toFiniteNonNegativeNumber(pagination.total) ?? 0;
const approximateTotal = resolveApproximateTotal(pagination);
if (pagination.totalKnown === false) {
if (prefersManualTotalCount) {
if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数…`;
if (supportsApproximateTableCount && approximateTotal !== null) return `当前 ${currentCount} 条 / 约 ${approximateTotal}`;
if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`;
return `当前 ${currentCount} 条 / 总数未统计`;
}
return `当前 ${currentCount} 条 / 正在统计总数…`;
}
if (!Number.isFinite(total) || total <= 0) {
return '当前 0 条 / 共 0 条';
}
return `当前 ${currentCount} 条 / 共 ${total}`;
};
export const resolvePaginationPageText = (params: {
pagination: PaginationStateLike;
supportsApproximateTotalPages: boolean;
}): string => {
const { pagination, supportsApproximateTotalPages } = params;
const exactTotal = toFiniteNonNegativeNumber(pagination.total) ?? 0;
const approximateTotal = resolveApproximateTotal(pagination);
const effectiveTotal =
pagination.totalKnown !== false
? exactTotal
: supportsApproximateTotalPages && approximateTotal !== null
? approximateTotal
: 0;
if (effectiveTotal <= 0) return `${pagination.current}`;
const totalPages = Math.max(1, Math.ceil(effectiveTotal / Math.max(1, pagination.pageSize)));
if (pagination.totalKnown === false && !(supportsApproximateTotalPages && approximateTotal !== null)) {
return `${pagination.current}`;
}
return `${pagination.current} / ${totalPages}`;
};
export const resolvePaginationTotalForControl = (params: {
pagination: PaginationStateLike;
supportsApproximateTotalPages: boolean;
}): number => {
const { pagination, supportsApproximateTotalPages } = params;
const exactTotal = toFiniteNonNegativeNumber(pagination.total) ?? 0;
const approximateTotal = resolveApproximateTotal(pagination);
if (pagination.totalKnown !== false) return exactTotal;
if (supportsApproximateTotalPages && approximateTotal !== null) return approximateTotal;
return exactTotal;
};

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { getDataSourceCapabilities } from './dataSourceCapabilities';
describe('dataSourceCapabilities', () => {
it('treats Oracle table preview totals as manual exact count plus approximate metadata count', () => {
expect(getDataSourceCapabilities({ type: 'oracle' })).toMatchObject({
type: 'oracle',
preferManualTotalCount: true,
supportsApproximateTableCount: true,
supportsApproximateTotalPages: false,
});
});
it('keeps DuckDB manual count and approximate total support', () => {
expect(getDataSourceCapabilities({ type: 'duckdb' })).toMatchObject({
type: 'duckdb',
preferManualTotalCount: true,
supportsApproximateTableCount: true,
supportsApproximateTotalPages: true,
});
});
it('keeps MySQL on automatic total count mode', () => {
expect(getDataSourceCapabilities({ type: 'mysql' })).toMatchObject({
type: 'mysql',
preferManualTotalCount: false,
supportsApproximateTableCount: false,
supportsApproximateTotalPages: false,
});
});
});

View File

@@ -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),
};
};

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { resolveDataViewerAutoFetchAction } from './dataViewerAutoFetch';
describe('resolveDataViewerAutoFetchAction', () => {
it('skips one fetch while tab state is hydrating', () => {
expect(resolveDataViewerAutoFetchAction({
skipNextAutoFetch: true,
hasInitialLoad: false,
})).toBe('skip');
});
it('loads current page on the first real fetch', () => {
expect(resolveDataViewerAutoFetchAction({
skipNextAutoFetch: false,
hasInitialLoad: false,
})).toBe('load-current-page');
});
it('reloads from first page after sort or filter changes', () => {
expect(resolveDataViewerAutoFetchAction({
skipNextAutoFetch: false,
hasInitialLoad: true,
})).toBe('reload-first-page');
});
});

View File

@@ -0,0 +1,16 @@
export type DataViewerAutoFetchAction = 'skip' | 'load-current-page' | 'reload-first-page';
export const resolveDataViewerAutoFetchAction = (params: {
skipNextAutoFetch: boolean;
hasInitialLoad: boolean;
}): DataViewerAutoFetchAction => {
if (params.skipNextAutoFetch) {
return 'skip';
}
if (!params.hasInitialLoad) {
return 'load-current-page';
}
return 'reload-first-page';
};