fix(redis): paginate stream entries

This commit is contained in:
onenewcode authored and GitHub committed 2026-07-30 15:56:56 +08:00
1 parent 7775bbf8f8
commit 223e9b3a2f
21 files changed
+479 -33

No files matched your search

@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
redisGetValue: vi.fn(),
redisGetStreamEntries: vi.fn(),
redisGetStreamGroups: vi.fn(),
redisGetStreamConsumers: vi.fn(),
redisGetStreamPending: vi.fn(),
@@ -16,6 +17,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/lib/backend/api", () => ({
redisGetValue: mocks.redisGetValue,
redisGetStreamEntries: mocks.redisGetStreamEntries,
redisGetStreamGroups: mocks.redisGetStreamGroups,
redisGetStreamConsumers: mocks.redisGetStreamConsumers,
redisGetStreamPending: mocks.redisGetStreamPending,
@@ -40,11 +42,7 @@ vi.mock("vue-virtual-scroller", async () => {
const DynamicScroller = defineComponent({
props: { items: { type: Array, default: () => [] } },
setup(props, { slots }) {
return () =>
h(
"div",
(props.items as unknown[]).map((item, index) => slots.default?.({ item, active: true, index })),
);
return () => h("div", [...(props.items as unknown[]).map((item, index) => slots.default?.({ item, active: true, index })), ...(slots.after?.() ?? [])]);
},
});
const DynamicScrollerItem = defineComponent({
@@ -84,13 +82,17 @@ async function settle() {
await nextTick();
}
function streamValue() {
function streamEntry(id: string) {
return { id, fields: [{ field: "event", value: "login" }] };
}
function streamValue(entries = [] as ReturnType<typeof streamEntry>[], nextCursor?: string, total = entries.length) {
return {
key_display: "orders",
key_raw: "b3JkZXJz",
ttl: -1,
redis_type: "stream",
data: { kind: "stream" as const, entries: [] },
data: { kind: "stream" as const, entries, total, ...(nextCursor ? { next_cursor: nextCursor } : {}) },
};
}
@@ -158,6 +160,21 @@ function openGroups(host: HTMLElement) {
}
describe("RedisValueViewer stream monitoring", () => {
it("loads more Stream entries from the returned cursor", async () => {
mocks.redisGetValue.mockResolvedValue(streamValue([streamEntry("1714470000000-0")], "1714470000000-0"));
mocks.redisGetStreamEntries.mockResolvedValue({ entries: [streamEntry("1714470000001-0")] });
const host = mountViewer();
await settle();
expect(host.querySelectorAll("[data-redis-stream-entry]")).toHaveLength(1);
host.querySelector<HTMLButtonElement>("[data-redis-stream-entries-more]")!.click();
await settle();
expect(mocks.redisGetStreamEntries).toHaveBeenCalledWith("redis-1", 2, "b3JkZXJz", "1714470000000-0");
expect(host.querySelectorAll("[data-redis-stream-entry]")).toHaveLength(2);
expect(host.querySelector("[data-redis-stream-entries-more]")).toBeNull();
});
it("renders large counter values transported as decimal strings", async () => {
const unsafeMetric = "9007199254740992";
mocks.redisGetValue.mockResolvedValue(streamValue());
@@ -82,6 +82,9 @@ const loading = ref(false);
const loadingMore = ref(false);
let loadRequestId = 0;
const streamTab = ref<"entries" | "groups">("entries");
const streamEntries = ref<RedisStreamEntry[]>([]);
const streamEntriesCursor = ref<string | undefined>();
const streamEntriesLoadingMore = ref(false);
const streamGroups = ref<RedisStreamGroup[]>([]);
const streamGroupsLoaded = ref(false);
const streamGroupsLoading = ref(false);
@@ -112,6 +115,7 @@ let streamGroupsRequestId = 0;
let streamGroupDetailRequestId = 0;
let streamConsumersRequestId = 0;
let streamPendingRequestId = 0;
let streamEntriesRequestId = 0;
const editValue = ref("");
const savingString = ref(false);
const savingJson = ref(false);
@@ -316,8 +320,8 @@ const metadataSizeLabel = computed(() => {
return String(size);
});
const streamRows = computed<RedisStreamRow[]>(() => {
if (data.value?.data.kind !== "stream") return [];
return data.value.data.entries.map((entry, index) => ({
if (redisKind.value !== "stream") return [];
return streamEntries.value.map((entry, index) => ({
id: `${index}:${entry.id}`,
index,
entry,
@@ -434,6 +438,25 @@ type RedisStreamRow = {
entry: RedisStreamEntry;
};
function replaceStreamEntries(value: RedisValue) {
streamEntriesRequestId++;
streamEntriesLoadingMore.value = false;
if (value.data.kind === "stream") {
streamEntries.value = [...value.data.entries];
streamEntriesCursor.value = value.data.next_cursor;
return;
}
streamEntries.value = [];
streamEntriesCursor.value = undefined;
}
function resetStreamEntries() {
streamEntriesRequestId++;
streamEntries.value = [];
streamEntriesCursor.value = undefined;
streamEntriesLoadingMore.value = false;
}
function isSelectedStreamGroup(group: RedisStreamGroup, requestId = streamGroupDetailRequestId): boolean {
return requestId === streamGroupDetailRequestId && selectedStreamGroup.value?.name.raw_base64 === group.name.raw_base64;
}
@@ -464,6 +487,25 @@ function resetStreamMonitoring() {
streamGroupsError.value = "";
}
async function loadMoreStreamEntries() {
const cursor = streamEntriesCursor.value;
if (redisKind.value !== "stream" || !cursor || loading.value || streamEntriesLoadingMore.value) return;
const requestId = ++streamEntriesRequestId;
streamEntriesLoadingMore.value = true;
try {
const page = await api.redisGetStreamEntries(props.connectionId, props.db, props.keyRaw, cursor);
if (requestId !== streamEntriesRequestId || redisKind.value !== "stream" || streamEntriesCursor.value !== cursor) return;
streamEntries.value = [...streamEntries.value, ...page.entries];
streamEntriesCursor.value = page.next_cursor;
} catch (error) {
if (requestId === streamEntriesRequestId) toast(errorMessage(error), 3000);
} finally {
if (requestId === streamEntriesRequestId) streamEntriesLoadingMore.value = false;
}
}
async function loadStreamGroups(force = false): Promise<boolean> {
if (redisKind.value !== "stream") return false;
if (!force && (streamGroupsLoaded.value || streamGroupsLoading.value)) return streamGroupsLoaded.value;
@@ -902,6 +944,7 @@ async function load(options: { selectDefaultMember?: boolean; preserveDraft?: bo
data.value = null;
collectionItems.value = [];
scanCursor.value = undefined;
resetStreamEntries();
resetStreamMonitoring();
stopAutoRefresh();
emit("deleted", props.keyRaw);
@@ -929,6 +972,7 @@ async function load(options: { selectDefaultMember?: boolean; preserveDraft?: bo
emit("loaded", loadedValue);
scanCursor.value = redisValueCollectionScanCursor(loadedValue);
collectionItems.value = redisValueCollectionItems(loadedValue);
replaceStreamEntries(loadedValue);
if (loadedValue.data.kind !== "stream") resetStreamMonitoring();
// A foreground load replaces the current value, so it also starts a new
@@ -1050,7 +1094,8 @@ function requestDeleteKey() {
async function copyValue() {
if (!data.value) return;
const text = redisValueCopyText(data.value, collectionItems.value);
const value = data.value.data.kind === "stream" ? { ...data.value, data: { ...data.value.data, entries: streamEntries.value } } : data.value;
const text = redisValueCopyText(value, collectionItems.value);
try {
await copyToClipboard(text);
toast(t("redis.copied"), 2000);
@@ -1129,7 +1174,7 @@ function generateInsertStatements(): string | null {
break;
}
case "stream": {
for (const entry of data.value.data.entries) {
for (const entry of streamEntries.value) {
const fields = entry.fields.map(({ field, value }) => `${escapeRedisArg(field)} ${escapeRedisArg(value)}`).join(" ");
commands.push(`XADD ${escapeRedisArg(key)} * ${fields}`);
}
@@ -1772,6 +1817,7 @@ watch(
() => {
resetValueSearch();
valueViewerSearchActive.value = false;
resetStreamEntries();
resetStreamMonitoring();
},
);
@@ -2227,9 +2273,6 @@ defineExpose({ focusSearch });
</div>
<TabsContent value="entries" class="m-0 min-h-0 flex-1 flex flex-col">
<div class="px-4 py-1 text-xs text-muted-foreground border-b shrink-0">
{{ t("redis.entries", { count: streamRows.length }) }}
</div>
<DynamicScroller class="flex-1 overflow-y-auto" :items="streamRows" :min-item-size="REDIS_STREAM_MIN_ROW_HEIGHT" :buffer="600" key-field="id">
<template #default="{ item: row, active }">
<DynamicScrollerItem :item="row" :active="active" :size-dependencies="[streamFieldCount(row)]" :data-index="row.index">
@@ -2259,6 +2302,14 @@ defineExpose({ focusSearch });
</div>
</DynamicScrollerItem>
</template>
<template #after>
<div v-if="streamEntriesCursor" class="border-t p-2">
<Button data-redis-stream-entries-more variant="outline" size="sm" class="h-7 w-full text-xs" :disabled="loading || streamEntriesLoadingMore" @click="loadMoreStreamEntries">
<Loader2 v-if="streamEntriesLoadingMore" class="mr-1.5 h-3 w-3 animate-spin" />
{{ t("redis.loadMoreEntries") }}
</Button>
</div>
</template>
</DynamicScroller>
</TabsContent>