feat(redis): add key statistics tracking for database browser (#269)

## 背景说明
为 Redis 数据库浏览器增加键统计功能,显示已加载 key 数/总 key 数,
并在动态加载、删除、清空时实时更新。

## 修改内容
- 新增 \RedisDatabaseInfo\ 结构体(db + keys),替代原来的 \Vec<u32>\
- \list_databases\ 返回每个 db 的 key 数量(解析 INFO keyspace)
- \scan_keys_page\ 增加 DBSIZE 调用获取 total_keys
- 前端 TreeNode 增加 loadedKeyCount/totalKeyCount 字段
- 树节点标签动态显示 \db0 (loaded/total)\ 格式
- 新增 \updateRedisDbKeyStats\ 在加载/删除/清空时更新统计

## 测试
- 建议补充 list_keyspace_databases 解析异常输入的单测
- 建议补充 redisDbLabel 边界值测试
This commit is contained in:
Abeautifulsnow
2026-05-14 16:24:46 +08:00
committed by GitHub
parent 7faa9ec818
commit 919c3d58c1
10 changed files with 94 additions and 20 deletions
+16 -1
View File
@@ -25,6 +25,7 @@ import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import RedisValueViewer from "./RedisValueViewer.vue";
import * as api from "@/lib/api";
import type { RedisKeyInfo } from "@/lib/api";
import { useConnectionStore } from "@/stores/connectionStore";
import {
buildRedisKeyTree,
collectExpandedGroupIds,
@@ -36,6 +37,7 @@ import { classifyRedisCommandSafety } from "@/lib/redisCommandSafety";
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
const { t } = useI18n();
const connectionStore = useConnectionStore();
const props = defineProps<{
connectionId: string;
@@ -134,6 +136,10 @@ async function scanNextPage() {
scanCursor.value = result.cursor;
hasMore.value = result.cursor !== 0;
rebuildTree(isSearchMode.value);
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
total: result.total_keys,
});
}
async function loadKeys() {
@@ -180,6 +186,10 @@ function onKeyDeleted() {
flatKeys.value = flatKeys.value.filter((key) => key.key_raw !== selectedKeyRaw.value);
selectedKeyRaw.value = null;
rebuildTree(false);
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
totalDelta: -1,
});
}
function toggleCheck(keyRaw: string, event: Event) {
@@ -220,7 +230,7 @@ function resetLoadedKeys() {
}
async function deleteKeyRaws(keys: string[]) {
await api.redisDeleteKeys(props.connectionId, props.db, keys);
const deletedCount = await api.redisDeleteKeys(props.connectionId, props.db, keys);
const deleted = new Set(keys);
flatKeys.value = flatKeys.value.filter((k) => !deleted.has(k.key_raw));
if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) {
@@ -228,6 +238,10 @@ async function deleteKeyRaws(keys: string[]) {
}
checkedKeys.value = new Set();
rebuildTree(false);
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
totalDelta: -deletedCount,
});
}
async function runRedisCommand(command: string) {
@@ -280,6 +294,7 @@ async function applyDangerAction() {
} else if (pending.kind === "flush-db") {
await api.redisFlushDb(props.connectionId, props.db);
resetLoadedKeys();
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, { loaded: 0, total: 0 });
} else {
await runRedisCommand(pending.command);
}
+1 -1
View File
@@ -17,7 +17,7 @@ export function useDatabaseOptions() {
await connectionStore.ensureConnected(connectionId);
if (connection.db_type === "redis") {
const dbs = await api.redisListDatabases(connectionId);
databaseOptions.value[connectionId] = dbs.map(String);
databaseOptions.value[connectionId] = dbs.map((db) => String(db.db));
} else if (connection.db_type === "mongodb") {
databaseOptions.value[connectionId] = await api.mongoListDatabases(connectionId);
} else {
+1
View File
@@ -156,6 +156,7 @@ export type {
AiChatMessage,
AiConversation,
UpdateInfo,
RedisDatabaseInfo,
RedisKeyInfo,
RedisValue,
RedisScanResult,
+2 -1
View File
@@ -24,6 +24,7 @@ import type {
AiStreamChunk,
AiConversation,
UpdateInfo,
RedisDatabaseInfo,
RedisValue,
RedisScanResult,
RedisCommandResult,
@@ -547,7 +548,7 @@ export async function cancelDatabaseExport(exportId: string): Promise<void> {
// Redis
// ---------------------------------------------------------------------------
export async function redisListDatabases(connectionId: string): Promise<number[]> {
export async function redisListDatabases(connectionId: string): Promise<RedisDatabaseInfo[]> {
return post("/api/redis/list-databases", { connectionId });
}
+7 -1
View File
@@ -355,6 +355,11 @@ export interface RedisKeyInfo {
value_preview: string;
}
export interface RedisDatabaseInfo {
db: number;
keys: number;
}
export interface RedisValue {
key_display: string;
key_raw: string;
@@ -369,6 +374,7 @@ export interface RedisValue {
export interface RedisScanResult {
cursor: number;
keys: RedisKeyInfo[];
total_keys: number;
}
export type RedisCommandSafety = "allowed" | "confirm" | "blocked";
@@ -379,7 +385,7 @@ export interface RedisCommandResult {
value: any;
}
export async function redisListDatabases(connectionId: string): Promise<number[]> {
export async function redisListDatabases(connectionId: string): Promise<RedisDatabaseInfo[]> {
return invoke("redis_list_databases", { connectionId });
}
+26 -3
View File
@@ -38,6 +38,11 @@ interface LoadTreeOptions {
force?: boolean;
}
function redisDbLabel(db: number, loadedKeyCount?: number, totalKeyCount?: number): string {
if (totalKeyCount == null) return `db${db}`;
return `db${db} (${loadedKeyCount ?? 0}/${totalKeyCount})`;
}
export const useConnectionStore = defineStore("connection", () => {
const connections = ref<ConnectionConfig[]>([]);
const isDesktop = isTauriRuntime();
@@ -581,11 +586,13 @@ export const useConnectionStore = defineStore("connection", () => {
withSavedSqlRoot(
connectionId,
dbs.map((db) => ({
id: `${connectionId}:db${db}`,
label: `db${db}`,
id: `${connectionId}:db${db.db}`,
label: redisDbLabel(db.db, 0, db.keys),
type: "redis-db" as const,
connectionId,
database: String(db),
database: String(db.db),
loadedKeyCount: 0,
totalKeyCount: db.keys,
isExpanded: false,
children: [],
})),
@@ -598,6 +605,21 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
function updateRedisDbKeyStats(
connectionId: string,
db: number,
stats: { loaded?: number; total?: number; totalDelta?: number },
) {
const node = findNode(treeNodes.value, `${connectionId}:db${db}`);
if (!node || node.type !== "redis-db") return;
if (stats.loaded != null) node.loadedKeyCount = stats.loaded;
if (stats.total != null) node.totalKeyCount = stats.total;
if (stats.totalDelta != null && node.totalKeyCount != null) {
node.totalKeyCount = Math.max(0, node.totalKeyCount + stats.totalDelta);
}
node.label = redisDbLabel(db, node.loadedKeyCount, node.totalKeyCount);
}
async function loadMongoDatabases(connectionId: string) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
@@ -1442,6 +1464,7 @@ export const useConnectionStore = defineStore("connection", () => {
initFromDisk,
loadDatabases,
loadRedisDatabases,
updateRedisDbKeyStats,
loadMongoDatabases,
loadMongoCollections,
loadSchemas,
+2
View File
@@ -231,6 +231,8 @@ export interface TreeNode {
schema?: string;
tableName?: string;
objectCount?: number;
loadedKeyCount?: number;
totalKeyCount?: number;
hiddenChildren?: TreeNode[];
savedSqlId?: string;
savedSqlFolderId?: string;