feat(redis): poll full values and track TTL countdowns
This commit is contained in:
parent
a6a072294a
commit
c69f1352d4
|
|
@ -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") }}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ describe("Redis expiry mode wiring", () => {
|
|||
const currentTtl = findFunction(viewerSource, "currentEditableTtl").getText();
|
||||
|
||||
expect(start).toContain("redisExpiryModeForTtl(ttl)");
|
||||
expect(currentTtl).toContain("computeTtlForExpiryEdit(autoRefreshEnabled.value, countdownTtl.value, data.value.ttl)");
|
||||
expect(currentTtl).toContain("computeTtlForExpiryEdit(countdownTtl.value, data.value.ttl)");
|
||||
expect(viewerSource).toContain("unixSecondsToCalendarDateTime(Math.ceil(Date.now() / 1_000) + ttl)");
|
||||
expect(save).toContain("validateRedisExpiry");
|
||||
expect(save).toContain("applyRedisExpiryPolicy");
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ describe("native RedisJSON editor", () => {
|
|||
});
|
||||
|
||||
it("keeps retained drafts out of background refresh and exposes word wrap for every JSON editor", () => {
|
||||
const refreshTtl = findFunction("refreshTtl");
|
||||
const refreshAutoValue = findFunction("refreshAutoValue");
|
||||
const hashSearch = findFunction("onHashSearch");
|
||||
const viewMember = findFunction("viewMember");
|
||||
const setMemberValueFormat = findFunction("setMemberValueFormat");
|
||||
|
|
@ -243,9 +243,9 @@ describe("native RedisJSON editor", () => {
|
|||
const labels = templateElements(parsedViewer.descriptor.template!.ast as unknown as TemplateElement);
|
||||
const stringTextarea = findTemplateElement((element) => element.tag === "textarea" && directiveExpression(element, "model") === "editValue");
|
||||
const memberTextarea = findTemplateElement((element) => element.tag === "textarea" && directiveExpression(element, "model") === "memberEditValue");
|
||||
const refreshItem = findTemplateElement((element) => element.tag === "DropdownMenuItem" && directiveExpression(element, "on", "select") === "refreshValueAndStreamGroups");
|
||||
const refreshButton = findTemplateElement((element) => element.tag === "Button" && element.props.some((prop) => prop.type === 6 && prop.name === "data-redis-value-refresh"));
|
||||
|
||||
expect(refreshTtl.getText().match(/hasUnsavedRedisDraft\.value/g)).toHaveLength(2);
|
||||
expect(refreshAutoValue.getText().match(/hasUnsavedRedisDraft\.value/g)).toHaveLength(2);
|
||||
expect(hashSearch.getText()).toContain("if (!hasRetainedMemberDraft.value) clearSelectedMember();");
|
||||
expect(viewMember.getText()).toContain("hasRetainedMemberDraft.value");
|
||||
// Clean JSON → other format must clear memberDraftFormat so rawText is not compared to the pretty baseline.
|
||||
|
|
@ -258,6 +258,6 @@ describe("native RedisJSON editor", () => {
|
|||
expect(labels.some((element) => element.tag === "label" && directiveExpression(element, "if") === "isTextRedisFormat(memberValueView)")).toBe(true);
|
||||
expect(directiveExpression(stringTextarea, "bind", "readonly")).toBe("!canEditCurrentStringFormat || savingString");
|
||||
expect(directiveExpression(memberTextarea, "bind", "readonly")).toBe("savingMember");
|
||||
expect(directiveExpression(refreshItem, "bind", "disabled")).toBe("hasUnsavedRedisDraft");
|
||||
expect(directiveExpression(refreshButton, "bind", "disabled")).toBe("loading || refreshingValue || hasUnsavedRedisDraft");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,22 +1,28 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeAutoRefreshTick, computeDisplayTtl, computeTtlForExpiryEdit, DEFAULT_REDIS_AUTO_REFRESH_INTERVAL_SECONDS, normalizeRedisAutoRefreshInterval } from "@/lib/redis/redisAutoRefresh";
|
||||
|
||||
describe("computeAutoRefreshTick", () => {
|
||||
it("returns idle when auto-refresh is disabled", () => {
|
||||
expect(computeAutoRefreshTick(false, 10)).toEqual({ type: "idle" });
|
||||
expect(computeAutoRefreshTick(false, 0)).toEqual({ type: "idle" });
|
||||
expect(computeAutoRefreshTick(false, 5)).toEqual({ type: "idle" });
|
||||
});
|
||||
import { computeDisplayTtl, computeTtlCountdownTick, computeTtlCountdownValue, computeTtlForExpiryEdit, DEFAULT_REDIS_AUTO_REFRESH_INTERVAL_SECONDS, normalizeRedisAutoRefreshInterval } from "@/lib/redis/redisAutoRefresh";
|
||||
|
||||
describe("computeTtlCountdownTick", () => {
|
||||
it("returns decrement while a positive TTL remains", () => {
|
||||
expect(computeAutoRefreshTick(true, 10)).toEqual({ type: "decrement" });
|
||||
expect(computeAutoRefreshTick(true, 1)).toEqual({ type: "decrement" });
|
||||
expect(computeTtlCountdownTick(10)).toEqual({ type: "decrement" });
|
||||
expect(computeTtlCountdownTick(1)).toEqual({ type: "decrement" });
|
||||
});
|
||||
|
||||
it("stops decrementing after the TTL has reached zero", () => {
|
||||
expect(computeAutoRefreshTick(true, 0)).toEqual({ type: "idle" });
|
||||
expect(computeAutoRefreshTick(true, -1)).toEqual({ type: "idle" });
|
||||
expect(computeTtlCountdownTick(0)).toEqual({ type: "idle" });
|
||||
expect(computeTtlCountdownTick(-1)).toEqual({ type: "idle" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeTtlCountdownValue", () => {
|
||||
it("accounts for time elapsed while countdown timers are paused", () => {
|
||||
expect(computeTtlCountdownValue(60, 10_000, 40_000)).toBe(30);
|
||||
});
|
||||
|
||||
it("preserves Redis sentinel values and clamps expired TTLs", () => {
|
||||
expect(computeTtlCountdownValue(5, 10_000, 20_000)).toBe(0);
|
||||
expect(computeTtlCountdownValue(-1, 10_000, 20_000)).toBe(-1);
|
||||
expect(computeTtlCountdownValue(-2, 10_000, 20_000)).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -30,34 +36,33 @@ describe("normalizeRedisAutoRefreshInterval", () => {
|
|||
});
|
||||
|
||||
describe("computeDisplayTtl", () => {
|
||||
it("returns server TTL when auto-refresh is disabled", () => {
|
||||
expect(computeDisplayTtl(false, 3, 10)).toBe(10);
|
||||
it("returns the live countdown regardless of automatic network refresh", () => {
|
||||
expect(computeDisplayTtl(3, 10)).toBe(3);
|
||||
});
|
||||
|
||||
it("does not flash back to the stale server TTL at zero", () => {
|
||||
expect(computeDisplayTtl(true, 0, 5)).toBe(0);
|
||||
expect(computeDisplayTtl(0, 5)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns live countdown when auto-refresh is active and counting", () => {
|
||||
expect(computeDisplayTtl(true, 5, 10)).toBe(5);
|
||||
expect(computeDisplayTtl(true, 1, 10)).toBe(1);
|
||||
it("returns live countdown while counting", () => {
|
||||
expect(computeDisplayTtl(5, 10)).toBe(5);
|
||||
expect(computeDisplayTtl(1, 10)).toBe(1);
|
||||
});
|
||||
|
||||
it("clamps an active countdown below zero instead of showing stale data", () => {
|
||||
expect(computeDisplayTtl(true, -1, 10)).toBe(0);
|
||||
expect(computeDisplayTtl(-1, 10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeTtlForExpiryEdit", () => {
|
||||
it("keeps a last-confirmed positive TTL during the in-flight zero-countdown window", () => {
|
||||
expect(computeTtlForExpiryEdit(true, 0, 5)).toBe(5);
|
||||
expect(computeTtlForExpiryEdit(true, -1, 5)).toBe(5);
|
||||
expect(computeTtlForExpiryEdit(0, 5)).toBe(5);
|
||||
expect(computeTtlForExpiryEdit(-1, 5)).toBe(5);
|
||||
});
|
||||
|
||||
it("uses the live positive countdown and preserves non-expiring server states", () => {
|
||||
expect(computeTtlForExpiryEdit(true, 3, 10)).toBe(3);
|
||||
expect(computeTtlForExpiryEdit(false, 3, 10)).toBe(10);
|
||||
expect(computeTtlForExpiryEdit(true, 0, -1)).toBe(-1);
|
||||
expect(computeTtlForExpiryEdit(true, 0, -2)).toBe(-2);
|
||||
expect(computeTtlForExpiryEdit(3, 10)).toBe(3);
|
||||
expect(computeTtlForExpiryEdit(0, -1)).toBe(-1);
|
||||
expect(computeTtlForExpiryEdit(0, -2)).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const MAX_REDIS_AUTO_REFRESH_INTERVAL_SECONDS = 3600;
|
|||
export const DEFAULT_REDIS_AUTO_REFRESH_INTERVAL_SECONDS = 5;
|
||||
|
||||
/** Action computed after evaluating one visible TTL countdown tick. */
|
||||
export type AutoRefreshTickAction = { type: "idle" } | { type: "decrement" };
|
||||
export type TtlCountdownTickAction = { type: "idle" } | { type: "decrement" };
|
||||
|
||||
/** Keep a user-entered polling frequency within a safe, whole-second range. */
|
||||
export function normalizeRedisAutoRefreshInterval(value: unknown): number {
|
||||
|
|
@ -22,22 +22,28 @@ export function normalizeRedisAutoRefreshInterval(value: unknown): number {
|
|||
/**
|
||||
* Evaluate one one-second visible TTL countdown tick.
|
||||
*
|
||||
* @param enabled Whether auto-refresh is currently toggled on.
|
||||
* @param countdownTtl Current countdown value in seconds.
|
||||
* @returns The action the caller should take this tick.
|
||||
*/
|
||||
export function computeAutoRefreshTick(enabled: boolean, countdownTtl: number): AutoRefreshTickAction {
|
||||
return enabled && countdownTtl > 0 ? { type: "decrement" } : { type: "idle" };
|
||||
export function computeTtlCountdownTick(countdownTtl: number): TtlCountdownTickAction {
|
||||
return countdownTtl > 0 ? { type: "decrement" } : { type: "idle" };
|
||||
}
|
||||
|
||||
/** Compute a TTL countdown from the time the server value was observed. */
|
||||
export function computeTtlCountdownValue(serverTtl: number, observedAtMs: number, nowMs: number): number {
|
||||
if (serverTtl <= 0) return serverTtl;
|
||||
const elapsedSeconds = Math.max(0, Math.floor((nowMs - observedAtMs) / 1000));
|
||||
return Math.max(serverTtl - elapsedSeconds, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the TTL value that should be displayed in the badge.
|
||||
*
|
||||
* When auto-refresh is active, the live countdown value is shown. The
|
||||
* The local countdown is independent from automatic network refreshes, so the
|
||||
* last known server TTL must not reappear after the countdown reaches zero.
|
||||
*/
|
||||
export function computeDisplayTtl(autoRefreshEnabled: boolean, countdownTtl: number, serverTtl: number): number {
|
||||
return autoRefreshEnabled ? Math.max(countdownTtl, 0) : serverTtl;
|
||||
export function computeDisplayTtl(countdownTtl: number, serverTtl: number): number {
|
||||
return serverTtl > 0 ? Math.max(countdownTtl, 0) : serverTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -47,8 +53,8 @@ export function computeDisplayTtl(autoRefreshEnabled: boolean, countdownTtl: num
|
|||
* is still in flight. Do not turn a last-confirmed positive Redis TTL into
|
||||
* PERSIST during that window.
|
||||
*/
|
||||
export function computeTtlForExpiryEdit(autoRefreshEnabled: boolean, countdownTtl: number, serverTtl: number): number {
|
||||
export function computeTtlForExpiryEdit(countdownTtl: number, serverTtl: number): number {
|
||||
if (serverTtl <= 0) return serverTtl;
|
||||
const displayedTtl = computeDisplayTtl(autoRefreshEnabled, countdownTtl, serverTtl);
|
||||
const displayedTtl = computeDisplayTtl(countdownTtl, serverTtl);
|
||||
return displayedTtl > 0 ? displayedTtl : serverTtl;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue