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

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-31 23:11:48 +08:00
1 parent a6a072294a
commit c69f1352d4
7 files changed
+268 -169

No files matched your search

@@ -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");
});
@@ -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", () => {
@@ -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") }}