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:04:48 +08:00
parent 753b72ebe4
commit 5c546f68af
9 changed files with 137 additions and 40 deletions
+38 -4
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>
+16 -4
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>
+3
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",
+3
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} 个成员",
+7 -2
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> {
+32 -2
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,
+2
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;