From 919c3d58c118122a9497eb29519af637ceb5bc9a Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Thu, 14 May 2026 16:24:46 +0800 Subject: [PATCH] feat(redis): add key statistics tracking for database browser (#269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景说明 为 Redis 数据库浏览器增加键统计功能,显示已加载 key 数/总 key 数, 并在动态加载、删除、清空时实时更新。 ## 修改内容 - 新增 \RedisDatabaseInfo\ 结构体(db + keys),替代原来的 \Vec\ - \list_databases\ 返回每个 db 的 key 数量(解析 INFO keyspace) - \scan_keys_page\ 增加 DBSIZE 调用获取 total_keys - 前端 TreeNode 增加 loadedKeyCount/totalKeyCount 字段 - 树节点标签动态显示 \db0 (loaded/total)\ 格式 - 新增 \updateRedisDbKeyStats\ 在加载/删除/清空时更新统计 ## 测试 - 建议补充 list_keyspace_databases 解析异常输入的单测 - 建议补充 redisDbLabel 边界值测试 --- crates/dbx-core/src/db/redis_driver.rs | 38 ++++++++++++++++++------ crates/dbx-core/src/redis_ops.rs | 7 +++-- src-tauri/src/commands/redis_cmd.rs | 7 +++-- src/components/redis/RedisKeyBrowser.vue | 17 ++++++++++- src/composables/useDatabaseOptions.ts | 2 +- src/lib/api.ts | 1 + src/lib/http.ts | 3 +- src/lib/tauri.ts | 8 ++++- src/stores/connectionStore.ts | 29 ++++++++++++++++-- src/types/database.ts | 2 ++ 10 files changed, 94 insertions(+), 20 deletions(-) diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index 28c02268b3..11016534ef 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -6,6 +6,12 @@ const STREAM_ENTRY_LIMIT: usize = 100; const COLLECTION_PAGE_SIZE: usize = 200; const DEFAULT_REDIS_DATABASES: u32 = 16; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedisDatabaseInfo { + pub db: u32, + pub keys: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RedisKeyInfo { pub key_display: String, @@ -20,6 +26,7 @@ pub struct RedisKeyInfo { pub struct RedisScanResult { pub cursor: u64, pub keys: Vec, + pub total_keys: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -64,16 +71,20 @@ pub async fn connect(url: &str) -> Result Result, String> { +pub async fn list_databases(con: &mut redis::aio::MultiplexedConnection) -> Result, String> { let configured_count = redis::cmd("CONFIG").arg("GET").arg("databases").query_async(con).await.ok().and_then(parse_database_count); let keyspace_dbs = list_keyspace_databases(con).await.unwrap_or_default(); let database_count = configured_count.unwrap_or(DEFAULT_REDIS_DATABASES); - let max_db = keyspace_dbs.iter().copied().max().map(|db| db + 1).unwrap_or(0); + let max_db = keyspace_dbs.iter().map(|db| db.db).max().map(|db| db + 1).unwrap_or(0); let visible_count = database_count.max(max_db).max(1); + let keyspace_counts = + keyspace_dbs.into_iter().map(|db| (db.db, db.keys)).collect::>(); - Ok((0..visible_count).collect()) + Ok((0..visible_count) + .map(|db| RedisDatabaseInfo { db, keys: keyspace_counts.get(&db).copied().unwrap_or(0) }) + .collect()) } fn parse_database_count(value: redis::Value) -> Option { @@ -92,15 +103,23 @@ fn parse_database_count(value: redis::Value) -> Option { }) } -async fn list_keyspace_databases(con: &mut redis::aio::MultiplexedConnection) -> Result, String> { +async fn list_keyspace_databases( + con: &mut redis::aio::MultiplexedConnection, +) -> Result, String> { let info: String = redis::cmd("INFO").arg("keyspace").query_async(con).await.map_err(|e| e.to_string())?; let mut dbs = Vec::new(); for line in info.lines() { if line.starts_with("db") { - if let Some(num) = line.strip_prefix("db").and_then(|s| s.split(':').next()) { - if let Ok(n) = num.parse::() { - dbs.push(n); + if let Some((db_part, stats_part)) = line.split_once(':') { + if let Some(num) = db_part.strip_prefix("db") { + if let Ok(db) = num.parse::() { + let keys = stats_part + .split(',') + .find_map(|part| part.strip_prefix("keys=").and_then(|value| value.parse::().ok())) + .unwrap_or(0); + dbs.push(RedisDatabaseInfo { db, keys }); + } } } } @@ -276,8 +295,9 @@ pub async fn scan_keys_page( .map_err(|e| e.to_string())?; let (next_cursor, keys) = parse_scan_keys(raw)?; + let total_keys: u64 = redis::cmd("DBSIZE").query_async(con).await.unwrap_or(0); if keys.is_empty() { - return Ok(RedisScanResult { cursor: next_cursor, keys: Vec::new() }); + return Ok(RedisScanResult { cursor: next_cursor, keys: Vec::new(), total_keys }); } let mut pipe = redis::pipe(); @@ -297,7 +317,7 @@ pub async fn scan_keys_page( value_preview: String::new(), }); } - Ok(RedisScanResult { cursor: next_cursor, keys: result }) + Ok(RedisScanResult { cursor: next_cursor, keys: result, total_keys }) } pub async fn get_value(con: &mut redis::aio::MultiplexedConnection, key: &[u8]) -> Result { diff --git a/crates/dbx-core/src/redis_ops.rs b/crates/dbx-core/src/redis_ops.rs index 6b58e48000..06ae42a1d4 100644 --- a/crates/dbx-core/src/redis_ops.rs +++ b/crates/dbx-core/src/redis_ops.rs @@ -1,7 +1,10 @@ use crate::connection::{AppState, PoolKind}; -use crate::db::redis_driver::{self, RedisCommandResult, RedisScanResult, RedisValue}; +use crate::db::redis_driver::{self, RedisCommandResult, RedisDatabaseInfo, RedisScanResult, RedisValue}; -pub async fn redis_list_databases_core(state: &AppState, connection_id: &str) -> Result, String> { +pub async fn redis_list_databases_core( + state: &AppState, + connection_id: &str, +) -> Result, String> { let connections = state.connections.read().await; let pool = connections.get(connection_id).ok_or("Connection not found")?; match pool { diff --git a/src-tauri/src/commands/redis_cmd.rs b/src-tauri/src/commands/redis_cmd.rs index d9f28223b0..b36ba7fb0b 100644 --- a/src-tauri/src/commands/redis_cmd.rs +++ b/src-tauri/src/commands/redis_cmd.rs @@ -2,10 +2,13 @@ use std::sync::Arc; use tauri::State; use crate::commands::connection::AppState; -use dbx_core::db::redis_driver::{RedisCommandResult, RedisScanResult, RedisValue}; +use dbx_core::db::redis_driver::{RedisCommandResult, RedisDatabaseInfo, RedisScanResult, RedisValue}; #[tauri::command] -pub async fn redis_list_databases(state: State<'_, Arc>, connection_id: String) -> Result, String> { +pub async fn redis_list_databases( + state: State<'_, Arc>, + connection_id: String, +) -> Result, String> { dbx_core::redis_ops::redis_list_databases_core(&state, &connection_id).await } diff --git a/src/components/redis/RedisKeyBrowser.vue b/src/components/redis/RedisKeyBrowser.vue index 323fe58e6f..3d3a031a6c 100644 --- a/src/components/redis/RedisKeyBrowser.vue +++ b/src/components/redis/RedisKeyBrowser.vue @@ -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); } diff --git a/src/composables/useDatabaseOptions.ts b/src/composables/useDatabaseOptions.ts index 3bf4f1b4cf..b0bfec1119 100644 --- a/src/composables/useDatabaseOptions.ts +++ b/src/composables/useDatabaseOptions.ts @@ -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 { diff --git a/src/lib/api.ts b/src/lib/api.ts index dd1b91369f..133113930a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -156,6 +156,7 @@ export type { AiChatMessage, AiConversation, UpdateInfo, + RedisDatabaseInfo, RedisKeyInfo, RedisValue, RedisScanResult, diff --git a/src/lib/http.ts b/src/lib/http.ts index 25e7b2b59a..3a2ce6e1ad 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -24,6 +24,7 @@ import type { AiStreamChunk, AiConversation, UpdateInfo, + RedisDatabaseInfo, RedisValue, RedisScanResult, RedisCommandResult, @@ -547,7 +548,7 @@ export async function cancelDatabaseExport(exportId: string): Promise { // Redis // --------------------------------------------------------------------------- -export async function redisListDatabases(connectionId: string): Promise { +export async function redisListDatabases(connectionId: string): Promise { return post("/api/redis/list-databases", { connectionId }); } diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index c63a037036..ad37e0f9d8 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -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 { +export async function redisListDatabases(connectionId: string): Promise { return invoke("redis_list_databases", { connectionId }); } diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index bfece434b4..82618762fd 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -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([]); 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, diff --git a/src/types/database.ts b/src/types/database.ts index 83817023cd..c59e7ad4b9 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -231,6 +231,8 @@ export interface TreeNode { schema?: string; tableName?: string; objectCount?: number; + loadedKeyCount?: number; + totalKeyCount?: number; hiddenChildren?: TreeNode[]; savedSqlId?: string; savedSqlFolderId?: string;