feat(redis): improve key detail auto-refresh and TTL polling
This commit is contained in:
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"));
|
||||
|
||||
+12
-6
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user