diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 746becd1e8..b8be04618e 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -664,6 +664,132 @@ describe("RedisKeyBrowser scope changes", () => { }); }); +describe("RedisKeyBrowser TTL list badges and no-expiry filter", () => { + it("renders TTL badges per row and filters rows to keys without expiry", async () => { + mocks.redisScanKeysBatch.mockResolvedValue({ + cursor: 0, + keys: [ + { + key_display: "session:a", + key_raw: "c2Vzc2lvbjph", + key_type: "string", + ttl: -1, + }, + { + key_display: "cache:b", + key_raw: "Y2FjaGU6Yg==", + key_type: "string", + ttl: 3600, + }, + ], + total_keys: 2, + }); + mountBrowser(); + await settle(); + // 初始 "*" 浏览是树模式且分组默认折叠;切到 key 搜索后走平铺行,leaf 直接可见 + await submitKeySearch("session"); + + expect(document.body.textContent).toContain("session:a"); + // 永不过期(TTL = -1)显示为本地化的 redis.noExpiry 文案, + // 剩余 3600 秒显示为加载时刻快照 redis.ttlHour(测试未配置 i18n 文案时回退为 key) + expect(document.body.textContent).toContain("redis.noExpiry"); + expect(document.body.textContent).toContain("redis.ttlHour"); + + requiredElement("[data-redis-no-expiry-filter]").click(); + await settle(); + + expect(document.body.textContent).toContain("session:a"); + expect(document.body.textContent).not.toContain("cache:b"); + }); + + it("shows an empty hint when no loaded key is without expiry", async () => { + mocks.redisScanKeysBatch.mockResolvedValue({ + cursor: 0, + keys: [ + { + key_display: "cache:b", + key_raw: "Y2FjaGU6Yg==", + key_type: "string", + ttl: 60, + }, + ], + total_keys: 1, + }); + mountBrowser(); + await settle(); + await submitKeySearch("cache"); + + requiredElement("[data-redis-no-expiry-filter]").click(); + await settle(); + + expect(document.body.textContent).toContain("redis.noExpiryKeysEmpty"); + }); + + it("counts down the list TTL locally without extra network requests", async () => { + vi.useFakeTimers(); + try { + mocks.redisScanKeysBatch.mockResolvedValue({ + cursor: 0, + keys: [ + { + key_display: "cache:b", + key_raw: "Y2FjaGU6Yg==", + key_type: "string", + ttl: 60, + }, + ], + total_keys: 1, + }); + mountBrowser(); + await settle(); + await submitKeySearch("cache"); + + // 刚加载时流逝为 0,60 秒只展示分钟单位(未配置文案时回退为 key) + expect(document.body.textContent).toContain("redis.ttlMinute"); + + const scanCalls = mocks.redisScanKeysBatch.mock.calls.length; + await vi.advanceTimersByTimeAsync(5000); + await settle(); + + // 倒计时是纯本地计算,不发额外请求;55 秒只展示秒单位 + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(scanCalls); + expect(document.body.textContent).toContain("redis.ttlSecond"); + expect(document.body.textContent).not.toContain("redis.ttlMinute"); + } finally { + vi.useRealTimers(); + } + }); + + it("shows an expired badge when the local countdown reaches zero", async () => { + vi.useFakeTimers(); + try { + mocks.redisScanKeysBatch.mockResolvedValue({ + cursor: 0, + keys: [ + { + key_display: "cache:b", + key_raw: "Y2FjaGU6Yg==", + key_type: "string", + ttl: 1, + }, + ], + total_keys: 1, + }); + mountBrowser(); + await settle(); + await submitKeySearch("cache"); + + await vi.advanceTimersByTimeAsync(2000); + await settle(); + + // 倒计时归零后展示已过期文案,而不是停留在旧快照 + expect(document.body.textContent).toContain("redis.expired"); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("RedisKeyBrowser command completion", () => { it("uses the connected server's module command docs and accepts the selection with Tab", async () => { mountBrowser(); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index d73ab0859f..2132599119 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -29,6 +29,7 @@ import { useSettingsStore } from "@/stores/settingsStore"; import { continuousQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit"; import { appendRedisKeysToTreeIndex, + buildRedisKeyTree, canBuildRedisFuzzyTree, collectExpandedGroupIds, collectRedisGroupKeyRaws, @@ -57,6 +58,8 @@ import { getRedisCreateKeyTypeHelp, redisCreateKeyTypeHelpOptionOnOpen, shouldAc import { optionHelpPanelOffsetTop } from "@/lib/common/optionHelpPanelOffset"; import { applyRedisExpiryPolicy, type RedisExpiryMode, validateRedisExpiry } from "@/lib/redis/redisExpiry"; import { shouldLoadMoreRedisKeys } from "@/lib/redis/redisKeyInfiniteScroll"; +import { formatTtl } from "@/lib/common/ttlFormat"; +import { computeTtlCountdownValue } from "@/lib/redis/redisAutoRefresh"; const { t, locale } = useI18n(); const { toast } = useToast(); @@ -229,7 +232,23 @@ watch(redisKeySeparator, () => { rebuildTree(false); }); const lastTotalKeys = ref(0); -const displayedKeyCount = computed(() => (isFetchingAll.value ? fetchAllLoadedCount.value : flatKeys.value.length)); +// “仅看无过期”过滤开关:开启后只保留 TTL 为 -1(永不过期)的已加载 key。 +// TTL 为 -2 的行(fetch-all 链路未查询 TTL)不会出现在过滤结果里。 +const noExpiryOnly = ref(false); +// 过滤后的平铺 key 列表:未开启过滤时与 flatKeys 完全一致,避免额外开销 +const filteredFlatKeys = computed(() => (noExpiryOnly.value ? flatKeys.value.filter((key) => key.ttl === -1) : flatKeys.value)); +// 过滤后的树:独立重建而不复用 treeIndex,避免污染后续 SCAN 增量合并的全量树基准; +// 分组 id 只由 db+路径决定,与全量树一致,因此展开状态可直接复用 +const filteredTreeKeys = computed(() => { + if (!noExpiryOnly.value) return treeKeys.value; + return buildRedisKeyTree(filteredFlatKeys.value, props.db, redisKeySeparator.value); +}); +const displayedKeyCount = computed(() => { + if (isFetchingAll.value) return fetchAllLoadedCount.value; + // 过滤时展示匹配数量,便于确认“无过期”key 的规模 + if (noExpiryOnly.value) return filteredFlatKeys.value.length; + return flatKeys.value.length; +}); const fetchAllProgressText = computed(() => { if (!isFetchingAll.value) return ""; if (lastTotalKeys.value > 0) { @@ -335,7 +354,62 @@ async function updateCreateKeyTypeHelpOffset() { watch(activeCreateKeyTypeHelp, () => { void updateCreateKeyTypeHelpOffset(); }); -const visibleRows = computed(() => (useFlatKeySearchRows.value ? flatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(treeKeys.value, expandedGroupIds.value))); +const visibleRows = computed(() => { + return useFlatKeySearchRows.value ? filteredFlatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(filteredTreeKeys.value, expandedGroupIds.value); +}); +// 列表行的 TTL 徽标文案:-1 表示永不过期,展示本地化文案; +// 大于 0 时展示本地倒计时后的剩余时间,倒计时归零展示已过期;其余(-2 未查询)不显示 +function redisTtlBadgeText(ttl: number, displayTtl: number): string | null { + if (ttl === -1) return t("redis.noExpiry"); + if (ttl > 0 && displayTtl <= 0) return t("redis.expired"); + return formatTtl(displayTtl, t); +} +// 列表行的 TTL 徽标配色:永不过期用琥珀色,已过期或 1 小时内即将过期用红色警示,其余用中性色 +function redisTtlBadgeClass(ttl: number, displayTtl: number): string { + if (ttl === -1) return "border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"; + if (displayTtl <= 3600) return "border-red-300 bg-red-50 text-red-600 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-300"; + return "border-border bg-muted/60 text-muted-foreground"; +} +// 记录每个 key 的 TTL 被观测到的时刻(毫秒);本地倒计时 = 观测时的 TTL - 已流逝时间, +// 与右侧详情面板同源(computeTtlCountdownValue),不需要额外的网络请求 +const ttlObservedAtByRaw = new Map(); +// 驱动列表行 TTL 倒计时的当前时刻,仅在存在需要倒计时的 key 时每秒更新 +const listTtlNowMs = ref(Date.now()); +let listTtlTimer: ReturnType | null = null; + +// 批次加载/详情回写等入口统一记录 key 的 TTL 观测时刻;非正 TTL 无需倒计时,移除旧记录 +function recordKeyTtlObservedAt(key: RedisKeyInfo) { + const ttl = key.ttl ?? -2; + if (ttl > 0) { + ttlObservedAtByRaw.set(key.key_raw, Date.now()); + } else { + ttlObservedAtByRaw.delete(key.key_raw); + } +} + +// 列表行展示的 TTL:正 TTL 按观测时刻到当前时刻的流逝本地递减;-1/-2 原样透传 +function redisRowDisplayTtl(ttl: number, keyRaw: string): number { + if (ttl <= 0) return ttl; + const observedAt = ttlObservedAtByRaw.get(keyRaw) ?? Date.now(); + return computeTtlCountdownValue(ttl, observedAt, listTtlNowMs.value); +} + +// 按需启停倒计时定时器:只在组件激活且存在正 TTL 的 key 时运行,避免空转 +function syncListTtlTimer() { + const needed = redisBrowserIsActive && flatKeys.value.some((key) => (key.ttl ?? -2) > 0); + if (needed && !listTtlTimer) { + listTtlNowMs.value = Date.now(); + listTtlTimer = setInterval(() => { + listTtlNowMs.value = Date.now(); + }, 1000); + } else if (!needed && listTtlTimer) { + clearInterval(listTtlTimer); + listTtlTimer = null; + } +} + +// flatKeys 的每次变更都是整体替换数组,浅监听即可感知增删改 +watch(flatKeys, syncListTtlTimer); let commandHistoryId = 0; function resetCheckedKeys() { @@ -597,6 +671,8 @@ async function fetchScanBatchPage(maxIterations: number, options: { count?: numb function appendScanResult(result: RedisScanResult, options: { updateTree?: boolean; buffer?: RedisKeyInfo[] } = {}): number { const newKeys = collectUniqueRedisKeys(result.keys, loadedKeyRaws); + // 批次到达前端即为 TTL 的观测时刻,直连合并与 Fetch All 缓冲两条路径在此统一记录 + for (const key of newKeys) recordKeyTtlObservedAt(key); if (options.buffer) { for (const key of newKeys) options.buffer.push(key); } else if (newKeys.length > 0) { @@ -658,6 +734,7 @@ async function loadKeys() { fetchAllLoadedCount.value = 0; loading.value = true; loadedKeyRaws.clear(); + ttlObservedAtByRaw.clear(); flatKeys.value = []; treeKeys.value = []; treeIndex = null; @@ -856,6 +933,7 @@ function onRowClick(node: RedisKeyTreeNode, event?: MouseEvent) { function removeKnownKey(keyRaw: string) { if (!flatKeys.value.some((key) => key.key_raw === keyRaw)) return; loadedKeyRaws.delete(keyRaw); + ttlObservedAtByRaw.delete(keyRaw); flatKeys.value = flatKeys.value.filter((key) => key.key_raw !== keyRaw); if (selectedKeyRaw.value === keyRaw) selectedKeyRaw.value = null; if (useFlatKeySearchRows.value) { @@ -890,6 +968,10 @@ function onKeyRenamed(oldKeyRaw: string, newKeyRaw: string, newKeyDisplay: strin loadedKeyRaws.delete(oldKeyRaw); loadedKeyRaws.add(newKeyRaw); + // 改名不换 TTL,观测时刻随 key 一起迁移,倒计时不中断 + const observedAt = ttlObservedAtByRaw.get(oldKeyRaw); + ttlObservedAtByRaw.delete(oldKeyRaw); + if (observedAt !== undefined) ttlObservedAtByRaw.set(newKeyRaw, observedAt); flatKeys.value = flatKeys.value.map((key) => (key.key_raw === oldKeyRaw ? { ...key, key_raw: newKeyRaw, key_display: newKeyDisplay } : key)); if (selectedKeyRaw.value === oldKeyRaw) selectedKeyRaw.value = newKeyRaw; if (checkedKeys.value.has(oldKeyRaw)) { @@ -926,6 +1008,8 @@ function onKeyLoaded(value: RedisValue) { const keyInfo = redisValueToKeyInfo(value); const existingIndex = flatKeys.value.findIndex((key) => key.key_raw === keyInfo.key_raw); if (existingIndex < 0) return; + // 详情面板回写了最新的 TTL,同步刷新观测时刻,保证两侧倒计时一致 + recordKeyTtlObservedAt(keyInfo); flatKeys.value = flatKeys.value.map((key, index) => (index === existingIndex ? keyInfo : key)); loadedKeyRaws.add(keyInfo.key_raw); if (useFlatKeySearchRows.value) { @@ -995,6 +1079,7 @@ function resetLoadedKeys() { fetchAllStopRequested.value = false; fetchAllLoadedCount.value = 0; loadedKeyRaws.clear(); + ttlObservedAtByRaw.clear(); flatKeys.value = []; treeKeys.value = []; treeIndex = null; @@ -1019,7 +1104,10 @@ async function deleteKeyRaws(keys: string[]) { deletedCount += await api.redisDeleteKeys(props.connectionId, props.db, batch); } const deleted = new Set(uniqueKeys); - for (const key of deleted) loadedKeyRaws.delete(key); + for (const key of deleted) { + loadedKeyRaws.delete(key); + ttlObservedAtByRaw.delete(key); + } flatKeys.value = flatKeys.value.filter((key) => !deleted.has(key.key_raw)); if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) { selectedKeyRaw.value = null; @@ -1245,6 +1333,8 @@ function upsertCreatedKey(value: RedisValue) { value_preview: redisValuePreview(value), }; const existingIndex = flatKeys.value.findIndex((key) => key.key_raw === keyInfo.key_raw); + // 新建 key 携带的 TTL 以当前时刻为观测起点 + recordKeyTtlObservedAt(keyInfo); if (existingIndex >= 0) { flatKeys.value = flatKeys.value.map((key, index) => (index === existingIndex ? keyInfo : key)); } else { @@ -1764,6 +1854,8 @@ function pauseRedisBrowserBackgroundWork() { // keys that were never rendered. const discardIncompleteFetchAll = isFetchingAll.value; redisBrowserIsActive = false; + // 组件停用/卸载后不再展示列表,停掉 TTL 倒计时定时器 + syncListTtlTimer(); // 与 onUnmounted 对称:组件被 keep-alive 包裹,停用时(onDeactivated)若不取消挂起的 rAF, // 帧回调仍会在隐藏组件上触发并调用 loadMore() 跑一次冗余 SCAN,故在此一并取消并置 0。 if (redisInfiniteScrollFrame) cancelAnimationFrame(redisInfiniteScrollFrame); @@ -1783,6 +1875,8 @@ function pauseRedisBrowserBackgroundWork() { function resumeRedisBrowserBackgroundWork() { redisBrowserIsActive = true; registerRedisDbFlushedListener(); + // 重新激活后恢复 TTL 倒计时定时器 + syncListTtlTimer(); } async function clearInMemoryHistory() { @@ -1985,19 +2079,35 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); @keydown="onSearchKeydown" /> - +
+ + + +
@@ -2017,6 +2127,10 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); {{ loadingEmptyText }} + +
+ {{ t("redis.noExpiryKeysEmpty") }} +