feat(redis): implement pagination for Redis key scanning and enhance UI for loading more keys

This commit is contained in:
t8y2 2026-04-30 13:01:10 +08:00
parent 753b72ebe4
commit 5c546f68af
9 changed files with 137 additions and 40 deletions

View File

@ -2,7 +2,7 @@ use std::sync::Arc;
use tauri::State;
use crate::commands::connection::{AppState, PoolKind};
use crate::db::redis_driver::{self, RedisKeyInfo, RedisValue};
use crate::db::redis_driver::{self, RedisScanResult, RedisValue};
#[tauri::command]
pub async fn redis_list_databases(
@ -25,16 +25,17 @@ pub async fn redis_scan_keys(
state: State<'_, Arc<AppState>>,
connection_id: String,
db: u32,
cursor: u64,
pattern: String,
count: usize,
) -> Result<Vec<RedisKeyInfo>, String> {
) -> Result<RedisScanResult, String> {
let connections = state.connections.lock().await;
let pool = connections.get(&connection_id).ok_or("Connection not found")?;
match pool {
PoolKind::Redis(con) => {
let mut con = con.lock().await;
redis_driver::select_db(&mut con, db).await?;
redis_driver::scan_keys(&mut con, &pattern, count).await
redis_driver::scan_keys_page(&mut con, cursor, &pattern, count).await
}
_ => Err("Not a Redis connection".to_string()),
}

View File

@ -8,6 +8,12 @@ pub struct RedisKeyInfo {
pub ttl: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedisScanResult {
pub cursor: u64,
pub keys: Vec<RedisKeyInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedisValue {
pub key: String,
@ -18,13 +24,20 @@ pub struct RedisValue {
pub async fn connect(url: &str) -> Result<redis::aio::MultiplexedConnection, String> {
let client = redis::Client::open(url).map_err(|e| format!("Redis connection failed: {e}"))?;
tokio::time::timeout(
let mut con = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.get_multiplexed_async_connection(),
)
.await
.map_err(|_| "Redis connection timed out (10s)".to_string())?
.map_err(|e| format!("Redis connection failed: {e}"))
.map_err(|e| format!("Redis connection failed: {e}"))?;
redis::cmd("PING")
.query_async::<String>(&mut con)
.await
.map_err(|e| format!("Redis authentication failed or command rejected: {e}"))?;
Ok(con)
}
pub async fn list_databases(con: &mut redis::aio::MultiplexedConnection) -> Result<Vec<u32>, String> {
@ -58,33 +71,24 @@ pub async fn select_db(con: &mut redis::aio::MultiplexedConnection, db: u32) ->
.map_err(|e| e.to_string())
}
pub async fn scan_keys(
pub async fn scan_keys_page(
con: &mut redis::aio::MultiplexedConnection,
cursor: u64,
pattern: &str,
count: usize,
) -> Result<Vec<RedisKeyInfo>, String> {
let mut cursor: u64 = 0;
let mut all_keys: Vec<String> = Vec::new();
loop {
let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(100)
.query_async(con)
.await
.map_err(|e| e.to_string())?;
all_keys.extend(keys);
cursor = new_cursor;
if cursor == 0 || all_keys.len() >= count {
break;
}
}
all_keys.truncate(count);
) -> Result<RedisScanResult, String> {
let (next_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(count)
.query_async(con)
.await
.map_err(|e| e.to_string())?;
let mut result = Vec::new();
for key in &all_keys {
for key in &keys {
let key_type: String = redis::cmd("TYPE")
.arg(key.as_str())
.query_async(con)
@ -99,7 +103,10 @@ pub async fn scan_keys(
ttl,
});
}
Ok(result)
Ok(RedisScanResult {
cursor: next_cursor,
keys: result,
})
}
pub async fn get_value(

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Search, RefreshCw, Key } from "lucide-vue-next";
import { Search, RefreshCw, Key, Loader2 } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@ -22,11 +22,34 @@ const keys = ref<RedisKeyInfo[]>([]);
const loading = ref(false);
const searchPattern = ref("*");
const selectedKey = ref<string | null>(null);
const cursor = ref(0);
const hasMore = ref(false);
const PAGE_SIZE = 200;
async function loadKeys() {
loading.value = true;
try {
keys.value = await api.redisScanKeys(props.connectionId, props.db, searchPattern.value, 1000);
const result = await api.redisScanKeys(props.connectionId, props.db, 0, searchPattern.value, PAGE_SIZE);
keys.value = result.keys;
cursor.value = result.cursor;
hasMore.value = result.cursor !== 0;
selectedKey.value = null;
} finally {
loading.value = false;
}
}
async function loadMoreKeys() {
if (loading.value || !hasMore.value) return;
loading.value = true;
try {
const result = await api.redisScanKeys(props.connectionId, props.db, cursor.value, searchPattern.value, PAGE_SIZE);
const existingKeys = new Set(keys.value.map((k) => k.key));
keys.value = [...keys.value, ...result.keys.filter((k) => !existingKeys.has(k.key))];
cursor.value = result.cursor;
hasMore.value = result.cursor !== 0;
} finally {
loading.value = false;
}
@ -72,13 +95,14 @@ onMounted(loadKeys);
@keydown.enter="loadKeys"
/>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="loadKeys">
<RefreshCw class="h-3 w-3" />
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
<RefreshCw v-else class="h-3 w-3" />
</Button>
</div>
<!-- Key count -->
<div class="px-3 py-1 text-xs text-muted-foreground border-b shrink-0">
{{ t('redis.keys', { count: keys.length }) }}
{{ loading && keys.length === 0 ? t('redis.loadingKeys') : t('redis.keys', { count: keys.length }) }}
</div>
<!-- Key list -->
@ -97,6 +121,16 @@ onMounted(loadKeys);
<div v-if="keys.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ t('redis.noKeys') }}
</div>
<div v-if="loading && keys.length === 0" class="px-3 py-8 flex items-center justify-center gap-2 text-muted-foreground text-xs">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>{{ t('redis.loadingKeys') }}</span>
</div>
<div v-if="hasMore || (loading && keys.length > 0)" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loading" @click="loadMoreKeys">
<Loader2 v-if="loading" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t('redis.loadMoreKeys') }}
</Button>
</div>
</div>
</div>
</Pane>

View File

@ -101,6 +101,7 @@ async function toggle() {
} else if (node.type === "redis-db" && node.connectionId && node.database) {
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "Redis"}:db${node.database}`;
queryStore.createTab(node.connectionId, node.database, tabTitle, "redis");
await connectionStore.loadRedisKeys(node.connectionId, Number(node.database), node.id);
} else if (node.type === "mongo-db" && node.connectionId && node.database) {
await connectionStore.loadMongoCollections(node.connectionId, node.database);
} else if (node.type === "mongo-collection" && node.connectionId && node.database) {
@ -179,6 +180,8 @@ async function refresh() {
const node = props.node;
node.isExpanded = false;
node.children = [];
node.scanCursor = undefined;
node.hasMore = undefined;
await toggle();
}
@ -229,15 +232,23 @@ const visibleChildren = computed(() => {
});
const hasMoreChildren = computed(() =>
(props.node.children?.length ?? 0) > displayLimit.value
(props.node.children?.length ?? 0) > displayLimit.value || !!props.node.hasMore
);
const remainingCount = computed(() =>
(props.node.children?.length ?? 0) - displayLimit.value
);
function showMore() {
displayLimit.value += CHILDREN_PAGE_SIZE;
async function showMore() {
if ((props.node.children?.length ?? 0) > displayLimit.value) {
displayLimit.value += CHILDREN_PAGE_SIZE;
return;
}
if (props.node.type === "redis-db" && props.node.connectionId && props.node.database && props.node.hasMore) {
await connectionStore.loadMoreRedisKeys(props.node.connectionId, Number(props.node.database), props.node.id);
displayLimit.value += CHILDREN_PAGE_SIZE;
}
}
</script>
@ -269,7 +280,8 @@ function showMore() {
:style="{ paddingLeft: `${(depth + 1) * 16 + 8}px` }"
@click="showMore"
>
<span>{{ t('sidebar.showMore', { count: Math.min(CHILDREN_PAGE_SIZE, remainingCount) }) }}</span>
<Loader2 v-if="node.isLoading" class="w-3 h-3 shrink-0 animate-spin" />
<span>{{ node.hasMore && remainingCount <= 0 ? t('sidebar.loadMore') : t('sidebar.showMore', { count: Math.min(CHILDREN_PAGE_SIZE, remainingCount) }) }}</span>
</div>
</template>
</div>

View File

@ -14,6 +14,7 @@ export default {
import: "Import Connections",
export: "Export Connections",
showMore: "Show {count} more...",
loadMore: "Load more...",
},
connection: {
title: "New Connection",
@ -155,6 +156,8 @@ export default {
noKeys: "No keys found",
pattern: "pattern (e.g. user:*)",
keys: "{count} keys",
loadingKeys: "Loading keys...",
loadMoreKeys: "Load more keys",
items: "{count} items",
fields: "{count} fields",
members: "{count} members",

View File

@ -14,6 +14,7 @@ export default {
import: "导入连接",
export: "导出连接",
showMore: "加载更多 ({count})...",
loadMore: "继续加载...",
},
connection: {
title: "新建连接",
@ -153,6 +154,8 @@ export default {
noKeys: "未找到 key",
pattern: "匹配模式 (如 user:*)",
keys: "{count} 个 key",
loadingKeys: "正在加载 key...",
loadMoreKeys: "继续加载 key",
items: "{count} 个元素",
fields: "{count} 个字段",
members: "{count} 个成员",

View File

@ -115,12 +115,17 @@ export interface RedisValue {
value: any;
}
export interface RedisScanResult {
cursor: number;
keys: RedisKeyInfo[];
}
export async function redisListDatabases(connectionId: string): Promise<number[]> {
return invoke("redis_list_databases", { connectionId });
}
export async function redisScanKeys(connectionId: string, db: number, pattern: string, count: number): Promise<RedisKeyInfo[]> {
return invoke("redis_scan_keys", { connectionId, db, pattern, count });
export async function redisScanKeys(connectionId: string, db: number, cursor: number, pattern: string, count: number): Promise<RedisScanResult> {
return invoke("redis_scan_keys", { connectionId, db, cursor, pattern, count });
}
export async function redisGetValue(connectionId: string, key: string): Promise<RedisValue> {

View File

@ -145,8 +145,10 @@ export const useConnectionStore = defineStore("connection", () => {
node.isLoading = true;
try {
const keys = await api.redisScanKeys(connectionId, db, "*", 500);
node.children = keys.map((k) => ({
const result = await api.redisScanKeys(connectionId, db, 0, "*", 200);
node.scanCursor = result.cursor;
node.hasMore = result.cursor !== 0;
node.children = result.keys.map((k) => ({
id: `${nodeId}:${k.key}`,
label: `${k.key} [${k.key_type}]${k.ttl > 0 ? ` TTL:${k.ttl}` : ""}`,
type: "redis-key" as const,
@ -160,6 +162,33 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function loadMoreRedisKeys(connectionId: string, db: number, nodeId: string) {
const node = findNode(treeNodes.value, nodeId);
if (!node || node.isLoading || !node.hasMore) return;
node.isLoading = true;
try {
const result = await api.redisScanKeys(connectionId, db, node.scanCursor ?? 0, "*", 200);
const existingIds = new Set((node.children ?? []).map((child) => child.id));
const moreChildren = result.keys
.filter((k) => !existingIds.has(`${nodeId}:${k.key}`))
.map((k) => ({
id: `${nodeId}:${k.key}`,
label: `${k.key} [${k.key_type}]${k.ttl > 0 ? ` TTL:${k.ttl}` : ""}`,
type: "redis-key" as const,
connectionId,
database: String(db),
isExpanded: false,
}));
node.scanCursor = result.cursor;
node.hasMore = result.cursor !== 0;
node.children = [...(node.children ?? []), ...moreChildren];
} finally {
node.isLoading = false;
}
}
async function loadMongoDatabases(connectionId: string) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
@ -455,6 +484,7 @@ export const useConnectionStore = defineStore("connection", () => {
loadDatabases,
loadRedisDatabases,
loadRedisKeys,
loadMoreRedisKeys,
loadMongoDatabases,
loadMongoCollections,
loadSchemas,

View File

@ -78,6 +78,8 @@ export interface TreeNode {
children?: TreeNode[];
isLoading?: boolean;
isExpanded?: boolean;
scanCursor?: number;
hasMore?: boolean;
connectionId?: string;
database?: string;
schema?: string;