fix(redis): paginate stream entries
This commit is contained in:
1 parent
7775bbf8f8
commit
223e9b3a2f
21 files changed
+479
-33
No files matched your search
+24
-7
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user