feat(redis): add auto-refresh countdown for key TTL display

* @
feat(redis): add auto-refresh countdown for Redis key TTL display

在 Redis 值查看器的 TTL 徽章旁增加自动刷新按钮,选中时显示 TTL 实时倒计时
(每秒递减),倒计时归零自动刷新数据,刷新按钮持续旋转。TTL 到期或网络异常
时自动关闭自动刷新避免死循环。

改动:
- RedisValueViewer.vue:新增 autoRefresh 状态、定时器与倒计时逻辑,模板增加
  Clock 切换按钮,RefreshCw 按钮支持 animate-spin 旋转
- load() 内复位倒计时;onBeforeUnmount 清理定时器

修复(review):
- #I01 TTL 到期/连接断开后自动关闭 autoRefresh 避免无限重试
- #C01 load() reject 增加 .catch() 处理 UnhandledPromiseRejection

i18n:zh-CN / zh-TW / en / ja / es / it / pt-BR 7 种语言补充 autoRefresh key
@

* @
test(redis): extract auto-refresh logic and add unit tests

将自动刷新核心决策逻辑提取为 lib/redis/redisAutoRefresh.ts 纯函数模块,
并编写 11 个单元测试覆盖状态机转换、TTL 显示计算和自动停止条件。

新增:
- lib/redis/redisAutoRefresh.ts:computeAutoRefreshTick / shouldStopAutoRefresh /
  computeDisplayTtl 纯函数
- lib/__tests__/redis/redisAutoRefresh.spec.ts:11 个测试用例

重构:
- RedisValueViewer.vue 改用提取后的纯函数,确保测试覆盖即实际运行逻辑
@
This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-07-08 15:37:27 +08:00
1 parent 73bd133886
commit a5359798dc
10 files changed
+190 -3

No files matched your search

@@ -3,7 +3,7 @@ import { computed, ref, nextTick, onBeforeUnmount, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { onClickOutside } from "@vueuse/core";
import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller";
import { Copy, Eye, Terminal, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, IndentIncrease, IndentDecrease, ArrowUp, ArrowDown, ArrowUpDown, Search } from "@lucide/vue";
import { Copy, Eye, Terminal, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, IndentIncrease, IndentDecrease, ArrowUp, ArrowDown, ArrowUpDown, Search, Clock } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -19,6 +19,7 @@ import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle
import { createRedisShikiJsonHighlighter, type RedisJsonHighlighter } from "@/lib/redis/redisJsonHighlighter";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatTtl } from "@/lib/common/ttlFormat";
import { computeAutoRefreshTick, computeDisplayTtl, shouldStopAutoRefresh } from "@/lib/redis/redisAutoRefresh";
import {
canRenderRedisValueFormat,
canEditRedisMemberDetail,
@@ -99,6 +100,62 @@ const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
const redisJsonView = ref<"raw" | "tree">("raw");
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
const redisJsonHighlighter = ref<RedisJsonHighlighter>();
// Auto-refresh
const autoRefreshEnabled = ref(false);
const countdownTtl = ref(0);
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
function toggleAutoRefresh() {
autoRefreshEnabled.value = !autoRefreshEnabled.value;
if (autoRefreshEnabled.value) {
startAutoRefresh();
} else {
stopAutoRefresh();
}
}
function startAutoRefresh() {
stopAutoRefresh();
if (data.value && data.value.ttl > 0) {
countdownTtl.value = data.value.ttl;
}
autoRefreshTimer = setInterval(() => {
const action = computeAutoRefreshTick(autoRefreshEnabled.value, countdownTtl.value, loading.value);
if (action.type === "decrement") {
countdownTtl.value--;
return;
}
if (action.type === "refresh") {
load()
.then(() => {
if (!autoRefreshEnabled.value) return;
if (data.value && !shouldStopAutoRefresh(data.value.ttl)) {
countdownTtl.value = data.value.ttl;
} else {
// Key expired or has no TTL — stop auto-refresh to avoid infinite loop
stopAutoRefresh();
autoRefreshEnabled.value = false;
}
})
.catch(() => {
// Network / connection error — stop auto-refresh to avoid tight retry loop
if (autoRefreshEnabled.value) {
stopAutoRefresh();
autoRefreshEnabled.value = false;
}
});
}
}, 1000);
}
function stopAutoRefresh() {
if (autoRefreshTimer !== null) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
const hashSortBy = ref<"field" | "value" | null>(null);
const hashSortDir = ref<"asc" | "desc">("asc");
const hashSearchQuery = ref("");
@@ -432,6 +489,10 @@ async function load(options: { selectDefaultMember?: boolean } = {}) {
try {
const loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw);
data.value = loadedValue;
// Reset auto-refresh countdown if active
if (autoRefreshEnabled.value) {
countdownTtl.value = shouldStopAutoRefresh(loadedValue.ttl) ? 0 : loadedValue.ttl;
}
emit("loaded", loadedValue);
scanCursor.value = redisValueCollectionScanCursor(loadedValue);
collectionItems.value = redisValueCollectionItems(loadedValue);
@@ -1064,6 +1125,7 @@ onMounted(() => {
});
});
onBeforeUnmount(() => {
stopAutoRefresh();
stopResizeMemberSheet();
stopResizeHashColumns();
stopResizeZsetColumns();
@@ -1082,7 +1144,7 @@ onBeforeUnmount(() => {
<div class="shrink-0 border-b bg-background">
<div class="flex h-9 items-center gap-2 px-4">
<span class="dbx-editor-font-family min-w-0 flex-1 truncate text-sm font-semibold">{{ formatValue(data.key_display) }}</span>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" @click="load"><RefreshCw class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" @click="load"><RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': autoRefreshEnabled }" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('redis.copyInsertStatement')" @click="copyInsertStatement"><Terminal class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 text-destructive" @click="requestDeleteKey"><Trash2 class="h-3.5 w-3.5" /></Button>
@@ -1092,13 +1154,16 @@ onBeforeUnmount(() => {
<Badge variant="secondary" class="dbx-editor-font-family text-xs uppercase">{{ data.redis_type }}</Badge>
<Badge v-if="metadataSizeLabel" variant="outline" class="text-xs text-muted-foreground"> {{ t("redis.columnSize") }}: {{ metadataSizeLabel }} </Badge>
<template v-if="!editingTtl">
<Badge v-if="data.ttl > 0" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent" @click="startEditTtl">TTL: {{ formatTtl(data.ttl, t) }}</Badge>
<Badge v-if="data.ttl > 0" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent" @click="startEditTtl">TTL: {{ formatTtl(computeDisplayTtl(autoRefreshEnabled, countdownTtl, data.ttl), t) }}</Badge>
<Badge v-else-if="data.ttl === -1" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent" @click="startEditTtl">{{ t("redis.noExpiry") }}</Badge>
</template>
<div ref="editTtlWrapper" v-else class="flex items-center gap-1">
<Input ref="ttlInputEl" v-model="ttlInput" class="h-6 w-20 text-xs" placeholder="seconds (-1=no expiry)" @keydown.enter="saveTtl" @keydown.escape="cancelEditTtl" />
<Button variant="ghost" size="icon" class="h-6 w-6" @click="saveTtl"><Save class="h-3 w-3" /></Button>
</div>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :class="{ 'text-primary bg-accent': autoRefreshEnabled }" :title="t('redis.autoRefresh')" @click="toggleAutoRefresh">
<Clock class="h-3.5 w-3.5" />
</Button>
</div>
</div>