feat(redis): poll full values and track TTL countdowns

This commit is contained in:
二丫讲梵 2026-07-31 23:11:48 +08:00 committed by GitHub
parent a6a072294a
commit c69f1352d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 268 additions and 169 deletions

View File

@ -94,6 +94,24 @@ function stringValue(rawBase64 = "dmFsdWU=", ttl = 60) {
};
}
function listValue(ttl = 60) {
return {
key_display: "key",
key_raw: "key",
ttl,
redis_type: "list",
data: {
kind: "list" as const,
items: [
{ index: 0, value: { raw_base64: "Zmlyc3Q=", encoding: "utf8" as const } },
{ index: 1, value: { raw_base64: "c2Vjb25k", encoding: "utf8" as const } },
],
total: 2,
scan_cursor: undefined,
},
};
}
function missingValue() {
return {
key_display: "key",
@ -221,7 +239,7 @@ async function setStringDraft(value: string) {
}
describe("RedisValueViewer expiry saving", () => {
it("defaults to manual refresh without TTL polling", async () => {
it("defaults to manual refresh without automatic value polling", async () => {
vi.useFakeTimers();
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60));
@ -229,16 +247,16 @@ describe("RedisValueViewer expiry saving", () => {
await settle();
await vi.advanceTimersByTimeAsync(10_000);
expect(mocks.redisGetValue).toHaveBeenCalledOnce();
expect(mocks.redisGetTtl).not.toHaveBeenCalled();
expect(document.querySelector<HTMLButtonElement>("[aria-label='grid.refresh']")?.getAttribute("aria-pressed")).toBe("false");
expect(document.querySelector<HTMLElement>("[data-slot='badge'][aria-label='redis.expiry']")?.textContent).toContain("00:00:50");
});
it("polls only the TTL at the configured interval", async () => {
it("polls the full value at the configured interval without refreshing the parent key tree", async () => {
vi.useFakeTimers();
localStorage.setItem("dbx-redis-auto-refresh-enabled-v2", "true");
localStorage.setItem("dbx-redis-auto-refresh-interval-seconds-v2", "5");
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60));
mocks.redisGetTtl.mockResolvedValueOnce(45);
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60)).mockResolvedValueOnce(stringValue("cmVmcmVzaGVk", 45));
const loaded = vi.fn();
mountViewer(vi.fn(), loaded);
@ -246,49 +264,76 @@ describe("RedisValueViewer expiry saving", () => {
await vi.advanceTimersByTimeAsync(5000);
await settle();
expect(mocks.redisGetTtl).toHaveBeenCalledWith("connection", 0, "key");
expect(mocks.redisGetValue).toHaveBeenCalledOnce();
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
expect(mocks.redisGetTtl).not.toHaveBeenCalled();
expect(loaded).toHaveBeenCalledOnce();
expect(document.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("refreshed");
expect(document.querySelector<HTMLElement>("[data-slot='badge'][aria-label='redis.expiry']")?.textContent).toContain("00:00:45");
});
it("pauses automatic value polling while a collection member is open", async () => {
vi.useFakeTimers();
localStorage.setItem("dbx-redis-auto-refresh-enabled-v2", "true");
localStorage.setItem("dbx-redis-auto-refresh-interval-seconds-v2", "1");
mocks.redisGetValue.mockResolvedValue(listValue());
mountViewer(vi.fn());
await settle();
Array.from(document.querySelectorAll<HTMLElement>("[data-redis-value-row]"))
.find((row) => row.textContent?.includes("second"))!
.click();
await settle();
expect(document.querySelector<HTMLElement>("[data-redis-member-detail]")?.textContent).toContain("second");
await vi.advanceTimersByTimeAsync(3000);
await settle();
expect(mocks.redisGetValue).toHaveBeenCalledOnce();
document.querySelector<HTMLButtonElement>("[data-slot='dialog-close']")!.click();
await settle();
await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
});
it("keeps polling when the loaded TTL starts at zero", async () => {
vi.useFakeTimers();
localStorage.setItem("dbx-redis-auto-refresh-enabled-v2", "true");
localStorage.setItem("dbx-redis-auto-refresh-interval-seconds-v2", "1");
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 0));
mocks.redisGetTtl.mockResolvedValueOnce(30);
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 0)).mockResolvedValueOnce(stringValue("dmFsdWU=", 30));
mountViewer(vi.fn());
await settle();
await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(mocks.redisGetTtl).toHaveBeenCalledOnce();
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
expect(document.querySelector<HTMLElement>("[data-slot='badge'][aria-label='redis.expiry']")?.textContent).toContain("00:00:30");
});
it("shows auto-refresh as stopped after a TTL polling error", async () => {
it("stops auto-refresh after a full-value polling error", async () => {
vi.useFakeTimers();
localStorage.setItem("dbx-redis-auto-refresh-enabled-v2", "true");
localStorage.setItem("dbx-redis-auto-refresh-interval-seconds-v2", "1");
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60));
mocks.redisGetTtl.mockRejectedValueOnce(new Error("network unavailable"));
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60)).mockRejectedValueOnce(new Error("network unavailable"));
mountViewer(vi.fn());
await settle();
await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(document.querySelector<HTMLButtonElement>("[aria-label='grid.refresh']")?.getAttribute("aria-pressed")).toBe("false");
await vi.advanceTimersByTimeAsync(5_000);
await settle();
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
});
it("keeps polling after an external PERSIST and picks up a later TTL", async () => {
vi.useFakeTimers();
localStorage.setItem("dbx-redis-auto-refresh-enabled-v2", "true");
localStorage.setItem("dbx-redis-auto-refresh-interval-seconds-v2", "1");
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60));
mocks.redisGetTtl.mockResolvedValueOnce(-1).mockResolvedValueOnce(30);
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60)).mockResolvedValueOnce(stringValue("dmFsdWU=", -1)).mockResolvedValueOnce(stringValue("dmFsdWU=", 30));
mountViewer(vi.fn());
await settle();
@ -299,8 +344,7 @@ describe("RedisValueViewer expiry saving", () => {
await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(mocks.redisGetTtl).toHaveBeenCalledTimes(2);
expect(document.querySelector<HTMLButtonElement>("[data-redis-value-refresh]")?.getAttribute("aria-pressed")).toBe("true");
expect(mocks.redisGetValue).toHaveBeenCalledTimes(3);
expect(document.querySelector<HTMLElement>("[data-slot='badge'][aria-label='redis.expiry']")?.textContent).toContain("00:00:30");
});
@ -308,8 +352,7 @@ describe("RedisValueViewer expiry saving", () => {
vi.useFakeTimers();
localStorage.setItem("dbx-redis-auto-refresh-enabled-v2", "true");
localStorage.setItem("dbx-redis-auto-refresh-interval-seconds-v2", "1");
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60));
mocks.redisGetTtl.mockResolvedValue(45);
mocks.redisGetValue.mockResolvedValue(stringValue("dmFsdWU=", 45));
let visibilityState: DocumentVisibilityState = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibilityState);
@ -319,38 +362,57 @@ describe("RedisValueViewer expiry saving", () => {
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5000);
expect(mocks.redisGetTtl).not.toHaveBeenCalled();
expect(mocks.redisGetValue).toHaveBeenCalledOnce();
visibilityState = "visible";
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(mocks.redisGetTtl).toHaveBeenCalledOnce();
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
});
it("keeps elapsed TTL time while manual refresh is hidden", async () => {
vi.useFakeTimers();
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60));
let visibilityState: DocumentVisibilityState = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibilityState);
mountViewer(vi.fn());
await settle();
await vi.advanceTimersByTimeAsync(10_000);
visibilityState = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(20_000);
visibilityState = "visible";
document.dispatchEvent(new Event("visibilitychange"));
await settle();
expect(document.querySelector<HTMLElement>("[data-slot='badge'][aria-label='redis.expiry']")?.textContent).toContain("00:00:30");
});
it("pauses polling while deactivated and resumes from the saved setting", async () => {
vi.useFakeTimers();
localStorage.setItem("dbx-redis-auto-refresh-enabled-v2", "true");
localStorage.setItem("dbx-redis-auto-refresh-interval-seconds-v2", "1");
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60));
mocks.redisGetTtl.mockResolvedValue(45);
mocks.redisGetValue.mockResolvedValue(stringValue("dmFsdWU=", 45));
const viewer = mountKeepAliveViewer();
await settle();
await viewer.setActive(false);
await vi.advanceTimersByTimeAsync(5000);
expect(mocks.redisGetTtl).not.toHaveBeenCalled();
expect(mocks.redisGetValue).toHaveBeenCalledOnce();
await viewer.setActive(true);
await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(mocks.redisGetTtl).toHaveBeenCalledOnce();
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
});
it("pauses TTL polling while a value draft is unsaved", async () => {
it("pauses value polling while a value draft is unsaved", async () => {
vi.useFakeTimers();
localStorage.setItem("dbx-redis-auto-refresh-enabled-v2", "true");
localStorage.setItem("dbx-redis-auto-refresh-interval-seconds-v2", "1");
@ -363,33 +425,33 @@ describe("RedisValueViewer expiry saving", () => {
await vi.advanceTimersByTimeAsync(5000);
await settle();
expect(mocks.redisGetTtl).not.toHaveBeenCalled();
expect(mocks.redisGetValue).toHaveBeenCalledOnce();
expect(deleted).not.toHaveBeenCalled();
expect(document.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("draft");
});
it("ignores a missing-key response when a draft is created during TTL polling", async () => {
it("ignores a missing-key response when a draft is created during value polling", async () => {
vi.useFakeTimers();
localStorage.setItem("dbx-redis-auto-refresh-enabled-v2", "true");
localStorage.setItem("dbx-redis-auto-refresh-interval-seconds-v2", "1");
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60));
const ttlRequest = deferred<number>();
mocks.redisGetTtl.mockReturnValueOnce(ttlRequest.promise);
const valueRequest = deferred<ReturnType<typeof missingValue>>();
mocks.redisGetValue.mockReturnValueOnce(valueRequest.promise);
const deleted = vi.fn();
mountViewer(deleted);
await settle();
await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(mocks.redisGetTtl).toHaveBeenCalledOnce();
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
await setStringDraft("draft");
ttlRequest.resolve(-2);
valueRequest.resolve(missingValue());
await settle();
await vi.advanceTimersByTimeAsync(5000);
await settle();
expect(mocks.redisGetTtl).toHaveBeenCalledOnce();
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
expect(deleted).not.toHaveBeenCalled();
expect(document.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("draft");
});

View File

@ -162,10 +162,6 @@ function openGroups(host: HTMLElement) {
async function refreshValue(host: HTMLElement) {
host.querySelector<HTMLButtonElement>("[data-redis-value-refresh]")!.click();
await settle();
const refreshItem = document.querySelector<HTMLElement>("[data-slot='dropdown-menu-content'] [data-slot='dropdown-menu-item']");
expect(refreshItem).not.toBeNull();
refreshItem!.click();
await settle();
}
describe("RedisValueViewer stream monitoring", () => {

View File

@ -4,7 +4,7 @@ import type { CalendarDateTime } from "@internationalized/date";
import { useI18n } from "vue-i18n";
import { onClickOutside } from "@vueuse/core";
import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller";
import { Check, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search } from "@lucide/vue";
import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@ -25,7 +25,7 @@ import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle
import { createShikiJsonHighlighter, type JsonHighlighter } from "@/lib/common/shikiJsonHighlighter";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatTtl } from "@/lib/common/ttlFormat";
import { computeAutoRefreshTick, computeDisplayTtl, computeTtlForExpiryEdit, DEFAULT_REDIS_AUTO_REFRESH_INTERVAL_SECONDS, normalizeRedisAutoRefreshInterval } from "@/lib/redis/redisAutoRefresh";
import { computeDisplayTtl, computeTtlCountdownTick, computeTtlCountdownValue, computeTtlForExpiryEdit, DEFAULT_REDIS_AUTO_REFRESH_INTERVAL_SECONDS, normalizeRedisAutoRefreshInterval } from "@/lib/redis/redisAutoRefresh";
import {
canRenderRedisValueFormat,
canEditRedisMemberDetail,
@ -164,16 +164,17 @@ const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
const redisJsonHighlighter = ref<JsonHighlighter>();
// Auto-refresh keeps the displayed TTL moving locally and periodically asks
// Redis for the authoritative value. The two timers stay separate so a short
// polling interval never causes a full key-value reload.
// Auto-refresh keeps the displayed TTL moving locally and periodically reloads
// the complete key detail. The full reload updates changed values as well as
// the authoritative TTL without rebuilding the parent key tree.
const autoRefreshEnabled = ref(readRedisAutoRefreshEnabled());
const autoRefreshIntervalSeconds = ref(readRedisAutoRefreshInterval());
const countdownTtl = ref(0);
const refreshingTtl = ref(false);
const refreshingValue = ref(false);
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
let countdownTimer: ReturnType<typeof setInterval> | null = null;
let ttlRefreshRequestId = 0;
let autoRefreshRequestId = 0;
let countdownTtlObservedAtMs = Date.now();
let redisValueViewerIsActive = true;
function canRunAutoRefresh(): boolean {
@ -200,46 +201,54 @@ function startAutoRefresh() {
stopAutoRefresh();
if (!autoRefreshEnabled.value || !data.value || !canRunAutoRefresh()) return;
countdownTtl.value = data.value.ttl;
countdownTimer = setInterval(() => {
const action = computeAutoRefreshTick(autoRefreshEnabled.value, countdownTtl.value);
if (action.type === "decrement") {
countdownTtl.value--;
}
}, 1000);
autoRefreshTimer = setInterval(() => void refreshTtl(), autoRefreshIntervalSeconds.value * 1000);
autoRefreshTimer = setInterval(() => void refreshAutoValue(), autoRefreshIntervalSeconds.value * 1000);
}
async function refreshTtl() {
if (refreshingTtl.value || loading.value || editingTtl.value || savingTtl.value || hasUnsavedRedisDraft.value || !autoRefreshEnabled.value || !data.value || !canRunAutoRefresh()) return;
function startCountdown() {
stopCountdown();
if (!data.value || !canRunAutoRefresh()) return;
const requestId = ++ttlRefreshRequestId;
refreshingTtl.value = true;
try {
const ttl = await api.redisGetTtl(props.connectionId, props.db, props.keyRaw);
// A draft may be created while the request is in flight. Never apply even
// a missing-key response after the user has started editing.
if (requestId !== ttlRefreshRequestId || hasUnsavedRedisDraft.value || !autoRefreshEnabled.value || !data.value || !canRunAutoRefresh()) return;
if (ttl === -2) {
data.value = null;
collectionItems.value = [];
scanCursor.value = undefined;
resetStreamEntries();
resetStreamMonitoring();
stopAutoRefresh();
emit("deleted", props.keyRaw);
return;
updateCountdownTtl();
countdownTimer = setInterval(() => {
const action = computeTtlCountdownTick(countdownTtl.value);
if (action.type === "decrement") {
updateCountdownTtl();
}
}, 1000);
}
const refreshedValue = { ...data.value, ttl };
data.value = refreshedValue;
countdownTtl.value = ttl;
function syncCountdownTtl(serverTtl: number) {
countdownTtl.value = serverTtl;
countdownTtlObservedAtMs = Date.now();
}
function updateCountdownTtl() {
if (!data.value) return;
countdownTtl.value = computeTtlCountdownValue(data.value.ttl, countdownTtlObservedAtMs, Date.now());
}
function startRefreshTimers() {
startCountdown();
startAutoRefresh();
}
async function refreshAutoValue() {
if (refreshingValue.value || loading.value || editingTtl.value || savingTtl.value || hasUnsavedRedisDraft.value || shouldPauseAutoValueRefresh() || !autoRefreshEnabled.value || !data.value || !canRunAutoRefresh()) return;
const requestId = ++autoRefreshRequestId;
refreshingValue.value = true;
try {
const applied = await load({
background: true,
preserveDraft: true,
notifyParent: false,
shouldApply: () => requestId === autoRefreshRequestId && !hasUnsavedRedisDraft.value && !shouldPauseAutoValueRefresh() && autoRefreshEnabled.value && canRunAutoRefresh(),
});
if (requestId !== autoRefreshRequestId || !applied || !data.value) return;
} catch {
// A failed background read must not retry in a tight loop. Manual refresh
// remains available and starts a fresh polling lifecycle on success.
if (requestId === ttlRefreshRequestId) {
if (requestId === autoRefreshRequestId) {
stopAutoRefresh();
// The user did not turn the preference off, so do not persist this
// transient failure. The visible state must still match the stopped
@ -247,29 +256,37 @@ async function refreshTtl() {
autoRefreshEnabled.value = false;
}
} finally {
if (requestId === ttlRefreshRequestId) refreshingTtl.value = false;
if (requestId === autoRefreshRequestId) refreshingValue.value = false;
}
}
function stopAutoRefresh() {
ttlRefreshRequestId++;
refreshingTtl.value = false;
autoRefreshRequestId++;
refreshingValue.value = false;
if (autoRefreshTimer !== null) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
function stopCountdown() {
if (countdownTimer !== null) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}
function stopRefreshTimers() {
stopAutoRefresh();
stopCountdown();
}
function handleDocumentVisibilityChange() {
if (document.visibilityState === "hidden") {
stopAutoRefresh();
stopRefreshTimers();
return;
}
startAutoRefresh();
startRefreshTimers();
}
const hashSortBy = ref<"field" | "value" | null>(null);
@ -476,6 +493,12 @@ let hashResizeStartWidth = 0;
let zsetResizeStartX = 0;
let zsetResizeStartWidth = 0;
function shouldPauseAutoValueRefresh(): boolean {
const loadedPageSize = data.value ? redisValueCollectionItems(data.value).length : 0;
const hasExpandedCollectionPage = collectionItems.value.length > loadedPageSize;
return showMemberDetail.value || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage;
}
type PendingDelete = { kind: "key" } | { kind: "hash"; field: string } | { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "zset"; member: string };
const pendingDelete = ref<PendingDelete | null>(null);
@ -1023,23 +1046,29 @@ const deleteDetails = computed(() => {
return t("dangerDialog.redisSetMemberDetails", { key, member: formatValue(pending.member) });
});
async function load(options: { selectDefaultMember?: boolean; preserveDraft?: boolean } = {}): Promise<boolean> {
async function load(options: { background?: boolean; notifyParent?: boolean; preserveDraft?: boolean; selectDefaultMember?: boolean; shouldApply?: () => boolean } = {}): Promise<boolean> {
const background = options.background ?? false;
const notifyParent = options.notifyParent ?? true;
const shouldSelectDefaultMember = options.selectDefaultMember ?? true;
const requestId = ++loadRequestId;
loading.value = true;
if (!background) loading.value = true;
try {
const loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw);
if (requestId !== loadRequestId) return false;
if (requestId !== loadRequestId || (options.shouldApply && !options.shouldApply())) return false;
// Redis reports a key that expired between refreshes as a `none` value.
// Tell the browser to remove it instead of rendering a stale detail shell.
if (loadedValue.redis_type === "none") {
// A background read can finish after the user starts editing. Preserve
// the draft and defer even a missing-key update until the user decides
// whether to save or discard it.
if (background && options.preserveDraft && hasUnsavedRedisDraft.value) return false;
data.value = null;
collectionItems.value = [];
scanCursor.value = undefined;
resetStreamEntries();
resetStreamMonitoring();
stopAutoRefresh();
stopRefreshTimers();
emit("deleted", props.keyRaw);
return true;
}
@ -1049,7 +1078,8 @@ async function load(options: { selectDefaultMember?: boolean; preserveDraft?: bo
if (currentValue) {
const preservedValue = { ...currentValue, ttl: loadedValue.ttl };
data.value = preservedValue;
emit("loaded", preservedValue);
syncCountdownTtl(loadedValue.ttl);
if (notifyParent) emit("loaded", preservedValue);
}
return false;
}
@ -1062,7 +1092,8 @@ async function load(options: { selectDefaultMember?: boolean; preserveDraft?: bo
searchLoading.value = false;
resetValueSearch();
data.value = loadedValue;
emit("loaded", loadedValue);
syncCountdownTtl(loadedValue.ttl);
if (notifyParent) emit("loaded", loadedValue);
scanCursor.value = redisValueCollectionScanCursor(loadedValue);
collectionItems.value = redisValueCollectionItems(loadedValue);
replaceStreamEntries(loadedValue);
@ -1100,10 +1131,8 @@ async function load(options: { selectDefaultMember?: boolean; preserveDraft?: bo
throw error;
} finally {
if (requestId === loadRequestId) {
loading.value = false;
if (autoRefreshEnabled.value && data.value) {
startAutoRefresh();
}
if (!background) loading.value = false;
if (!background && data.value) startRefreshTimers();
}
}
}
@ -1613,7 +1642,7 @@ function requestZsetRemove(member: string | null) {
// TTL
function currentEditableTtl(): number {
if (!data.value) return -1;
return computeTtlForExpiryEdit(autoRefreshEnabled.value, countdownTtl.value, data.value.ttl);
return computeTtlForExpiryEdit(countdownTtl.value, data.value.ttl);
}
function expiryValidationMessage(reason: "ttl" | "date" | "past"): string {
@ -1956,17 +1985,17 @@ onMounted(() => {
});
onActivated(() => {
redisValueViewerIsActive = true;
startAutoRefresh();
startRefreshTimers();
});
onDeactivated(() => {
redisValueViewerIsActive = false;
stopAutoRefresh();
stopRefreshTimers();
});
onBeforeUnmount(() => {
window.removeEventListener("pointerdown", handleValueViewerPointerDown, true);
document.removeEventListener("visibilitychange", handleDocumentVisibilityChange);
redisValueViewerIsActive = false;
stopAutoRefresh();
stopRefreshTimers();
stopResizeHashColumns();
stopResizeZsetColumns();
if (hashSearchTimer) clearTimeout(hashSearchTimer);
@ -2001,39 +2030,40 @@ defineExpose({ focusSearch });
<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>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
data-redis-value-refresh
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 animate-none"
:class="autoRefreshEnabled ? 'bg-primary/10 text-primary hover:bg-primary/15' : ''"
:title="autoRefreshEnabled ? `${t('redis.autoRefresh')}: ${autoRefreshIntervalSeconds}s` : t('grid.refresh')"
:aria-label="autoRefreshEnabled ? `${t('redis.autoRefresh')}: ${autoRefreshIntervalSeconds}s` : t('grid.refresh')"
:aria-pressed="autoRefreshEnabled"
><RefreshCw class="h-3.5 w-3.5 animate-none"
/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-40">
<DropdownMenuItem class="gap-2" :disabled="hasUnsavedRedisDraft" @select="refreshValueAndStreamGroups">
<RefreshCw class="h-3.5 w-3.5" />
{{ t("grid.refresh") }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{{ t("redis.autoRefresh") }}</DropdownMenuLabel>
<DropdownMenuItem class="gap-2" @select="disableAutoRefresh">
<Check v-if="!autoRefreshEnabled" class="h-3.5 w-3.5" />
<span v-else class="h-3.5 w-3.5" />
{{ t("serverDashboard.off") }}
</DropdownMenuItem>
<DropdownMenuItem v-for="interval in REDIS_AUTO_REFRESH_INTERVAL_OPTIONS" :key="interval" class="gap-2" @select="selectAutoRefreshInterval(interval)">
<Check v-if="autoRefreshEnabled && autoRefreshIntervalSeconds === interval" class="h-3.5 w-3.5" />
<span v-else class="h-3.5 w-3.5" />
{{ interval }}s
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div class="flex h-7 shrink-0 overflow-hidden rounded-md border">
<Button data-redis-value-refresh variant="ghost" size="icon" class="h-7 w-7 rounded-none animate-none" :disabled="loading || refreshingValue || hasUnsavedRedisDraft" :title="t('grid.refresh')" :aria-label="t('grid.refresh')" @click="refreshValueAndStreamGroups">
<RefreshCw class="h-3.5 w-3.5 animate-none" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
data-redis-auto-refresh-menu
variant="ghost"
size="icon"
class="h-7 w-5 rounded-none border-l px-0"
:class="autoRefreshEnabled ? 'bg-primary/10 text-primary hover:bg-primary/15' : ''"
:title="autoRefreshEnabled ? `${t('redis.autoRefresh')}: ${autoRefreshIntervalSeconds}s` : t('redis.autoRefresh')"
:aria-label="autoRefreshEnabled ? `${t('redis.autoRefresh')}: ${autoRefreshIntervalSeconds}s` : t('redis.autoRefresh')"
>
<ChevronDown class="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-40">
<DropdownMenuLabel>{{ t("redis.autoRefresh") }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem class="gap-2" @select="disableAutoRefresh">
<Check v-if="!autoRefreshEnabled" class="h-3.5 w-3.5" />
<span v-else class="h-3.5 w-3.5" />
{{ t("serverDashboard.off") }}
</DropdownMenuItem>
<DropdownMenuItem v-for="interval in REDIS_AUTO_REFRESH_INTERVAL_OPTIONS" :key="interval" class="gap-2" @select="selectAutoRefreshInterval(interval)">
<Check v-if="autoRefreshEnabled && autoRefreshIntervalSeconds === interval" class="h-3.5 w-3.5" />
<span v-else class="h-3.5 w-3.5" />
{{ interval }}s
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('grid.copyValue')" :aria-label="t('grid.copyValue')" @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')" :aria-label="t('redis.copyInsertStatement')" @click="copyInsertStatement"><ClipboardCopy 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>
@ -2044,7 +2074,7 @@ defineExpose({ focusSearch });
<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" as="button" type="button" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" :disabled="savingTtl" :aria-label="t('redis.expiry')" @click="startEditTtl">
TTL: {{ formatTtl(computeDisplayTtl(autoRefreshEnabled, countdownTtl, data.ttl), t) }}
TTL: {{ formatTtl(computeDisplayTtl(countdownTtl, data.ttl), t) }}
</Badge>
<Badge v-else-if="data.ttl === -1" as="button" type="button" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" :disabled="savingTtl" :aria-label="t('redis.expiry')" @click="startEditTtl">
{{ t("redis.noExpiry") }}

View File

@ -101,7 +101,7 @@ describe("Redis expiry mode wiring", () => {
const currentTtl = findFunction(viewerSource, "currentEditableTtl").getText();
expect(start).toContain("redisExpiryModeForTtl(ttl)");
expect(currentTtl).toContain("computeTtlForExpiryEdit(autoRefreshEnabled.value, countdownTtl.value, data.value.ttl)");
expect(currentTtl).toContain("computeTtlForExpiryEdit(countdownTtl.value, data.value.ttl)");
expect(viewerSource).toContain("unixSecondsToCalendarDateTime(Math.ceil(Date.now() / 1_000) + ttl)");
expect(save).toContain("validateRedisExpiry");
expect(save).toContain("applyRedisExpiryPolicy");

View File

@ -234,7 +234,7 @@ describe("native RedisJSON editor", () => {
});
it("keeps retained drafts out of background refresh and exposes word wrap for every JSON editor", () => {
const refreshTtl = findFunction("refreshTtl");
const refreshAutoValue = findFunction("refreshAutoValue");
const hashSearch = findFunction("onHashSearch");
const viewMember = findFunction("viewMember");
const setMemberValueFormat = findFunction("setMemberValueFormat");
@ -243,9 +243,9 @@ describe("native RedisJSON editor", () => {
const labels = templateElements(parsedViewer.descriptor.template!.ast as unknown as TemplateElement);
const stringTextarea = findTemplateElement((element) => element.tag === "textarea" && directiveExpression(element, "model") === "editValue");
const memberTextarea = findTemplateElement((element) => element.tag === "textarea" && directiveExpression(element, "model") === "memberEditValue");
const refreshItem = findTemplateElement((element) => element.tag === "DropdownMenuItem" && directiveExpression(element, "on", "select") === "refreshValueAndStreamGroups");
const refreshButton = findTemplateElement((element) => element.tag === "Button" && element.props.some((prop) => prop.type === 6 && prop.name === "data-redis-value-refresh"));
expect(refreshTtl.getText().match(/hasUnsavedRedisDraft\.value/g)).toHaveLength(2);
expect(refreshAutoValue.getText().match(/hasUnsavedRedisDraft\.value/g)).toHaveLength(2);
expect(hashSearch.getText()).toContain("if (!hasRetainedMemberDraft.value) clearSelectedMember();");
expect(viewMember.getText()).toContain("hasRetainedMemberDraft.value");
// Clean JSON → other format must clear memberDraftFormat so rawText is not compared to the pretty baseline.
@ -258,6 +258,6 @@ describe("native RedisJSON editor", () => {
expect(labels.some((element) => element.tag === "label" && directiveExpression(element, "if") === "isTextRedisFormat(memberValueView)")).toBe(true);
expect(directiveExpression(stringTextarea, "bind", "readonly")).toBe("!canEditCurrentStringFormat || savingString");
expect(directiveExpression(memberTextarea, "bind", "readonly")).toBe("savingMember");
expect(directiveExpression(refreshItem, "bind", "disabled")).toBe("hasUnsavedRedisDraft");
expect(directiveExpression(refreshButton, "bind", "disabled")).toBe("loading || refreshingValue || hasUnsavedRedisDraft");
});
});

View File

@ -1,22 +1,28 @@
import { describe, expect, it } from "vitest";
import { computeAutoRefreshTick, computeDisplayTtl, computeTtlForExpiryEdit, DEFAULT_REDIS_AUTO_REFRESH_INTERVAL_SECONDS, normalizeRedisAutoRefreshInterval } from "@/lib/redis/redisAutoRefresh";
describe("computeAutoRefreshTick", () => {
it("returns idle when auto-refresh is disabled", () => {
expect(computeAutoRefreshTick(false, 10)).toEqual({ type: "idle" });
expect(computeAutoRefreshTick(false, 0)).toEqual({ type: "idle" });
expect(computeAutoRefreshTick(false, 5)).toEqual({ type: "idle" });
});
import { computeDisplayTtl, computeTtlCountdownTick, computeTtlCountdownValue, computeTtlForExpiryEdit, DEFAULT_REDIS_AUTO_REFRESH_INTERVAL_SECONDS, normalizeRedisAutoRefreshInterval } from "@/lib/redis/redisAutoRefresh";
describe("computeTtlCountdownTick", () => {
it("returns decrement while a positive TTL remains", () => {
expect(computeAutoRefreshTick(true, 10)).toEqual({ type: "decrement" });
expect(computeAutoRefreshTick(true, 1)).toEqual({ type: "decrement" });
expect(computeTtlCountdownTick(10)).toEqual({ type: "decrement" });
expect(computeTtlCountdownTick(1)).toEqual({ type: "decrement" });
});
it("stops decrementing after the TTL has reached zero", () => {
expect(computeAutoRefreshTick(true, 0)).toEqual({ type: "idle" });
expect(computeAutoRefreshTick(true, -1)).toEqual({ type: "idle" });
expect(computeTtlCountdownTick(0)).toEqual({ type: "idle" });
expect(computeTtlCountdownTick(-1)).toEqual({ type: "idle" });
});
});
describe("computeTtlCountdownValue", () => {
it("accounts for time elapsed while countdown timers are paused", () => {
expect(computeTtlCountdownValue(60, 10_000, 40_000)).toBe(30);
});
it("preserves Redis sentinel values and clamps expired TTLs", () => {
expect(computeTtlCountdownValue(5, 10_000, 20_000)).toBe(0);
expect(computeTtlCountdownValue(-1, 10_000, 20_000)).toBe(-1);
expect(computeTtlCountdownValue(-2, 10_000, 20_000)).toBe(-2);
});
});
@ -30,34 +36,33 @@ describe("normalizeRedisAutoRefreshInterval", () => {
});
describe("computeDisplayTtl", () => {
it("returns server TTL when auto-refresh is disabled", () => {
expect(computeDisplayTtl(false, 3, 10)).toBe(10);
it("returns the live countdown regardless of automatic network refresh", () => {
expect(computeDisplayTtl(3, 10)).toBe(3);
});
it("does not flash back to the stale server TTL at zero", () => {
expect(computeDisplayTtl(true, 0, 5)).toBe(0);
expect(computeDisplayTtl(0, 5)).toBe(0);
});
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 live countdown while counting", () => {
expect(computeDisplayTtl(5, 10)).toBe(5);
expect(computeDisplayTtl(1, 10)).toBe(1);
});
it("clamps an active countdown below zero instead of showing stale data", () => {
expect(computeDisplayTtl(true, -1, 10)).toBe(0);
expect(computeDisplayTtl(-1, 10)).toBe(0);
});
});
describe("computeTtlForExpiryEdit", () => {
it("keeps a last-confirmed positive TTL during the in-flight zero-countdown window", () => {
expect(computeTtlForExpiryEdit(true, 0, 5)).toBe(5);
expect(computeTtlForExpiryEdit(true, -1, 5)).toBe(5);
expect(computeTtlForExpiryEdit(0, 5)).toBe(5);
expect(computeTtlForExpiryEdit(-1, 5)).toBe(5);
});
it("uses the live positive countdown and preserves non-expiring server states", () => {
expect(computeTtlForExpiryEdit(true, 3, 10)).toBe(3);
expect(computeTtlForExpiryEdit(false, 3, 10)).toBe(10);
expect(computeTtlForExpiryEdit(true, 0, -1)).toBe(-1);
expect(computeTtlForExpiryEdit(true, 0, -2)).toBe(-2);
expect(computeTtlForExpiryEdit(3, 10)).toBe(3);
expect(computeTtlForExpiryEdit(0, -1)).toBe(-1);
expect(computeTtlForExpiryEdit(0, -2)).toBe(-2);
});
});

View File

@ -10,7 +10,7 @@ export const MAX_REDIS_AUTO_REFRESH_INTERVAL_SECONDS = 3600;
export const DEFAULT_REDIS_AUTO_REFRESH_INTERVAL_SECONDS = 5;
/** Action computed after evaluating one visible TTL countdown tick. */
export type AutoRefreshTickAction = { type: "idle" } | { type: "decrement" };
export type TtlCountdownTickAction = { type: "idle" } | { type: "decrement" };
/** Keep a user-entered polling frequency within a safe, whole-second range. */
export function normalizeRedisAutoRefreshInterval(value: unknown): number {
@ -22,22 +22,28 @@ export function normalizeRedisAutoRefreshInterval(value: unknown): number {
/**
* Evaluate one one-second visible TTL countdown tick.
*
* @param enabled Whether auto-refresh is currently toggled on.
* @param countdownTtl Current countdown value in seconds.
* @returns The action the caller should take this tick.
*/
export function computeAutoRefreshTick(enabled: boolean, countdownTtl: number): AutoRefreshTickAction {
return enabled && countdownTtl > 0 ? { type: "decrement" } : { type: "idle" };
export function computeTtlCountdownTick(countdownTtl: number): TtlCountdownTickAction {
return countdownTtl > 0 ? { type: "decrement" } : { type: "idle" };
}
/** Compute a TTL countdown from the time the server value was observed. */
export function computeTtlCountdownValue(serverTtl: number, observedAtMs: number, nowMs: number): number {
if (serverTtl <= 0) return serverTtl;
const elapsedSeconds = Math.max(0, Math.floor((nowMs - observedAtMs) / 1000));
return Math.max(serverTtl - elapsedSeconds, 0);
}
/**
* Compute the TTL value that should be displayed in the badge.
*
* When auto-refresh is active, the live countdown value is shown. The
* The local countdown is independent from automatic network refreshes, so the
* last known server TTL must not reappear after the countdown reaches zero.
*/
export function computeDisplayTtl(autoRefreshEnabled: boolean, countdownTtl: number, serverTtl: number): number {
return autoRefreshEnabled ? Math.max(countdownTtl, 0) : serverTtl;
export function computeDisplayTtl(countdownTtl: number, serverTtl: number): number {
return serverTtl > 0 ? Math.max(countdownTtl, 0) : serverTtl;
}
/**
@ -47,8 +53,8 @@ export function computeDisplayTtl(autoRefreshEnabled: boolean, countdownTtl: num
* is still in flight. Do not turn a last-confirmed positive Redis TTL into
* PERSIST during that window.
*/
export function computeTtlForExpiryEdit(autoRefreshEnabled: boolean, countdownTtl: number, serverTtl: number): number {
export function computeTtlForExpiryEdit(countdownTtl: number, serverTtl: number): number {
if (serverTtl <= 0) return serverTtl;
const displayedTtl = computeDisplayTtl(autoRefreshEnabled, countdownTtl, serverTtl);
const displayedTtl = computeDisplayTtl(countdownTtl, serverTtl);
return displayedTtl > 0 ? displayedTtl : serverTtl;
}