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:
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>
|
||||
|
||||
|
||||
@@ -1942,6 +1942,7 @@ export default {
|
||||
loadedMembers: "{loaded} of {total} members loaded",
|
||||
entries: "{count} entries",
|
||||
noExpiry: "no expiry",
|
||||
autoRefresh: "Auto-refresh countdown",
|
||||
ttlDay: "{count}d",
|
||||
columnType: "Type",
|
||||
columnKey: "Key",
|
||||
|
||||
@@ -1874,6 +1874,7 @@ export default withEnglishFallback({
|
||||
loadedMembers: "{loaded} de {total} miembros cargados",
|
||||
entries: "{count} entradas",
|
||||
noExpiry: "sin expiración",
|
||||
autoRefresh: "Actualización automática",
|
||||
ttlDay: "{count}d",
|
||||
columnType: "Tipo",
|
||||
columnKey: "Clave",
|
||||
|
||||
@@ -1872,6 +1872,7 @@ export default withEnglishFallback({
|
||||
loadedMembers: "{loaded} di {total} membri caricati",
|
||||
entries: "{count} voci",
|
||||
noExpiry: "nessuna scadenza",
|
||||
autoRefresh: "Aggiornamento automatico",
|
||||
ttlDay: "{count}g",
|
||||
columnType: "Tipo",
|
||||
columnKey: "Chiave",
|
||||
|
||||
@@ -1872,6 +1872,7 @@ export default withEnglishFallback({
|
||||
loadedMembers: "{loaded}/{total}メンバー読み込み完了",
|
||||
entries: "{count}エントリ",
|
||||
noExpiry: "期限なし",
|
||||
autoRefresh: "自動更新(カウントダウン)",
|
||||
ttlDay: "{count}日",
|
||||
columnType: "型",
|
||||
columnKey: "キー",
|
||||
|
||||
@@ -1873,6 +1873,7 @@ export default withEnglishFallback({
|
||||
loadedMembers: "{loaded} de {total} membros carregados",
|
||||
entries: "{count} entradas",
|
||||
noExpiry: "sem expiração",
|
||||
autoRefresh: "Atualização automática",
|
||||
ttlDay: "{count}d",
|
||||
columnType: "Tipo",
|
||||
columnKey: "Chave",
|
||||
|
||||
@@ -1942,6 +1942,7 @@ export default withEnglishFallback({
|
||||
loadedMembers: "已加载 {loaded} / 共 {total} 个成员",
|
||||
entries: "{count} 条记录",
|
||||
noExpiry: "永不过期",
|
||||
autoRefresh: "自动刷新(倒计时)",
|
||||
ttlDay: "{count}天",
|
||||
columnType: "类型",
|
||||
columnKey: "键",
|
||||
|
||||
@@ -1775,6 +1775,7 @@ export default withEnglishFallback({
|
||||
loadedMembers: "已載入 {loaded} / 共 {total} 個成員",
|
||||
entries: "{count} 筆項目",
|
||||
noExpiry: "永不過期",
|
||||
autoRefresh: "自動刷新(倒計時)",
|
||||
ttlDay: "{count}天",
|
||||
columnType: "類型",
|
||||
columnKey: "鍵",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeAutoRefreshTick, computeDisplayTtl, shouldStopAutoRefresh } from "@/lib/redis/redisAutoRefresh";
|
||||
|
||||
describe("computeAutoRefreshTick", () => {
|
||||
it("returns idle when auto-refresh is disabled", () => {
|
||||
expect(computeAutoRefreshTick(false, 10, false)).toEqual({ type: "idle" });
|
||||
// countdown value and loading flag don't matter when disabled
|
||||
expect(computeAutoRefreshTick(false, 0, false)).toEqual({ type: "idle" });
|
||||
expect(computeAutoRefreshTick(false, 5, true)).toEqual({ type: "idle" });
|
||||
});
|
||||
|
||||
it("returns decrement when countdown is above zero", () => {
|
||||
expect(computeAutoRefreshTick(true, 10, false)).toEqual({ type: "decrement" });
|
||||
expect(computeAutoRefreshTick(true, 1, false)).toEqual({ type: "decrement" });
|
||||
// decrement even when loading — countdown should keep ticking
|
||||
expect(computeAutoRefreshTick(true, 3, true)).toEqual({ type: "decrement" });
|
||||
});
|
||||
|
||||
it("returns refresh when countdown reaches zero and not loading", () => {
|
||||
expect(computeAutoRefreshTick(true, 0, false)).toEqual({ type: "refresh" });
|
||||
expect(computeAutoRefreshTick(true, -1, false)).toEqual({ type: "refresh" });
|
||||
});
|
||||
|
||||
it("returns idle when countdown is zero but a load is already in flight", () => {
|
||||
// This prevents concurrent load() calls
|
||||
expect(computeAutoRefreshTick(true, 0, true)).toEqual({ type: "idle" });
|
||||
expect(computeAutoRefreshTick(true, -1, true)).toEqual({ type: "idle" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldStopAutoRefresh", () => {
|
||||
it("returns true when TTL is zero (key expired)", () => {
|
||||
expect(shouldStopAutoRefresh(0)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when TTL is negative (no expiry or key deleted)", () => {
|
||||
expect(shouldStopAutoRefresh(-1)).toBe(true);
|
||||
expect(shouldStopAutoRefresh(-2)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when TTL is positive (key still alive)", () => {
|
||||
expect(shouldStopAutoRefresh(1)).toBe(false);
|
||||
expect(shouldStopAutoRefresh(3600)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDisplayTtl", () => {
|
||||
it("returns server TTL when auto-refresh is disabled", () => {
|
||||
expect(computeDisplayTtl(false, 3, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it("returns server TTL when countdown has not started (zero)", () => {
|
||||
// countdownTtl starts at 0 before first tick; show server TTL
|
||||
expect(computeDisplayTtl(true, 0, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it("returns live countdown when auto-refresh is active and counting", () => {
|
||||
expect(computeDisplayTtl(true, 5, 10)).toBe(5);
|
||||
expect(computeDisplayTtl(true, 1, 10)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns server TTL when countdown would go below zero", () => {
|
||||
// Edge case: countdownTtl shouldn't be negative in practice,
|
||||
// but if it is, fall back to server TTL
|
||||
expect(computeDisplayTtl(true, -1, 10)).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Pure decision functions for Redis key auto-refresh countdown.
|
||||
*
|
||||
* Extracted from RedisValueViewer.vue to enable unit testing of the
|
||||
* state-machine logic without mounting a Vue component.
|
||||
*/
|
||||
|
||||
/** Action computed after evaluating one auto-refresh tick. */
|
||||
export type AutoRefreshTickAction = { type: "idle" } | { type: "decrement" } | { type: "refresh" };
|
||||
|
||||
/**
|
||||
* Evaluate one tick of the auto-refresh interval.
|
||||
*
|
||||
* @param enabled Whether auto-refresh is currently toggled on.
|
||||
* @param countdownTtl Current countdown value in seconds.
|
||||
* @param isLoading Whether a data load is already in flight.
|
||||
* @returns The action the caller should take this tick.
|
||||
*/
|
||||
export function computeAutoRefreshTick(enabled: boolean, countdownTtl: number, isLoading: boolean): AutoRefreshTickAction {
|
||||
if (!enabled) return { type: "idle" };
|
||||
if (countdownTtl > 0) return { type: "decrement" };
|
||||
if (countdownTtl <= 0 && !isLoading) return { type: "refresh" };
|
||||
return { type: "idle" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether auto-refresh should be automatically disabled
|
||||
* after a server load completes (e.g. key expired or deleted).
|
||||
*
|
||||
* @param ttl The TTL value returned by the server (seconds, -1 = no expiry).
|
||||
*/
|
||||
export function shouldStopAutoRefresh(ttl: number): boolean {
|
||||
return ttl <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the TTL value that should be displayed in the badge.
|
||||
*
|
||||
* When auto-refresh is active and countdown is running, the live
|
||||
* countdown value is shown. Otherwise the last known server TTL is used.
|
||||
*/
|
||||
export function computeDisplayTtl(autoRefreshEnabled: boolean, countdownTtl: number, serverTtl: number): number {
|
||||
if (autoRefreshEnabled && countdownTtl > 0) {
|
||||
return countdownTtl;
|
||||
}
|
||||
return serverTtl;
|
||||
}
|
||||
Reference in new issue
Block a user