feat(redis): implement pagination for Redis key scanning and enhance UI for loading more keys
This commit is contained in:
parent
753b72ebe4
commit
5c546f68af
|
|
@ -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()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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} 个成员",
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ export interface TreeNode {
|
|||
children?: TreeNode[];
|
||||
isLoading?: boolean;
|
||||
isExpanded?: boolean;
|
||||
scanCursor?: number;
|
||||
hasMore?: boolean;
|
||||
connectionId?: string;
|
||||
database?: string;
|
||||
schema?: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue