feat(redis): poll full values and track TTL countdowns
This commit is contained in:
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") }}
|
||||
|
||||
Reference in new issue
Block a user