feat(redis): improve key detail auto-refresh and TTL polling

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-31 02:41:26 +08:00
1 parent a662d12f57
commit c697d0c7ad
15 files changed
+540 -106

No files matched your search

@@ -1,12 +1,13 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick } from "vue";
import { createApp, defineComponent, h, KeepAlive, nextTick, ref } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { calendarDateTimeToUnixSeconds, parseLocalDateTime } from "@/components/ui/date-time-picker/dateTimePicker";
const mocks = vi.hoisted(() => ({
redisGetValue: vi.fn(),
redisGetTtl: vi.fn(),
redisSetTtl: vi.fn(),
redisSetExpireAt: vi.fn(),
toast: vi.fn(),
@@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/lib/backend/api", () => ({
redisGetValue: mocks.redisGetValue,
redisGetTtl: mocks.redisGetTtl,
redisSetTtl: mocks.redisSetTtl,
redisSetExpireAt: mocks.redisSetExpireAt,
}));
@@ -34,15 +36,44 @@ import RedisValueViewer from "./RedisValueViewer.vue";
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
function createLocalStorage(): Storage {
const entries = new Map<string, string>();
return {
get length() {
return entries.size;
},
clear() {
entries.clear();
},
getItem(key) {
return entries.get(key) ?? null;
},
key(index) {
return [...entries.keys()][index] ?? null;
},
removeItem(key) {
entries.delete(key);
},
setItem(key, value) {
entries.set(key, String(value));
},
};
}
afterEach(() => {
for (const { unmount, host } of mountedApps.splice(0)) {
unmount();
host.remove();
}
localStorage.removeItem("dbx-redis-auto-refresh-enabled-v2");
localStorage.removeItem("dbx-redis-auto-refresh-interval-seconds-v2");
vi.unstubAllGlobals();
vi.useRealTimers();
});
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("localStorage", createLocalStorage());
});
async function settle() {
@@ -81,7 +112,7 @@ function deferred<T>() {
return { promise, resolve };
}
function mountViewer(onDeleted: (keyRaw: string) => void) {
function mountViewer(onDeleted: (keyRaw: string) => void, onLoaded = vi.fn()) {
const host = document.createElement("div");
document.body.append(host);
const app = createApp(
@@ -94,6 +125,7 @@ function mountViewer(onDeleted: (keyRaw: string) => void) {
keyDisplay: "key",
keyRaw: "key",
onDeleted,
onLoaded,
});
},
}),
@@ -103,6 +135,42 @@ function mountViewer(onDeleted: (keyRaw: string) => void) {
mountedApps.push({ unmount: () => app.unmount(), host });
}
function mountKeepAliveViewer(onDeleted = vi.fn()) {
const host = document.createElement("div");
document.body.append(host);
const active = ref(true);
const inactiveView = defineComponent({ setup: () => () => h("div") });
const app = createApp(
defineComponent({
setup() {
return () =>
h(KeepAlive, null, {
default: () =>
active.value
? h(RedisValueViewer, {
key: "viewer",
connectionId: "connection",
db: 0,
keyDisplay: "key",
keyRaw: "key",
onDeleted,
})
: h(inactiveView, { key: "inactive" }),
});
},
}),
);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
mountedApps.push({ unmount: () => app.unmount(), host });
return {
async setActive(value: boolean) {
active.value = value;
await settle();
},
};
}
async function saveTtlFromEditor() {
await openTtlEditor();
await saveOpenTtlEditor();
@@ -153,6 +221,179 @@ async function setStringDraft(value: string) {
}
describe("RedisValueViewer expiry saving", () => {
it("defaults to manual refresh without TTL polling", async () => {
vi.useFakeTimers();
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60));
mountViewer(vi.fn());
await settle();
await vi.advanceTimersByTimeAsync(10_000);
expect(mocks.redisGetTtl).not.toHaveBeenCalled();
expect(document.querySelector<HTMLButtonElement>("[aria-label='grid.refresh']")?.getAttribute("aria-pressed")).toBe("false");
});
it("polls only the TTL at the configured interval", 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);
const loaded = vi.fn();
mountViewer(vi.fn(), loaded);
await settle();
await vi.advanceTimersByTimeAsync(5000);
await settle();
expect(mocks.redisGetTtl).toHaveBeenCalledWith("connection", 0, "key");
expect(mocks.redisGetValue).toHaveBeenCalledOnce();
expect(loaded).toHaveBeenCalledOnce();
expect(document.querySelector<HTMLElement>("[data-slot='badge'][aria-label='redis.expiry']")?.textContent).toContain("00:00:45");
});
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);
mountViewer(vi.fn());
await settle();
await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(mocks.redisGetTtl).toHaveBeenCalledOnce();
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 () => {
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"));
mountViewer(vi.fn());
await settle();
await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(document.querySelector<HTMLButtonElement>("[aria-label='grid.refresh']")?.getAttribute("aria-pressed")).toBe("false");
});
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);
mountViewer(vi.fn());
await settle();
await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(document.querySelector<HTMLElement>("[data-slot='badge'][aria-label='redis.expiry']")?.textContent).toContain("redis.noExpiry");
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(document.querySelector<HTMLElement>("[data-slot='badge'][aria-label='redis.expiry']")?.textContent).toContain("00:00:30");
});
it("pauses polling while the document is hidden and resumes when visible", 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);
let visibilityState: DocumentVisibilityState = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibilityState);
mountViewer(vi.fn());
await settle();
visibilityState = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5000);
expect(mocks.redisGetTtl).not.toHaveBeenCalled();
visibilityState = "visible";
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(mocks.redisGetTtl).toHaveBeenCalledOnce();
});
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);
const viewer = mountKeepAliveViewer();
await settle();
await viewer.setActive(false);
await vi.advanceTimersByTimeAsync(5000);
expect(mocks.redisGetTtl).not.toHaveBeenCalled();
await viewer.setActive(true);
await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(mocks.redisGetTtl).toHaveBeenCalledOnce();
});
it("pauses TTL 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");
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60));
const deleted = vi.fn();
mountViewer(deleted);
await settle();
await setStringDraft("draft");
await vi.advanceTimersByTimeAsync(5000);
await settle();
expect(mocks.redisGetTtl).not.toHaveBeenCalled();
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 () => {
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 deleted = vi.fn();
mountViewer(deleted);
await settle();
await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(mocks.redisGetTtl).toHaveBeenCalledOnce();
await setStringDraft("draft");
ttlRequest.resolve(-2);
await settle();
await vi.advanceTimersByTimeAsync(5000);
await settle();
expect(mocks.redisGetTtl).toHaveBeenCalledOnce();
expect(deleted).not.toHaveBeenCalled();
expect(document.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("draft");
});
it("removes a key that disappears while a TTL save fails", async () => {
mocks.redisGetValue.mockResolvedValueOnce(stringValue()).mockResolvedValueOnce(missingValue());
mocks.redisSetTtl.mockRejectedValueOnce(new Error("Redis key no longer exists; EXPIRE was not applied"));
@@ -159,6 +159,15 @@ function openGroups(host: HTMLElement) {
host.querySelector<HTMLButtonElement>("[data-redis-stream-groups-tab]")!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
}
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", () => {
it("loads more Stream entries from the returned cursor", async () => {
mocks.redisGetValue.mockResolvedValue(streamValue([streamEntry("1714470000000-0")], "1714470000000-0"));
@@ -347,8 +356,7 @@ describe("RedisValueViewer stream monitoring", () => {
host.querySelector<HTMLButtonElement>("[data-redis-stream-consumer-row]")!.click();
await settle();
host.querySelector<HTMLButtonElement>("[data-redis-value-refresh]")!.click();
await settle();
await refreshValue(host);
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
expect(mocks.redisGetStreamGroups).toHaveBeenCalledTimes(2);
@@ -372,8 +380,7 @@ describe("RedisValueViewer stream monitoring", () => {
host.querySelector<HTMLButtonElement>("[data-redis-stream-consumer-row]")!.click();
await settle();
host.querySelector<HTMLButtonElement>("[data-redis-value-refresh]")!.click();
await settle();
await refreshValue(host);
expect(mocks.redisGetStreamConsumers).toHaveBeenCalledTimes(2);
expect(host.querySelector("[data-redis-stream-consumer-crumb]")).toBeNull();
@@ -399,8 +406,7 @@ describe("RedisValueViewer stream monitoring", () => {
host.querySelector<HTMLElement>("[data-redis-stream-group-row]")!.click();
await settle();
host.querySelector<HTMLButtonElement>("[data-redis-value-refresh]")!.click();
await settle();
await refreshValue(host);
expect(mocks.redisGetStreamGroups).toHaveBeenCalledTimes(2);
expect(host.querySelector("[data-redis-stream-group-detail]")).toBeNull();
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed, ref, shallowRef, nextTick, onBeforeUnmount, onMounted, watch } from "vue";
import { computed, ref, shallowRef, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, watch } from "vue";
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 { Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search, Clock } from "@lucide/vue";
import { Check, 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";
@@ -13,6 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import DateTimePicker from "@/components/ui/date-time-picker/DateTimePicker.vue";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import JsonTree from "@/components/common/JsonTree.vue";
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
@@ -24,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, shouldStopAutoRefresh } from "@/lib/redis/redisAutoRefresh";
import { computeAutoRefreshTick, computeDisplayTtl, computeTtlForExpiryEdit, DEFAULT_REDIS_AUTO_REFRESH_INTERVAL_SECONDS, normalizeRedisAutoRefreshInterval } from "@/lib/redis/redisAutoRefresh";
import {
canRenderRedisValueFormat,
canEditRedisMemberDetail,
@@ -74,6 +75,11 @@ const emit = defineEmits<{ deleted: [keyRaw: string]; loaded: [value: RedisValue
const REDIS_JSON_WRAP_STORAGE_KEY = "dbx-redis-json-word-wrap";
const REDIS_VALUE_FORMAT_STORAGE_KEY = "dbx-redis-value-format";
// Versioned after moving the setting into the refresh menu so the previous
// always-on default does not carry into the new manual-refresh default.
const REDIS_AUTO_REFRESH_ENABLED_STORAGE_KEY = "dbx-redis-auto-refresh-enabled-v2";
const REDIS_AUTO_REFRESH_INTERVAL_STORAGE_KEY = "dbx-redis-auto-refresh-interval-seconds-v2";
const REDIS_AUTO_REFRESH_INTERVAL_OPTIONS = [1, 3, 5, 10] as const;
const REDIS_COLLECTION_ROW_HEIGHT = 32;
const REDIS_STREAM_MIN_ROW_HEIGHT = 96;
@@ -158,58 +164,112 @@ const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
const redisJsonHighlighter = ref<JsonHighlighter>();
// Auto-refresh
const autoRefreshEnabled = ref(true);
// 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.
const autoRefreshEnabled = ref(readRedisAutoRefreshEnabled());
const autoRefreshIntervalSeconds = ref(readRedisAutoRefreshInterval());
const countdownTtl = ref(0);
const refreshingTtl = ref(false);
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
let countdownTimer: ReturnType<typeof setInterval> | null = null;
let ttlRefreshRequestId = 0;
let redisValueViewerIsActive = true;
function toggleAutoRefresh() {
autoRefreshEnabled.value = !autoRefreshEnabled.value;
if (autoRefreshEnabled.value) {
startAutoRefresh();
} else {
stopAutoRefresh();
function canRunAutoRefresh(): boolean {
return redisValueViewerIsActive && document.visibilityState !== "hidden";
}
function disableAutoRefresh() {
autoRefreshEnabled.value = false;
persistRedisAutoRefreshEnabled(false);
stopAutoRefresh();
}
function selectAutoRefreshInterval(interval: number) {
autoRefreshIntervalSeconds.value = interval;
persistRedisAutoRefreshInterval(interval);
if (!autoRefreshEnabled.value) {
autoRefreshEnabled.value = true;
persistRedisAutoRefreshEnabled(true);
}
startAutoRefresh();
}
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 (!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--;
return;
}
if (action.type === "refresh") {
// Do not let a background refresh overwrite a Redis value draft.
if (hasUnsavedRedisDraft.value) return;
load({ preserveDraft: true })
.then((applied) => {
if (!applied || !autoRefreshEnabled.value) return;
if (!data.value || shouldStopAutoRefresh(data.value.ttl)) {
stopAutoRefresh();
autoRefreshEnabled.value = false;
}
})
.catch(() => {
// Network / connection error — stop auto-refresh to avoid tight retry loop
if (autoRefreshEnabled.value) {
stopAutoRefresh();
autoRefreshEnabled.value = false;
}
});
}
}, 1000);
autoRefreshTimer = setInterval(() => void refreshTtl(), autoRefreshIntervalSeconds.value * 1000);
}
async function refreshTtl() {
if (refreshingTtl.value || loading.value || editingTtl.value || savingTtl.value || hasUnsavedRedisDraft.value || !autoRefreshEnabled.value || !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;
}
const refreshedValue = { ...data.value, ttl };
data.value = refreshedValue;
countdownTtl.value = ttl;
} 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) {
stopAutoRefresh();
// The user did not turn the preference off, so do not persist this
// transient failure. The visible state must still match the stopped
// timers and let one click restart polling.
autoRefreshEnabled.value = false;
}
} finally {
if (requestId === ttlRefreshRequestId) refreshingTtl.value = false;
}
}
function stopAutoRefresh() {
ttlRefreshRequestId++;
refreshingTtl.value = false;
if (autoRefreshTimer !== null) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
if (countdownTimer !== null) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}
function handleDocumentVisibilityChange() {
if (document.visibilityState === "hidden") {
stopAutoRefresh();
return;
}
startAutoRefresh();
}
const hashSortBy = ref<"field" | "value" | null>(null);
@@ -776,6 +836,39 @@ function readRedisJsonWordWrap(): boolean {
}
}
function readRedisAutoRefreshEnabled(): boolean {
try {
return localStorage.getItem(REDIS_AUTO_REFRESH_ENABLED_STORAGE_KEY) === "true";
} catch {
return false;
}
}
function persistRedisAutoRefreshEnabled(enabled: boolean) {
try {
localStorage.setItem(REDIS_AUTO_REFRESH_ENABLED_STORAGE_KEY, enabled ? "true" : "false");
} catch {
// Keep the preference for this component if storage is unavailable.
}
}
function readRedisAutoRefreshInterval(): number {
try {
const stored = localStorage.getItem(REDIS_AUTO_REFRESH_INTERVAL_STORAGE_KEY);
return stored === null ? DEFAULT_REDIS_AUTO_REFRESH_INTERVAL_SECONDS : normalizeRedisAutoRefreshInterval(stored);
} catch {
return DEFAULT_REDIS_AUTO_REFRESH_INTERVAL_SECONDS;
}
}
function persistRedisAutoRefreshInterval(interval: number) {
try {
localStorage.setItem(REDIS_AUTO_REFRESH_INTERVAL_STORAGE_KEY, String(interval));
} catch {
// Keep the current interval if storage is unavailable.
}
}
function setRedisJsonWordWrap(value: boolean) {
redisJsonWordWrap.value = value;
try {
@@ -1008,7 +1101,7 @@ async function load(options: { selectDefaultMember?: boolean; preserveDraft?: bo
} finally {
if (requestId === loadRequestId) {
loading.value = false;
if (autoRefreshEnabled.value && data.value && data.value.ttl > 0) {
if (autoRefreshEnabled.value && data.value) {
startAutoRefresh();
}
}
@@ -1849,6 +1942,7 @@ watch(showMemberDetail, (open) => {
onMounted(() => {
window.addEventListener("pointerdown", handleValueViewerPointerDown, true);
document.addEventListener("visibilitychange", handleDocumentVisibilityChange);
void load();
void createShikiJsonHighlighter({
appearance: () => redisJsonAppearance.value,
@@ -1860,8 +1954,18 @@ onMounted(() => {
redisJsonHighlighter.value = undefined;
});
});
onActivated(() => {
redisValueViewerIsActive = true;
startAutoRefresh();
});
onDeactivated(() => {
redisValueViewerIsActive = false;
stopAutoRefresh();
});
onBeforeUnmount(() => {
window.removeEventListener("pointerdown", handleValueViewerPointerDown, true);
document.removeEventListener("visibilitychange", handleDocumentVisibilityChange);
redisValueViewerIsActive = false;
stopAutoRefresh();
stopResizeHashColumns();
stopResizeZsetColumns();
@@ -1897,7 +2001,39 @@ 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>
<Button data-redis-value-refresh variant="ghost" size="icon" class="h-7 w-7 shrink-0 animate-none" :disabled="hasUnsavedRedisDraft" @click="refreshValueAndStreamGroups"><RefreshCw class="h-3.5 w-3.5 animate-none" /></Button>
<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>
<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>
@@ -1929,9 +2065,6 @@ defineExpose({ focusSearch });
<DateTimePicker v-else-if="ttlExpiryMode === 'at'" v-model="ttlExpireAt" compact :locale="locale" :disabled="savingTtl" />
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="savingTtl" :title="t('grid.save')" :aria-label="t('grid.save')" @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>