feat(redis): add stream consumer group monitoring
This commit is contained in:
1 parent
99cfa1b516
commit
31f0b7d6ef
23 files changed
+2092
-44
No files matched your search
@@ -1755,7 +1755,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
<!-- Redis mode: key browser -->
|
||||
<template v-else-if="activeTab.mode === 'redis'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<RedisKeyBrowser ref="redisKeyBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" :db="Number(activeTab.database)" :block-dangerous-redis-commands="props.blockDangerousRedisCommands" />
|
||||
<RedisKeyBrowser ref="redisKeyBrowserRef" :key="`${activeTab.id}:${activeTab.connectionId}:${activeTab.database}`" :connection-id="activeTab.connectionId" :db="Number(activeTab.database)" :block-dangerous-redis-commands="props.blockDangerousRedisCommands" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createApp, defineComponent, h, KeepAlive, nextTick, ref, type Component
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { calendarDateTimeToUnixSeconds } from "@/components/ui/date-time-picker/dateTimePicker";
|
||||
import type { RedisKeyInfo } from "@/lib/backend/api";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
redisScanKeysBatch: vi.fn(),
|
||||
@@ -367,6 +368,32 @@ function mountBrowser() {
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
}
|
||||
|
||||
function mountScopedBrowser() {
|
||||
const connectionId = ref("connection");
|
||||
const db = ref(0);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(RedisKeyBrowser, { connectionId: connectionId.value, db: db.value, blockDangerousRedisCommands: false });
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
|
||||
return {
|
||||
host,
|
||||
async setScope(nextConnectionId: string, nextDb: number) {
|
||||
connectionId.value = nextConnectionId;
|
||||
db.value = nextDb;
|
||||
await settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mountKeptAliveBrowser() {
|
||||
const active = ref(true);
|
||||
const host = document.createElement("div");
|
||||
@@ -522,6 +549,34 @@ afterEach(() => {
|
||||
resetLocalTimeZone();
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser scope changes", () => {
|
||||
it("reloads the new database and discards a late scan from the previous one", async () => {
|
||||
const previousDatabase = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>();
|
||||
const currentKey = { key_display: "db1-key", key_raw: "ZGIxLWtleQ==", key_type: "string", ttl: -1 };
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, db: number) => {
|
||||
if (db === 0) return previousDatabase.promise;
|
||||
return Promise.resolve({ cursor: 0, keys: [currentKey], total_keys: 1 });
|
||||
});
|
||||
const browser = mountScopedBrowser();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledWith("connection", 0, 0, "*", 100, 8, false);
|
||||
|
||||
await browser.setScope("connection", 1);
|
||||
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledWith("connection", 1, 0, "*", 100, 8, false);
|
||||
previousDatabase.resolve({
|
||||
cursor: 0,
|
||||
keys: [{ key_display: "db0-key", key_raw: "ZGIwLWtleQ==", key_type: "string", ttl: -1 }],
|
||||
total_keys: 1,
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(browser.host.textContent).toContain("db1-key");
|
||||
expect(browser.host.textContent).not.toContain("db0-key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser expiry creation", () => {
|
||||
it.each(["string", "hash", "list", "set", "zset", "stream", "json"] as const)("writes %s before applying one relative TTL", async (type) => {
|
||||
mountBrowser();
|
||||
|
||||
@@ -1432,9 +1432,19 @@ onDeactivated(pauseRedisBrowserBackgroundWork);
|
||||
onUnmounted(pauseRedisBrowserBackgroundWork);
|
||||
|
||||
watch(
|
||||
() => props.db,
|
||||
(db) => {
|
||||
() => [props.connectionId, props.db] as const,
|
||||
async ([connectionId, db]) => {
|
||||
// ContentArea remounts this browser for scope changes; keep embedded uses
|
||||
// in sync as well so an old scan cannot populate the new scope.
|
||||
commandDb.value = db;
|
||||
resetLoadedKeys();
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
} catch (error) {
|
||||
console.warn("[DBX] ensureConnected failed for", connectionId, error);
|
||||
}
|
||||
if (connectionId !== props.connectionId || db !== props.db) return;
|
||||
void loadKeys();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
redisGetValue: vi.fn(),
|
||||
redisGetStreamGroups: vi.fn(),
|
||||
redisGetStreamConsumers: vi.fn(),
|
||||
redisGetStreamPending: vi.fn(),
|
||||
redisSetTtl: vi.fn(),
|
||||
redisSetExpireAt: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
redisGetValue: mocks.redisGetValue,
|
||||
redisGetStreamGroups: mocks.redisGetStreamGroups,
|
||||
redisGetStreamConsumers: mocks.redisGetStreamConsumers,
|
||||
redisGetStreamPending: mocks.redisGetStreamPending,
|
||||
redisSetTtl: mocks.redisSetTtl,
|
||||
redisSetExpireAt: mocks.redisSetExpireAt,
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useEditorFontFamilyStyle", () => ({
|
||||
useEditorFontFamilyStyle: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast: mocks.toast }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/shikiJsonHighlighter", () => ({
|
||||
createShikiJsonHighlighter: vi.fn().mockResolvedValue(() => ""),
|
||||
}));
|
||||
|
||||
vi.mock("vue-virtual-scroller", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
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 })),
|
||||
);
|
||||
},
|
||||
});
|
||||
const DynamicScrollerItem = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => h("div", slots.default?.());
|
||||
},
|
||||
});
|
||||
const RecycleScroller = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => h("div", slots.default?.());
|
||||
},
|
||||
});
|
||||
|
||||
return { DynamicScroller, DynamicScrollerItem, RecycleScroller };
|
||||
});
|
||||
|
||||
import RedisValueViewer from "./RedisValueViewer.vue";
|
||||
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function settle() {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function streamValue() {
|
||||
return {
|
||||
key_display: "orders",
|
||||
key_raw: "b3JkZXJz",
|
||||
ttl: -1,
|
||||
redis_type: "stream",
|
||||
data: { kind: "stream" as const, entries: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function blob(raw_base64: string) {
|
||||
return { raw_base64, encoding: "utf8" as const };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((next) => {
|
||||
resolve = next;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function group(pending: number | string = 1) {
|
||||
return {
|
||||
name: blob("cGF5bWVudHM="),
|
||||
consumers: 1,
|
||||
pending,
|
||||
last_delivered_id: "1714470000000-0",
|
||||
entries_read: 12,
|
||||
lag: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function mountViewer() {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(RedisValueViewer, {
|
||||
connectionId: "redis-1",
|
||||
db: 2,
|
||||
keyDisplay: "orders",
|
||||
keyRaw: "b3JkZXJz",
|
||||
onDeleted: vi.fn(),
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: {
|
||||
en: {
|
||||
common: { loading: "Loading", retry: "Retry" },
|
||||
redis: { noConsumerGroups: "No consumer groups" },
|
||||
},
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
}),
|
||||
);
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
return host;
|
||||
}
|
||||
|
||||
function openGroups(host: HTMLElement) {
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-stream-groups-tab]")!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
|
||||
}
|
||||
|
||||
describe("RedisValueViewer stream monitoring", () => {
|
||||
it("renders large counter values transported as decimal strings", async () => {
|
||||
const unsafeMetric = "9007199254740992";
|
||||
mocks.redisGetValue.mockResolvedValue(streamValue());
|
||||
mocks.redisGetStreamGroups.mockResolvedValue([group(unsafeMetric)]);
|
||||
const host = mountViewer();
|
||||
await settle();
|
||||
|
||||
openGroups(host);
|
||||
await settle();
|
||||
|
||||
expect(host.querySelector<HTMLElement>("[data-redis-stream-group-row]")?.textContent).toContain(BigInt(unsafeMetric).toLocaleString());
|
||||
});
|
||||
|
||||
it("loads groups lazily, then loads a selected consumer's pending entries", async () => {
|
||||
mocks.redisGetValue.mockResolvedValue(streamValue());
|
||||
mocks.redisGetStreamGroups.mockResolvedValue([group()]);
|
||||
mocks.redisGetStreamConsumers.mockResolvedValue([{ name: blob("d29ya2VyLWE="), pending: 1, idle_ms: 1_200, inactive_ms: 800 }]);
|
||||
mocks.redisGetStreamPending.mockResolvedValueOnce({
|
||||
entries: [{ id: "1714470000000-0", consumer: blob("d29ya2VyLWE="), idle_ms: 2_400, deliveries: 2 }],
|
||||
next_cursor: "1714470000000-0",
|
||||
});
|
||||
mocks.redisGetStreamPending.mockResolvedValueOnce({
|
||||
entries: [{ id: "1714470000001-0", consumer: blob("d29ya2VyLWE="), idle_ms: 1_000, deliveries: 1 }],
|
||||
});
|
||||
const host = mountViewer();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetValue).toHaveBeenCalledWith("redis-1", 2, "b3JkZXJz");
|
||||
expect(mocks.redisGetStreamGroups).not.toHaveBeenCalled();
|
||||
|
||||
openGroups(host);
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetStreamGroups).toHaveBeenCalledWith("redis-1", 2, "b3JkZXJz");
|
||||
const groupRow = host.querySelector<HTMLElement>("[data-redis-stream-group-row]")!;
|
||||
expect(groupRow.textContent).toContain("payments");
|
||||
|
||||
groupRow.click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetStreamConsumers).toHaveBeenCalledWith("redis-1", 2, "b3JkZXJz", "cGF5bWVudHM=");
|
||||
expect(host.querySelector("[data-redis-stream-group-detail]")?.textContent).toContain("worker-a");
|
||||
expect(mocks.redisGetStreamPending).not.toHaveBeenCalled();
|
||||
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-stream-consumer-row]")!.click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetStreamPending).toHaveBeenNthCalledWith(1, "redis-1", 2, "b3JkZXJz", "cGF5bWVudHM=", undefined, "d29ya2VyLWE=");
|
||||
expect(host.querySelector("[data-redis-stream-consumer-crumb]")?.textContent).toContain("worker-a");
|
||||
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-stream-pending-more]")!.click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetStreamPending).toHaveBeenNthCalledWith(2, "redis-1", 2, "b3JkZXJz", "cGF5bWVudHM=", "1714470000000-0", "d29ya2VyLWE=");
|
||||
expect(host.querySelector("[data-redis-stream-group-detail]")?.textContent).toContain("1714470000001-0");
|
||||
});
|
||||
|
||||
it("renders group metrics and consumer navigation without an aggregate pending list", async () => {
|
||||
mocks.redisGetValue.mockResolvedValue(streamValue());
|
||||
mocks.redisGetStreamGroups.mockResolvedValue([group()]);
|
||||
mocks.redisGetStreamConsumers.mockResolvedValue([{ name: blob("d29ya2VyLWE="), pending: 1, idle_ms: 1_200, inactive_ms: 800 }]);
|
||||
const host = mountViewer();
|
||||
await settle();
|
||||
|
||||
openGroups(host);
|
||||
await settle();
|
||||
host.querySelector<HTMLElement>("[data-redis-stream-group-row]")!.click();
|
||||
await settle();
|
||||
|
||||
const summary = host.querySelector<HTMLElement>("[data-redis-stream-group-summary]")!;
|
||||
const consumerPanel = host.querySelector<HTMLElement>("[data-redis-stream-consumers]")!;
|
||||
const consumerTable = host.querySelector<HTMLTableElement>("[data-redis-stream-consumers-table]")!;
|
||||
const groupsTab = host.querySelector<HTMLElement>("[data-redis-stream-groups-tab]")!;
|
||||
const groupCrumb = host.querySelector<HTMLElement>("[data-redis-stream-group-crumb]")!;
|
||||
|
||||
expect(summary.classList.contains("rounded-lg")).toBe(true);
|
||||
expect(summary.classList.contains("border")).toBe(true);
|
||||
expect(consumerPanel.classList.contains("rounded-lg")).toBe(true);
|
||||
expect(consumerPanel.classList.contains("bg-card")).toBe(true);
|
||||
expect(consumerTable.classList.contains("table-fixed")).toBe(true);
|
||||
expect([...consumerTable.querySelectorAll("col")].map((column) => column.className)).toEqual(["w-1/4", "w-1/4", "w-1/4", "w-1/4"]);
|
||||
|
||||
const consumerHeaders = consumerTable.querySelectorAll<HTMLTableCellElement>("[data-redis-stream-consumer-header] th");
|
||||
const consumerCells = consumerTable.querySelectorAll<HTMLTableCellElement>("tbody td");
|
||||
|
||||
expect(host.querySelector("[data-redis-stream-pending]")).toBeNull();
|
||||
expect(groupsTab.className).toContain("group-data-[variant=line]/tabs-list:data-active:after:opacity-0");
|
||||
expect(groupCrumb.classList.contains("border-foreground")).toBe(true);
|
||||
expect(groupCrumb.classList.contains("border-transparent")).toBe(false);
|
||||
expect(consumerHeaders[1].classList.contains("text-right")).toBe(true);
|
||||
expect(consumerHeaders[2].classList.contains("text-right")).toBe(true);
|
||||
expect(consumerHeaders[3].classList.contains("text-right")).toBe(true);
|
||||
expect(consumerCells[0].classList.contains("text-left")).toBe(true);
|
||||
expect(consumerCells[1].classList.contains("text-right")).toBe(true);
|
||||
expect(consumerCells[2].classList.contains("text-right")).toBe(true);
|
||||
expect(consumerCells[3].classList.contains("text-right")).toBe(true);
|
||||
expect(consumerTable.querySelector("[data-redis-stream-consumer-row] svg")).toBeNull();
|
||||
});
|
||||
|
||||
it("drills into a consumer and reloads its pending entries server-side", async () => {
|
||||
mocks.redisGetValue.mockResolvedValue(streamValue());
|
||||
mocks.redisGetStreamGroups.mockResolvedValue([group()]);
|
||||
mocks.redisGetStreamConsumers.mockResolvedValue([
|
||||
{ name: blob("d29ya2VyLWE="), pending: 1, idle_ms: 1_200, inactive_ms: 800 },
|
||||
{ name: blob("d29ya2VyLWI="), pending: 2, idle_ms: 900, inactive_ms: 600 },
|
||||
]);
|
||||
mocks.redisGetStreamPending.mockResolvedValueOnce({ entries: [{ id: "1714470000001-0", consumer: blob("d29ya2VyLWI="), idle_ms: 1_000, deliveries: 1 }] });
|
||||
const host = mountViewer();
|
||||
await settle();
|
||||
|
||||
openGroups(host);
|
||||
await settle();
|
||||
host.querySelector<HTMLElement>("[data-redis-stream-group-row]")!.click();
|
||||
await settle();
|
||||
|
||||
host.querySelectorAll<HTMLButtonElement>("[data-redis-stream-consumer-row]")[1].click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetStreamPending).toHaveBeenNthCalledWith(1, "redis-1", 2, "b3JkZXJz", "cGF5bWVudHM=", undefined, "d29ya2VyLWI=");
|
||||
const groupCrumb = host.querySelector<HTMLElement>("[data-redis-stream-group-crumb]")!;
|
||||
const consumerCrumb = host.querySelector<HTMLElement>("[data-redis-stream-consumer-crumb]")!;
|
||||
expect(consumerCrumb.textContent).toContain("worker-b");
|
||||
expect(groupCrumb.classList.contains("border-transparent")).toBe(true);
|
||||
expect(consumerCrumb.classList.contains("border-foreground")).toBe(true);
|
||||
expect(host.querySelector("[data-redis-stream-consumers]")).toBeNull();
|
||||
expect(host.querySelectorAll("[data-redis-stream-pending-header] th")).toHaveLength(3);
|
||||
expect([...host.querySelectorAll("[data-redis-stream-pending-table] col")].map((column) => column.className)).toEqual(["w-[44%]", "w-[36%]", "w-[20%]"]);
|
||||
expect(host.querySelector("[data-redis-stream-pending]")?.textContent).toContain("1714470000001-0");
|
||||
expect(host.querySelector<HTMLTableCellElement>("[data-redis-stream-pending-table] tbody td")?.textContent?.trim()).toBe("1714470000001-0");
|
||||
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-stream-group-crumb]")!.click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetStreamPending).toHaveBeenCalledTimes(1);
|
||||
expect(host.querySelector("[data-redis-stream-consumers]")).not.toBeNull();
|
||||
expect(host.querySelector("[data-redis-stream-consumer-crumb]")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an empty state and lets a failed group query be retried", async () => {
|
||||
mocks.redisGetValue.mockResolvedValue(streamValue());
|
||||
mocks.redisGetStreamGroups.mockRejectedValueOnce(new Error("NOPERM XINFO is not allowed")).mockResolvedValueOnce([]);
|
||||
const host = mountViewer();
|
||||
await settle();
|
||||
|
||||
openGroups(host);
|
||||
await settle();
|
||||
|
||||
expect(host.querySelector("[data-redis-stream-groups-retry]")).not.toBeNull();
|
||||
expect(host.textContent).toContain("NOPERM XINFO is not allowed");
|
||||
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-stream-groups-retry]")!.click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetStreamGroups).toHaveBeenCalledTimes(2);
|
||||
expect(host.textContent).toContain("No consumer groups");
|
||||
});
|
||||
|
||||
it("manually refreshes the active group and its dependent views", async () => {
|
||||
mocks.redisGetValue.mockResolvedValue(streamValue());
|
||||
mocks.redisGetStreamGroups.mockResolvedValue([group()]);
|
||||
mocks.redisGetStreamConsumers.mockResolvedValue([{ name: blob("d29ya2VyLWE="), pending: 0, idle_ms: 0 }]);
|
||||
mocks.redisGetStreamPending.mockResolvedValue({ entries: [] });
|
||||
const host = mountViewer();
|
||||
await settle();
|
||||
|
||||
openGroups(host);
|
||||
await settle();
|
||||
host.querySelector<HTMLElement>("[data-redis-stream-group-row]")!.click();
|
||||
await settle();
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-stream-consumer-row]")!.click();
|
||||
await settle();
|
||||
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-value-refresh]")!.click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.redisGetStreamGroups).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.redisGetStreamConsumers).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.redisGetStreamPending).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns to the consumer list when refresh removes the selected consumer", async () => {
|
||||
const refreshedPending = deferred<{ entries: Array<{ id: string; consumer: ReturnType<typeof blob>; idle_ms: number; deliveries: number }> }>();
|
||||
mocks.redisGetValue.mockResolvedValue(streamValue());
|
||||
mocks.redisGetStreamGroups.mockResolvedValue([group()]);
|
||||
mocks.redisGetStreamConsumers.mockResolvedValueOnce([{ name: blob("d29ya2VyLWE="), pending: 1, idle_ms: 1_200, inactive_ms: 800 }]).mockResolvedValueOnce([]);
|
||||
mocks.redisGetStreamPending.mockResolvedValueOnce({ entries: [{ id: "1714470000000-0", consumer: blob("d29ya2VyLWE="), idle_ms: 2_400, deliveries: 2 }] }).mockReturnValueOnce(refreshedPending.promise);
|
||||
const host = mountViewer();
|
||||
await settle();
|
||||
|
||||
openGroups(host);
|
||||
await settle();
|
||||
host.querySelector<HTMLElement>("[data-redis-stream-group-row]")!.click();
|
||||
await settle();
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-stream-consumer-row]")!.click();
|
||||
await settle();
|
||||
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-value-refresh]")!.click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetStreamConsumers).toHaveBeenCalledTimes(2);
|
||||
expect(host.querySelector("[data-redis-stream-consumer-crumb]")).toBeNull();
|
||||
expect(host.querySelector("[data-redis-stream-consumers]")).not.toBeNull();
|
||||
expect(host.querySelector("[data-redis-stream-pending]")).toBeNull();
|
||||
|
||||
refreshedPending.resolve({ entries: [{ id: "1714470000001-0", consumer: blob("d29ya2VyLWE="), idle_ms: 1_000, deliveries: 1 }] });
|
||||
await settle();
|
||||
|
||||
expect(host.querySelector("[data-redis-stream-consumer-crumb]")).toBeNull();
|
||||
expect(host.querySelector("[data-redis-stream-pending]")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns to the group list when refresh removes the selected group", async () => {
|
||||
mocks.redisGetValue.mockResolvedValue(streamValue());
|
||||
mocks.redisGetStreamGroups.mockResolvedValueOnce([group()]).mockResolvedValueOnce([]);
|
||||
mocks.redisGetStreamConsumers.mockResolvedValue([{ name: blob("d29ya2VyLWE="), pending: 1, idle_ms: 1_200, inactive_ms: 800 }]);
|
||||
const host = mountViewer();
|
||||
await settle();
|
||||
|
||||
openGroups(host);
|
||||
await settle();
|
||||
host.querySelector<HTMLElement>("[data-redis-stream-group-row]")!.click();
|
||||
await settle();
|
||||
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-value-refresh]")!.click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetStreamGroups).toHaveBeenCalledTimes(2);
|
||||
expect(host.querySelector("[data-redis-stream-group-detail]")).toBeNull();
|
||||
expect(host.querySelector("[data-redis-stream-groups]")).toBeNull();
|
||||
expect(host.textContent).toContain("No consumer groups");
|
||||
});
|
||||
|
||||
it("retries failed consumer and pending reads without retaining stale data", async () => {
|
||||
mocks.redisGetValue.mockResolvedValue(streamValue());
|
||||
mocks.redisGetStreamGroups.mockResolvedValue([group()]);
|
||||
mocks.redisGetStreamConsumers.mockRejectedValueOnce(new Error("NOPERM XINFO is not allowed")).mockResolvedValueOnce([{ name: blob("d29ya2VyLWE="), pending: 1, idle_ms: 1_200, inactive_ms: 800 }]);
|
||||
mocks.redisGetStreamPending.mockRejectedValueOnce(new Error("NOPERM XPENDING is not allowed")).mockResolvedValueOnce({
|
||||
entries: [{ id: "1714470000000-0", consumer: blob("d29ya2VyLWE="), idle_ms: 2_400, deliveries: 2 }],
|
||||
});
|
||||
const host = mountViewer();
|
||||
await settle();
|
||||
|
||||
openGroups(host);
|
||||
await settle();
|
||||
host.querySelector<HTMLElement>("[data-redis-stream-group-row]")!.click();
|
||||
await settle();
|
||||
|
||||
expect(host.querySelector("[data-redis-stream-consumers-retry]")).not.toBeNull();
|
||||
expect(host.textContent).toContain("NOPERM XINFO is not allowed");
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-stream-consumers-retry]")!.click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetStreamConsumers).toHaveBeenCalledTimes(2);
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-stream-consumer-row]")!.click();
|
||||
await settle();
|
||||
|
||||
expect(host.querySelector("[data-redis-stream-pending-retry]")).not.toBeNull();
|
||||
expect(host.textContent).toContain("NOPERM XPENDING is not allowed");
|
||||
host.querySelector<HTMLButtonElement>("[data-redis-stream-pending-retry]")!.click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisGetStreamPending).toHaveBeenCalledTimes(2);
|
||||
expect(host.querySelector("[data-redis-stream-pending]")?.textContent).toContain("1714470000000-0");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import DateTimePicker from "@/components/ui/date-time-picker/DateTimePicker.vue";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -16,7 +17,7 @@ import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import JsonTree from "@/components/common/JsonTree.vue";
|
||||
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { RedisBlob, RedisHashItem, RedisKeyInfo, RedisListItem, RedisSetItem, RedisStreamEntry, RedisValue, RedisZsetItem } from "@/lib/backend/api";
|
||||
import type { RedisBlob, RedisHashItem, RedisKeyInfo, RedisListItem, RedisSetItem, RedisStreamConsumer, RedisStreamEntry, RedisStreamGroup, RedisStreamPendingEntry, RedisValue, RedisZsetItem } from "@/lib/backend/api";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
|
||||
@@ -80,6 +81,37 @@ const data = ref<RedisValue | null>(null);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
let loadRequestId = 0;
|
||||
const streamTab = ref<"entries" | "groups">("entries");
|
||||
const streamGroups = ref<RedisStreamGroup[]>([]);
|
||||
const streamGroupsLoaded = ref(false);
|
||||
const streamGroupsLoading = ref(false);
|
||||
const streamGroupsError = ref("");
|
||||
const selectedStreamGroup = ref<RedisStreamGroup | null>(null);
|
||||
const selectedStreamConsumer = ref<RedisStreamConsumer | null>(null);
|
||||
const streamConsumers = ref<RedisStreamConsumer[]>([]);
|
||||
const streamConsumersLoading = ref(false);
|
||||
const streamConsumersError = ref("");
|
||||
const streamPendingEntries = ref<RedisStreamPendingEntry[]>([]);
|
||||
const streamPendingCursor = ref<string | undefined>();
|
||||
const streamPendingLoading = ref(false);
|
||||
const streamPendingLoadingMore = ref(false);
|
||||
const streamPendingError = ref("");
|
||||
const streamDateTimeFormatter = computed(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(locale.value, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}),
|
||||
);
|
||||
let streamGroupsRequestId = 0;
|
||||
let streamGroupDetailRequestId = 0;
|
||||
let streamConsumersRequestId = 0;
|
||||
let streamPendingRequestId = 0;
|
||||
const editValue = ref("");
|
||||
const savingString = ref(false);
|
||||
const savingJson = ref(false);
|
||||
@@ -402,6 +434,256 @@ type RedisStreamRow = {
|
||||
entry: RedisStreamEntry;
|
||||
};
|
||||
|
||||
function isSelectedStreamGroup(group: RedisStreamGroup, requestId = streamGroupDetailRequestId): boolean {
|
||||
return requestId === streamGroupDetailRequestId && selectedStreamGroup.value?.name.raw_base64 === group.name.raw_base64;
|
||||
}
|
||||
|
||||
function resetStreamGroupDetail() {
|
||||
streamGroupDetailRequestId++;
|
||||
streamConsumersRequestId++;
|
||||
streamPendingRequestId++;
|
||||
selectedStreamGroup.value = null;
|
||||
selectedStreamConsumer.value = null;
|
||||
streamConsumers.value = [];
|
||||
streamConsumersLoading.value = false;
|
||||
streamConsumersError.value = "";
|
||||
streamPendingEntries.value = [];
|
||||
streamPendingCursor.value = undefined;
|
||||
streamPendingLoading.value = false;
|
||||
streamPendingLoadingMore.value = false;
|
||||
streamPendingError.value = "";
|
||||
}
|
||||
|
||||
function resetStreamMonitoring() {
|
||||
streamGroupsRequestId++;
|
||||
resetStreamGroupDetail();
|
||||
streamTab.value = "entries";
|
||||
streamGroups.value = [];
|
||||
streamGroupsLoaded.value = false;
|
||||
streamGroupsLoading.value = false;
|
||||
streamGroupsError.value = "";
|
||||
}
|
||||
|
||||
async function loadStreamGroups(force = false): Promise<boolean> {
|
||||
if (redisKind.value !== "stream") return false;
|
||||
if (!force && (streamGroupsLoaded.value || streamGroupsLoading.value)) return streamGroupsLoaded.value;
|
||||
|
||||
const requestId = ++streamGroupsRequestId;
|
||||
streamGroupsLoading.value = true;
|
||||
streamGroupsError.value = "";
|
||||
try {
|
||||
const groups = await api.redisGetStreamGroups(props.connectionId, props.db, props.keyRaw);
|
||||
if (requestId !== streamGroupsRequestId || redisKind.value !== "stream") return false;
|
||||
|
||||
streamGroups.value = groups;
|
||||
streamGroupsLoaded.value = true;
|
||||
const selected = selectedStreamGroup.value;
|
||||
if (selected && !groups.some((group) => group.name.raw_base64 === selected.name.raw_base64)) {
|
||||
resetStreamGroupDetail();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (requestId === streamGroupsRequestId) streamGroupsError.value = errorMessage(error);
|
||||
return false;
|
||||
} finally {
|
||||
if (requestId === streamGroupsRequestId) streamGroupsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStreamConsumers(group: RedisStreamGroup, requestId: number) {
|
||||
if (!isSelectedStreamGroup(group, requestId)) return;
|
||||
const consumerRequestId = ++streamConsumersRequestId;
|
||||
streamConsumersLoading.value = true;
|
||||
streamConsumersError.value = "";
|
||||
try {
|
||||
const consumers = await api.redisGetStreamConsumers(props.connectionId, props.db, props.keyRaw, group.name.raw_base64);
|
||||
if (!isSelectedStreamGroup(group, requestId) || consumerRequestId !== streamConsumersRequestId) return;
|
||||
streamConsumers.value = consumers;
|
||||
const selectedConsumerRaw = selectedStreamConsumer.value?.name.raw_base64;
|
||||
if (selectedConsumerRaw) {
|
||||
const selectedConsumer = consumers.find((consumer) => consumer.name.raw_base64 === selectedConsumerRaw);
|
||||
if (selectedConsumer) {
|
||||
selectedStreamConsumer.value = selectedConsumer;
|
||||
} else {
|
||||
// A refresh can race with XGROUP DELCONSUMER. Do not leave a stale
|
||||
// consumer detail open or allow its pending request to update the view.
|
||||
resetStreamConsumerDetail();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSelectedStreamGroup(group, requestId) && consumerRequestId === streamConsumersRequestId) {
|
||||
streamConsumersError.value = errorMessage(error);
|
||||
}
|
||||
} finally {
|
||||
if (isSelectedStreamGroup(group, requestId) && consumerRequestId === streamConsumersRequestId) {
|
||||
streamConsumersLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStreamPendingPage(group: RedisStreamGroup, cursor?: string, append = false, requestId = streamGroupDetailRequestId) {
|
||||
if (!isSelectedStreamGroup(group, requestId)) return;
|
||||
const pendingRequestId = ++streamPendingRequestId;
|
||||
const consumerRaw = selectedStreamConsumer.value?.name.raw_base64;
|
||||
if (append) {
|
||||
streamPendingLoadingMore.value = true;
|
||||
} else {
|
||||
streamPendingLoading.value = true;
|
||||
streamPendingError.value = "";
|
||||
streamPendingEntries.value = [];
|
||||
streamPendingCursor.value = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const page = consumerRaw ? await api.redisGetStreamPending(props.connectionId, props.db, props.keyRaw, group.name.raw_base64, cursor, consumerRaw) : await api.redisGetStreamPending(props.connectionId, props.db, props.keyRaw, group.name.raw_base64, cursor);
|
||||
if (!isSelectedStreamGroup(group, requestId) || pendingRequestId !== streamPendingRequestId || selectedStreamConsumer.value?.name.raw_base64 !== consumerRaw) return;
|
||||
streamPendingEntries.value = append ? [...streamPendingEntries.value, ...page.entries] : page.entries;
|
||||
streamPendingCursor.value = page.next_cursor;
|
||||
} catch (error) {
|
||||
if (isSelectedStreamGroup(group, requestId) && pendingRequestId === streamPendingRequestId && selectedStreamConsumer.value?.name.raw_base64 === consumerRaw) {
|
||||
streamPendingError.value = errorMessage(error);
|
||||
}
|
||||
} finally {
|
||||
if (isSelectedStreamGroup(group, requestId) && pendingRequestId === streamPendingRequestId && selectedStreamConsumer.value?.name.raw_base64 === consumerRaw) {
|
||||
if (append) streamPendingLoadingMore.value = false;
|
||||
else streamPendingLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectStreamGroup(group: RedisStreamGroup, reload = false) {
|
||||
if (!reload && selectedStreamGroup.value?.name.raw_base64 === group.name.raw_base64) return;
|
||||
|
||||
const preservedConsumer = reload && selectedStreamGroup.value?.name.raw_base64 === group.name.raw_base64 ? selectedStreamConsumer.value : null;
|
||||
streamGroupDetailRequestId++;
|
||||
streamConsumersRequestId++;
|
||||
streamPendingRequestId++;
|
||||
const requestId = streamGroupDetailRequestId;
|
||||
selectedStreamGroup.value = group;
|
||||
selectedStreamConsumer.value = preservedConsumer;
|
||||
streamConsumers.value = [];
|
||||
streamConsumersLoading.value = false;
|
||||
streamConsumersError.value = "";
|
||||
streamPendingEntries.value = [];
|
||||
streamPendingCursor.value = undefined;
|
||||
streamPendingLoading.value = false;
|
||||
streamPendingLoadingMore.value = false;
|
||||
streamPendingError.value = "";
|
||||
void loadStreamConsumers(group, requestId);
|
||||
if (selectedStreamConsumer.value) void loadStreamPendingPage(group, undefined, false, requestId);
|
||||
}
|
||||
|
||||
function selectStreamConsumer(consumer: RedisStreamConsumer) {
|
||||
const group = selectedStreamGroup.value;
|
||||
if (!group || selectedStreamConsumer.value?.name.raw_base64 === consumer.name.raw_base64) return;
|
||||
|
||||
streamPendingRequestId++;
|
||||
selectedStreamConsumer.value = consumer;
|
||||
streamPendingEntries.value = [];
|
||||
streamPendingCursor.value = undefined;
|
||||
streamPendingLoading.value = false;
|
||||
streamPendingLoadingMore.value = false;
|
||||
streamPendingError.value = "";
|
||||
void loadStreamPendingPage(group, undefined, false, streamGroupDetailRequestId);
|
||||
}
|
||||
|
||||
function resetStreamConsumerDetail() {
|
||||
if (!selectedStreamConsumer.value) return;
|
||||
|
||||
streamPendingRequestId++;
|
||||
selectedStreamConsumer.value = null;
|
||||
streamPendingEntries.value = [];
|
||||
streamPendingCursor.value = undefined;
|
||||
streamPendingLoading.value = false;
|
||||
streamPendingLoadingMore.value = false;
|
||||
streamPendingError.value = "";
|
||||
}
|
||||
|
||||
function retryStreamGroups() {
|
||||
void loadStreamGroups(true);
|
||||
}
|
||||
|
||||
function retryStreamConsumers() {
|
||||
const group = selectedStreamGroup.value;
|
||||
if (!group) return;
|
||||
void loadStreamConsumers(group, streamGroupDetailRequestId);
|
||||
}
|
||||
|
||||
function retryStreamPending() {
|
||||
const group = selectedStreamGroup.value;
|
||||
if (!group || !selectedStreamConsumer.value) return;
|
||||
void loadStreamPendingPage(group, undefined, false, streamGroupDetailRequestId);
|
||||
}
|
||||
|
||||
function loadMoreStreamPending() {
|
||||
const group = selectedStreamGroup.value;
|
||||
const cursor = streamPendingCursor.value;
|
||||
if (!group || !selectedStreamConsumer.value || !cursor || streamPendingLoading.value || streamPendingLoadingMore.value) return;
|
||||
void loadStreamPendingPage(group, cursor, true, streamGroupDetailRequestId);
|
||||
}
|
||||
|
||||
async function refreshValueAndStreamGroups() {
|
||||
const refreshGroups = redisKind.value === "stream" && streamTab.value === "groups";
|
||||
const selectedGroupRaw = selectedStreamGroup.value?.name.raw_base64;
|
||||
try {
|
||||
const applied = await load();
|
||||
if (!applied || !refreshGroups || redisKind.value !== "stream") return;
|
||||
const loaded = await loadStreamGroups(true);
|
||||
if (!loaded || !selectedGroupRaw) return;
|
||||
const selected = streamGroups.value.find((group) => group.name.raw_base64 === selectedGroupRaw);
|
||||
if (selected) selectStreamGroup(selected, true);
|
||||
} catch (error) {
|
||||
toast(errorMessage(error), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function streamMetricInteger(value: number | string | undefined): bigint | null {
|
||||
if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
|
||||
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
|
||||
try {
|
||||
return BigInt(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatStreamMetric(value: number | string | undefined): string {
|
||||
const integer = streamMetricInteger(value);
|
||||
return integer == null ? "-" : integer.toLocaleString();
|
||||
}
|
||||
|
||||
function formatStreamDuration(value: number | string | undefined): string {
|
||||
const integer = streamMetricInteger(value);
|
||||
if (integer == null) return "-";
|
||||
if (integer > BigInt(Number.MAX_SAFE_INTEGER)) return `${integer.toLocaleString()} ms`;
|
||||
|
||||
const milliseconds = Number(integer);
|
||||
if (milliseconds < 1_000) return `${milliseconds.toLocaleString()} ms`;
|
||||
const seconds = milliseconds / 1_000;
|
||||
if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)} s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ${Math.floor(seconds % 60)}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
||||
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
||||
}
|
||||
|
||||
function formatStreamDurationTitle(value: number | string | undefined): string {
|
||||
const formatted = formatStreamMetric(value);
|
||||
return formatted === "-" ? formatted : `${formatted} ms`;
|
||||
}
|
||||
|
||||
function formatStreamDateTime(timestamp: number): string {
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return "-";
|
||||
return streamDateTimeFormatter.value.format(new Date(timestamp));
|
||||
}
|
||||
|
||||
function formatStreamLastDelivery(value: number | string | undefined): string {
|
||||
const idle = streamMetricInteger(value);
|
||||
if (idle == null || idle > BigInt(Number.MAX_SAFE_INTEGER)) return "-";
|
||||
return formatStreamDateTime(Date.now() - Number(idle));
|
||||
}
|
||||
|
||||
function collectionCountLabel(kind: "items" | "fields" | "members", loaded: number, total?: number | null) {
|
||||
if (total == null || total === loaded) return t(`redis.${kind}`, { count: loaded });
|
||||
return t(`redis.loaded${kind[0].toUpperCase()}${kind.slice(1)}`, { loaded, total });
|
||||
@@ -620,6 +902,7 @@ async function load(options: { selectDefaultMember?: boolean; preserveDraft?: bo
|
||||
data.value = null;
|
||||
collectionItems.value = [];
|
||||
scanCursor.value = undefined;
|
||||
resetStreamMonitoring();
|
||||
stopAutoRefresh();
|
||||
emit("deleted", props.keyRaw);
|
||||
return true;
|
||||
@@ -646,6 +929,7 @@ async function load(options: { selectDefaultMember?: boolean; preserveDraft?: bo
|
||||
emit("loaded", loadedValue);
|
||||
scanCursor.value = redisValueCollectionScanCursor(loadedValue);
|
||||
collectionItems.value = redisValueCollectionItems(loadedValue);
|
||||
if (loadedValue.data.kind !== "stream") resetStreamMonitoring();
|
||||
|
||||
// A foreground load replaces the current value, so it also starts a new
|
||||
// draft lifecycle. Member saves opt out until selection is restored.
|
||||
@@ -1484,13 +1768,18 @@ watch(contentSearchText, () => {
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.keyRaw,
|
||||
() => [props.connectionId, props.db, props.keyRaw],
|
||||
() => {
|
||||
resetValueSearch();
|
||||
valueViewerSearchActive.value = false;
|
||||
resetStreamMonitoring();
|
||||
},
|
||||
);
|
||||
|
||||
watch(streamTab, (tab) => {
|
||||
if (tab === "groups" && redisKind.value === "stream") void loadStreamGroups();
|
||||
});
|
||||
|
||||
watch(stringValueView, () => {
|
||||
if (!showMemberDetail.value) {
|
||||
valueSearchMatchIndex.value = 0;
|
||||
@@ -1562,7 +1851,7 @@ defineExpose({ focusSearch });
|
||||
<div class="shrink-0 border-b bg-background">
|
||||
<div class="flex h-9 items-center gap-2 px-4">
|
||||
<span class="dbx-editor-font-family min-w-0 flex-1 truncate text-sm font-semibold">{{ formatValue(data.key_display) }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 animate-none" :disabled="hasUnsavedRedisDraft" @click="load"><RefreshCw class="h-3.5 w-3.5 animate-none" /></Button>
|
||||
<Button data-redis-value-refresh variant="ghost" size="icon" class="h-7 w-7 shrink-0 animate-none" :disabled="hasUnsavedRedisDraft" @click="refreshValueAndStreamGroups"><RefreshCw class="h-3.5 w-3.5 animate-none" /></Button>
|
||||
<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>
|
||||
@@ -1904,36 +2193,272 @@ defineExpose({ focusSearch });
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
|
||||
<!-- Stream (readonly) -->
|
||||
<!-- Stream monitoring -->
|
||||
<div v-else-if="redisKind === 'stream'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<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">
|
||||
<div data-redis-stream-entry class="dbx-editor-font-family px-4 py-2 border-b text-sm hover:bg-accent/50">
|
||||
<div class="mb-1 text-xs text-muted-foreground">{{ row.entry.id }}</div>
|
||||
<div
|
||||
v-for="(field, fieldIndex) in row.entry.fields"
|
||||
:key="`${row.id}:${field.field}:${fieldIndex}`"
|
||||
class="grid grid-cols-[minmax(6rem,0.35fr)_1fr_56px] gap-3 py-0.5 group cursor-pointer"
|
||||
:class="{ 'bg-accent/60': isSelectedMember(field.field, field.value, streamFieldSelectionIdentity(row.entry.id, fieldIndex)) }"
|
||||
@click="viewMember(field.field, field.value, { kind: 'stream', field: field.field, canEdit: false }, streamFieldSelectionIdentity(row.entry.id, fieldIndex))"
|
||||
>
|
||||
<span class="truncate text-blue-500">{{ field.field }}</span>
|
||||
<span class="truncate text-muted-foreground">{{ field.value }}</span>
|
||||
<span class="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.viewMember')" @click.stop="viewMember(field.field, field.value, { kind: 'stream', field: field.field, canEdit: false }, streamFieldSelectionIdentity(row.entry.id, fieldIndex))"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.copyMember')" @click.stop="copyMember(field.value)"><Copy class="w-3 h-3" /></Button>
|
||||
</span>
|
||||
<Tabs v-model="streamTab" :unmount-on-hide="false" class="h-full min-h-0 gap-0">
|
||||
<div class="flex h-9 shrink-0 items-stretch overflow-x-auto border-b px-4">
|
||||
<TabsList variant="line" class="h-full shrink-0 gap-0 p-0">
|
||||
<TabsTrigger value="entries" data-redis-stream-entries-tab class="h-full flex-none rounded-none px-3 text-xs group-data-horizontal/tabs:after:bottom-0">{{ t("redis.streamData") }}</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="groups"
|
||||
data-redis-stream-groups-tab
|
||||
:class="['h-full flex-none rounded-none px-3 text-xs group-data-horizontal/tabs:after:bottom-0', selectedStreamGroup && 'data-active:text-foreground/60 group-data-[variant=line]/tabs-list:data-active:after:opacity-0']"
|
||||
@click="resetStreamGroupDetail"
|
||||
>
|
||||
{{ t("redis.consumerGroups") }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<template v-if="streamTab === 'groups' && selectedStreamGroup">
|
||||
<span class="mx-1 h-4 w-px shrink-0 self-center bg-border" aria-hidden="true" />
|
||||
<button
|
||||
data-redis-stream-group-crumb
|
||||
type="button"
|
||||
class="relative inline-flex h-full max-w-48 shrink-0 items-center border-b-2 px-3 text-left text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
:class="selectedStreamConsumer ? 'border-transparent text-foreground/60 hover:text-foreground' : 'border-foreground text-foreground'"
|
||||
:title="formatValue(selectedStreamGroup.name)"
|
||||
@click="resetStreamConsumerDetail"
|
||||
>
|
||||
<span class="dbx-editor-font-family truncate">{{ formatValue(selectedStreamGroup.name) }}</span>
|
||||
</button>
|
||||
<span v-if="selectedStreamConsumer" data-redis-stream-consumer-crumb class="dbx-editor-font-family inline-flex h-full max-w-48 shrink-0 items-center truncate border-b-2 border-foreground px-3 text-xs font-medium text-foreground" :title="formatValue(selectedStreamConsumer.name)">
|
||||
<span class="truncate">{{ formatValue(selectedStreamConsumer.name) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</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">
|
||||
<div data-redis-stream-entry class="dbx-editor-font-family px-4 py-2 border-b text-sm hover:bg-accent/50">
|
||||
<div class="mb-1 text-xs text-muted-foreground">{{ row.entry.id }}</div>
|
||||
<div
|
||||
v-for="(field, fieldIndex) in row.entry.fields"
|
||||
:key="`${row.id}:${field.field}:${fieldIndex}`"
|
||||
class="grid grid-cols-[minmax(6rem,0.35fr)_1fr_56px] gap-3 py-0.5 group cursor-pointer"
|
||||
:class="{ 'bg-accent/60': isSelectedMember(field.field, field.value, streamFieldSelectionIdentity(row.entry.id, fieldIndex)) }"
|
||||
@click="viewMember(field.field, field.value, { kind: 'stream', field: field.field, canEdit: false }, streamFieldSelectionIdentity(row.entry.id, fieldIndex))"
|
||||
>
|
||||
<span class="truncate text-blue-500">{{ field.field }}</span>
|
||||
<span class="truncate text-muted-foreground">{{ field.value }}</span>
|
||||
<span class="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5 opacity-0 group-hover:opacity-100"
|
||||
:title="t('redis.viewMember')"
|
||||
@click.stop="viewMember(field.field, field.value, { kind: 'stream', field: field.field, canEdit: false }, streamFieldSelectionIdentity(row.entry.id, fieldIndex))"
|
||||
><Eye class="w-3 h-3"
|
||||
/></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.copyMember')" @click.stop="copyMember(field.value)"><Copy class="w-3 h-3" /></Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="groups" class="m-0 min-h-0 flex-1 flex flex-col">
|
||||
<template v-if="!selectedStreamGroup">
|
||||
<div class="flex shrink-0 items-center gap-2 border-b px-4 py-1.5 text-xs text-muted-foreground">
|
||||
<span>{{ t("redis.consumerGroups") }}</span>
|
||||
<Loader2 v-if="streamGroupsLoading" class="h-3 w-3 animate-spin" />
|
||||
</div>
|
||||
|
||||
<div v-if="streamGroupsLoading && !streamGroupsLoaded" class="flex flex-1 items-center justify-center text-xs text-muted-foreground">
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div v-else-if="streamGroupsError && streamGroups.length === 0" class="flex flex-1 flex-col items-center justify-center gap-3 p-4 text-center text-xs text-muted-foreground">
|
||||
<span>{{ t("redis.streamGroupsLoadFailed") }}: {{ streamGroupsError }}</span>
|
||||
<Button data-redis-stream-groups-retry variant="outline" size="sm" class="h-7 text-xs" @click="retryStreamGroups">{{ t("common.retry") }}</Button>
|
||||
</div>
|
||||
<div v-else class="min-h-0 flex-1 overflow-auto">
|
||||
<div v-if="streamGroupsError" class="flex items-center gap-2 border-b bg-destructive/5 px-4 py-2 text-xs text-destructive">
|
||||
<span class="min-w-0 flex-1 truncate">{{ t("redis.streamGroupsLoadFailed") }}: {{ streamGroupsError }}</span>
|
||||
<Button data-redis-stream-groups-retry variant="ghost" size="sm" class="h-6 text-xs text-destructive" @click="retryStreamGroups">{{ t("common.retry") }}</Button>
|
||||
</div>
|
||||
<div v-if="streamGroups.length === 0" class="flex h-full min-h-40 items-center justify-center p-4 text-xs text-muted-foreground">
|
||||
{{ t("redis.noConsumerGroups") }}
|
||||
</div>
|
||||
<table v-else data-redis-stream-groups class="w-full min-w-[780px] border-collapse text-left text-sm">
|
||||
<thead class="sticky top-0 bg-muted/95 text-xs text-muted-foreground backdrop-blur">
|
||||
<tr>
|
||||
<th class="px-4 py-2 font-medium">{{ t("redis.group") }}</th>
|
||||
<th class="px-3 py-2 text-right font-medium">{{ t("redis.consumers") }}</th>
|
||||
<th class="px-3 py-2 text-right font-medium">{{ t("redis.pending") }}</th>
|
||||
<th class="px-3 py-2 font-medium">{{ t("redis.lastDeliveredId") }}</th>
|
||||
<th class="px-3 py-2 text-right font-medium">{{ t("redis.entriesRead") }}</th>
|
||||
<th class="px-4 py-2 text-right font-medium">{{ t("redis.lag") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="group in streamGroups" :key="group.name.raw_base64" data-redis-stream-group-row class="cursor-pointer border-t hover:bg-accent/50" @click="selectStreamGroup(group)">
|
||||
<td class="dbx-editor-font-family max-w-72 truncate px-4 py-2" :title="formatValue(group.name)">{{ formatValue(group.name) }}</td>
|
||||
<td class="px-3 py-2 text-right tabular-nums">{{ formatStreamMetric(group.consumers) }}</td>
|
||||
<td class="px-3 py-2 text-right tabular-nums">{{ formatStreamMetric(group.pending) }}</td>
|
||||
<td class="dbx-editor-font-family max-w-56 truncate px-3 py-2 text-muted-foreground" :title="group.last_delivered_id">{{ group.last_delivered_id }}</td>
|
||||
<td class="px-3 py-2 text-right tabular-nums">{{ formatStreamMetric(group.entries_read) }}</td>
|
||||
<td class="px-4 py-2 text-right tabular-nums">{{ formatStreamMetric(group.lag) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div data-redis-stream-group-detail class="min-h-0 flex-1 overflow-auto bg-muted/20 p-3">
|
||||
<div class="mx-auto flex w-full max-w-[1120px] flex-col gap-3">
|
||||
<div v-if="selectedStreamConsumer" data-redis-stream-consumer-summary class="grid grid-cols-3 gap-px overflow-hidden rounded-lg border bg-border">
|
||||
<div class="bg-card px-3 py-2.5">
|
||||
<div class="text-xs text-muted-foreground">{{ t("redis.pending") }}</div>
|
||||
<div class="mt-1 text-lg font-semibold tabular-nums">{{ formatStreamMetric(selectedStreamConsumer.pending) }}</div>
|
||||
</div>
|
||||
<div class="bg-card px-3 py-2.5">
|
||||
<div class="text-xs text-muted-foreground">{{ t("redis.idle") }}</div>
|
||||
<div class="mt-1 text-lg font-semibold tabular-nums" :title="formatStreamDurationTitle(selectedStreamConsumer.idle_ms)">{{ formatStreamDuration(selectedStreamConsumer.idle_ms) }}</div>
|
||||
</div>
|
||||
<div class="bg-card px-3 py-2.5">
|
||||
<div class="text-xs text-muted-foreground">{{ t("redis.inactive") }}</div>
|
||||
<div class="mt-1 text-lg font-semibold tabular-nums" :title="formatStreamDurationTitle(selectedStreamConsumer.inactive_ms)">{{ formatStreamDuration(selectedStreamConsumer.inactive_ms) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else data-redis-stream-group-summary class="grid grid-cols-3 gap-px overflow-hidden rounded-lg border bg-border">
|
||||
<div class="bg-card px-3 py-2.5">
|
||||
<div class="text-xs text-muted-foreground">{{ t("redis.consumers") }}</div>
|
||||
<div class="mt-1 text-lg font-semibold tabular-nums">{{ formatStreamMetric(selectedStreamGroup.consumers) }}</div>
|
||||
</div>
|
||||
<div class="bg-card px-3 py-2.5">
|
||||
<div class="text-xs text-muted-foreground">{{ t("redis.pending") }}</div>
|
||||
<div class="mt-1 text-lg font-semibold tabular-nums">{{ formatStreamMetric(selectedStreamGroup.pending) }}</div>
|
||||
</div>
|
||||
<div class="bg-card px-3 py-2.5">
|
||||
<div class="text-xs text-muted-foreground">{{ t("redis.lag") }}</div>
|
||||
<div class="mt-1 text-lg font-semibold tabular-nums">{{ formatStreamMetric(selectedStreamGroup.lag) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="!selectedStreamConsumer" data-redis-stream-consumers class="overflow-hidden rounded-lg border bg-card text-card-foreground">
|
||||
<div class="flex min-h-10 items-center gap-2 border-b px-3 py-2">
|
||||
<span class="text-sm font-medium">{{ t("redis.streamConsumers") }}</span>
|
||||
<Badge v-if="streamConsumers.length" variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] tabular-nums">{{ formatStreamMetric(streamConsumers.length) }}</Badge>
|
||||
<Loader2 v-if="streamConsumersLoading" class="ml-auto h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-if="streamConsumersLoading && streamConsumers.length === 0" class="flex items-center gap-2 px-3 py-6 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div v-else-if="streamConsumersError && streamConsumers.length === 0" class="flex items-center gap-2 px-3 py-5 text-xs text-destructive">
|
||||
<span class="min-w-0 flex-1 truncate">{{ t("redis.streamConsumersLoadFailed") }}: {{ streamConsumersError }}</span>
|
||||
<Button data-redis-stream-consumers-retry variant="ghost" size="sm" class="h-7 text-xs text-destructive" @click="retryStreamConsumers">{{ t("common.retry") }}</Button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="streamConsumersError" class="flex items-center gap-2 border-b bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<span class="min-w-0 flex-1 truncate">{{ t("redis.streamConsumersLoadFailed") }}: {{ streamConsumersError }}</span>
|
||||
<Button data-redis-stream-consumers-retry variant="ghost" size="sm" class="h-6 text-xs text-destructive" @click="retryStreamConsumers">{{ t("common.retry") }}</Button>
|
||||
</div>
|
||||
<div v-if="streamConsumers.length === 0" class="px-3 py-6 text-center text-xs text-muted-foreground">{{ t("redis.noStreamConsumers") }}</div>
|
||||
<table v-else data-redis-stream-consumers-table class="w-full table-fixed border-collapse text-sm">
|
||||
<colgroup>
|
||||
<col class="w-1/4" />
|
||||
<col class="w-1/4" />
|
||||
<col class="w-1/4" />
|
||||
<col class="w-1/4" />
|
||||
</colgroup>
|
||||
<thead class="bg-muted/50 text-xs text-muted-foreground">
|
||||
<tr data-redis-stream-consumer-header>
|
||||
<th scope="col" class="px-3 py-1.5 text-left font-medium">{{ t("redis.consumer") }}</th>
|
||||
<th scope="col" class="px-2 py-1.5 text-right font-medium">{{ t("redis.pending") }}</th>
|
||||
<th scope="col" class="px-2 py-1.5 text-right font-medium">{{ t("redis.idle") }}</th>
|
||||
<th scope="col" class="px-3 py-1.5 text-right font-medium">{{ t("redis.inactive") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="consumer in streamConsumers" :key="consumer.name.raw_base64" class="border-t hover:bg-muted/20">
|
||||
<td class="px-3 py-2 text-left">
|
||||
<button
|
||||
data-redis-stream-consumer-row
|
||||
type="button"
|
||||
class="block w-full min-w-0 rounded-sm text-left outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
:title="t('redis.openConsumerDetails')"
|
||||
@click="selectStreamConsumer(consumer)"
|
||||
>
|
||||
<span class="dbx-editor-font-family block truncate">{{ formatValue(consumer.name) }}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-2 py-2 text-right tabular-nums">{{ formatStreamMetric(consumer.pending) }}</td>
|
||||
<td class="px-2 py-2 text-right tabular-nums" :title="formatStreamDurationTitle(consumer.idle_ms)">{{ formatStreamDuration(consumer.idle_ms) }}</td>
|
||||
<td class="px-3 py-2 text-right tabular-nums" :title="formatStreamDurationTitle(consumer.inactive_ms)">{{ formatStreamDuration(consumer.inactive_ms) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section v-if="selectedStreamConsumer" data-redis-stream-pending class="overflow-hidden rounded-lg border bg-card text-card-foreground">
|
||||
<div class="flex min-h-10 items-center gap-2 border-b px-3 py-2">
|
||||
<span class="text-sm font-medium">{{ t("redis.pendingEntries") }}</span>
|
||||
<Badge v-if="selectedStreamConsumer" variant="outline" class="dbx-editor-font-family min-w-0 max-w-48 truncate px-1.5 text-[10px]" :title="formatValue(selectedStreamConsumer.name)">{{ formatValue(selectedStreamConsumer.name) }}</Badge>
|
||||
<Badge v-if="streamPendingEntries.length" variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] tabular-nums">{{ formatStreamMetric(streamPendingEntries.length) }}</Badge>
|
||||
<Loader2 v-if="streamPendingLoading || streamPendingLoadingMore" class="ml-auto h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-if="streamPendingLoading && streamPendingEntries.length === 0" class="flex items-center gap-2 px-3 py-6 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div v-else-if="streamPendingError && streamPendingEntries.length === 0" class="flex items-center gap-2 px-3 py-5 text-xs text-destructive">
|
||||
<span class="min-w-0 flex-1 truncate">{{ t("redis.pendingEntriesLoadFailed") }}: {{ streamPendingError }}</span>
|
||||
<Button data-redis-stream-pending-retry variant="ghost" size="sm" class="h-7 text-xs text-destructive" @click="retryStreamPending">{{ t("common.retry") }}</Button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="streamPendingError" class="flex items-center gap-2 border-b bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<span class="min-w-0 flex-1 truncate">{{ t("redis.pendingEntriesLoadFailed") }}: {{ streamPendingError }}</span>
|
||||
<Button data-redis-stream-pending-retry variant="ghost" size="sm" class="h-6 text-xs text-destructive" @click="retryStreamPending">{{ t("common.retry") }}</Button>
|
||||
</div>
|
||||
<div v-if="streamPendingEntries.length === 0" class="px-3 py-6 text-center text-xs text-muted-foreground">{{ t("redis.noPendingEntries") }}</div>
|
||||
<table v-else data-redis-stream-pending-table class="w-full table-fixed border-collapse text-sm">
|
||||
<colgroup>
|
||||
<col class="w-[44%]" />
|
||||
<col class="w-[36%]" />
|
||||
<col class="w-[20%]" />
|
||||
</colgroup>
|
||||
<thead class="bg-muted/50 text-xs text-muted-foreground">
|
||||
<tr data-redis-stream-pending-header>
|
||||
<th scope="col" class="px-3 py-1.5 text-left font-medium">{{ t("redis.entryId") }}</th>
|
||||
<th scope="col" class="px-2 py-1.5 text-left font-medium">{{ t("redis.lastDelivered") }}</th>
|
||||
<th scope="col" class="px-3 py-1.5 text-right font-medium">{{ t("redis.deliveries") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in streamPendingEntries" :key="entry.id" class="border-t hover:bg-muted/20">
|
||||
<td class="px-3 py-2 text-left" :title="entry.id">
|
||||
<div class="dbx-editor-font-family truncate">{{ entry.id }}</div>
|
||||
</td>
|
||||
<td class="px-2 py-2 text-left tabular-nums" :title="formatStreamDurationTitle(entry.idle_ms)">{{ formatStreamLastDelivery(entry.idle_ms) }}</td>
|
||||
<td class="px-3 py-2 text-right tabular-nums">{{ formatStreamMetric(entry.deliveries) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<div v-if="streamPendingCursor" class="border-t p-2">
|
||||
<Button data-redis-stream-pending-more variant="outline" size="sm" class="h-7 w-full text-xs" :disabled="streamPendingLoadingMore" @click="loadMoreStreamPending">
|
||||
<Loader2 v-if="streamPendingLoadingMore" class="mr-1.5 h-3 w-3 animate-spin" />
|
||||
{{ t("redis.loadMorePending") }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
</template>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- Unknown -->
|
||||
|
||||
@@ -2940,6 +2940,31 @@ export default {
|
||||
loadedFields: "{loaded} of {total} fields loaded",
|
||||
loadedMembers: "{loaded} of {total} members loaded",
|
||||
entries: "{count} entries",
|
||||
streamData: "Stream Data",
|
||||
consumerGroups: "Consumer Groups",
|
||||
group: "Group",
|
||||
consumers: "Consumers",
|
||||
pending: "Pending",
|
||||
lastDeliveredId: "Last Delivered ID",
|
||||
lastDelivered: "Last Delivered",
|
||||
entriesRead: "Entries Read",
|
||||
lag: "Lag",
|
||||
noConsumerGroups: "No consumer groups",
|
||||
streamGroupsLoadFailed: "Failed to load consumer groups",
|
||||
backToGroups: "Back to groups",
|
||||
streamConsumers: "Consumers",
|
||||
streamConsumersLoadFailed: "Failed to load consumers",
|
||||
noStreamConsumers: "No consumers",
|
||||
openConsumerDetails: "Open consumer details",
|
||||
consumer: "Consumer",
|
||||
idle: "Idle",
|
||||
inactive: "Inactive",
|
||||
pendingEntries: "Pending Entries",
|
||||
pendingEntriesLoadFailed: "Failed to load pending entries",
|
||||
noPendingEntries: "No pending entries",
|
||||
entryId: "Entry ID",
|
||||
deliveries: "Deliveries",
|
||||
loadMorePending: "Load more pending entries",
|
||||
noExpiry: "no expiry",
|
||||
expiry: "Expiration",
|
||||
expiryNone: "No expiry",
|
||||
|
||||
@@ -2801,6 +2801,31 @@ export default withEnglishFallback({
|
||||
loadedFields: "{loaded} de {total} campos cargados",
|
||||
loadedMembers: "{loaded} de {total} miembros cargados",
|
||||
entries: "{count} entradas",
|
||||
streamData: "Datos del stream",
|
||||
consumerGroups: "Grupos de consumidores",
|
||||
group: "Grupo",
|
||||
consumers: "Consumidores",
|
||||
pending: "Pendientes",
|
||||
lastDeliveredId: "Último ID entregado",
|
||||
lastDelivered: "Última entrega",
|
||||
entriesRead: "Entradas leídas",
|
||||
lag: "Retraso",
|
||||
noConsumerGroups: "No hay grupos de consumidores",
|
||||
streamGroupsLoadFailed: "No se pudieron cargar los grupos de consumidores",
|
||||
backToGroups: "Volver a los grupos",
|
||||
streamConsumers: "Consumidores",
|
||||
streamConsumersLoadFailed: "No se pudieron cargar los consumidores",
|
||||
noStreamConsumers: "No hay consumidores",
|
||||
openConsumerDetails: "Abrir detalles del consumidor",
|
||||
consumer: "Consumidor",
|
||||
idle: "Inactivo",
|
||||
inactive: "Inactivo",
|
||||
pendingEntries: "Entradas pendientes",
|
||||
pendingEntriesLoadFailed: "No se pudieron cargar las entradas pendientes",
|
||||
noPendingEntries: "No hay entradas pendientes",
|
||||
entryId: "ID de entrada",
|
||||
deliveries: "Entregas",
|
||||
loadMorePending: "Cargar más entradas pendientes",
|
||||
noExpiry: "sin expiración",
|
||||
expiry: "Expiración",
|
||||
expiryNone: "Sin expiración",
|
||||
|
||||
@@ -2799,6 +2799,31 @@ export default withEnglishFallback({
|
||||
loadedFields: "{loaded} di {total} campi caricati",
|
||||
loadedMembers: "{loaded} di {total} membri caricati",
|
||||
entries: "{count} voci",
|
||||
streamData: "Dati stream",
|
||||
consumerGroups: "Gruppi di consumer",
|
||||
group: "Gruppo",
|
||||
consumers: "Consumer",
|
||||
pending: "In sospeso",
|
||||
lastDeliveredId: "Ultimo ID consegnato",
|
||||
lastDelivered: "Ultima consegna",
|
||||
entriesRead: "Voci lette",
|
||||
lag: "Ritardo",
|
||||
noConsumerGroups: "Nessun gruppo di consumer",
|
||||
streamGroupsLoadFailed: "Impossibile caricare i gruppi di consumer",
|
||||
backToGroups: "Torna ai gruppi",
|
||||
streamConsumers: "Consumer",
|
||||
streamConsumersLoadFailed: "Impossibile caricare i consumer",
|
||||
noStreamConsumers: "Nessun consumer",
|
||||
openConsumerDetails: "Apri dettagli del consumer",
|
||||
consumer: "Consumer",
|
||||
idle: "Inattivo",
|
||||
inactive: "Non attivo",
|
||||
pendingEntries: "Voci in sospeso",
|
||||
pendingEntriesLoadFailed: "Impossibile caricare le voci in sospeso",
|
||||
noPendingEntries: "Nessuna voce in sospeso",
|
||||
entryId: "ID voce",
|
||||
deliveries: "Consegne",
|
||||
loadMorePending: "Carica altre voci in sospeso",
|
||||
noExpiry: "nessuna scadenza",
|
||||
expiry: "Scadenza",
|
||||
expiryNone: "Nessuna scadenza",
|
||||
|
||||
@@ -2800,6 +2800,31 @@ export default withEnglishFallback({
|
||||
loadedFields: "{loaded}/{total}フィールド読み込み完了",
|
||||
loadedMembers: "{loaded}/{total}メンバー読み込み完了",
|
||||
entries: "{count}エントリ",
|
||||
streamData: "ストリームデータ",
|
||||
consumerGroups: "コンシューマーグループ",
|
||||
group: "グループ",
|
||||
consumers: "コンシューマー",
|
||||
pending: "保留中",
|
||||
lastDeliveredId: "最終配信 ID",
|
||||
lastDelivered: "最終配信時刻",
|
||||
entriesRead: "読み取り済みエントリ",
|
||||
lag: "遅延",
|
||||
noConsumerGroups: "コンシューマーグループはありません",
|
||||
streamGroupsLoadFailed: "コンシューマーグループの読み込みに失敗しました",
|
||||
backToGroups: "グループに戻る",
|
||||
streamConsumers: "コンシューマー",
|
||||
streamConsumersLoadFailed: "コンシューマーの読み込みに失敗しました",
|
||||
noStreamConsumers: "コンシューマーはいません",
|
||||
openConsumerDetails: "コンシューマーの詳細を開く",
|
||||
consumer: "コンシューマー",
|
||||
idle: "アイドル",
|
||||
inactive: "非アクティブ",
|
||||
pendingEntries: "保留中のエントリ",
|
||||
pendingEntriesLoadFailed: "保留中のエントリの読み込みに失敗しました",
|
||||
noPendingEntries: "保留中のエントリはありません",
|
||||
entryId: "エントリ ID",
|
||||
deliveries: "配信回数",
|
||||
loadMorePending: "保留中のエントリをさらに読み込む",
|
||||
noExpiry: "期限なし",
|
||||
expiry: "有効期限",
|
||||
expiryNone: "期限なし",
|
||||
|
||||
@@ -2801,6 +2801,31 @@ export default withEnglishFallback({
|
||||
loadedFields: "{loaded} de {total} campos carregados",
|
||||
loadedMembers: "{loaded} de {total} membros carregados",
|
||||
entries: "{count} entradas",
|
||||
streamData: "Dados do stream",
|
||||
consumerGroups: "Grupos de consumidores",
|
||||
group: "Grupo",
|
||||
consumers: "Consumidores",
|
||||
pending: "Pendentes",
|
||||
lastDeliveredId: "Último ID entregue",
|
||||
lastDelivered: "Última entrega",
|
||||
entriesRead: "Entradas lidas",
|
||||
lag: "Atraso",
|
||||
noConsumerGroups: "Nenhum grupo de consumidores",
|
||||
streamGroupsLoadFailed: "Não foi possível carregar os grupos de consumidores",
|
||||
backToGroups: "Voltar aos grupos",
|
||||
streamConsumers: "Consumidores",
|
||||
streamConsumersLoadFailed: "Não foi possível carregar os consumidores",
|
||||
noStreamConsumers: "Nenhum consumidor",
|
||||
openConsumerDetails: "Abrir detalhes do consumidor",
|
||||
consumer: "Consumidor",
|
||||
idle: "Ocioso",
|
||||
inactive: "Inativo",
|
||||
pendingEntries: "Entradas pendentes",
|
||||
pendingEntriesLoadFailed: "Não foi possível carregar as entradas pendentes",
|
||||
noPendingEntries: "Nenhuma entrada pendente",
|
||||
entryId: "ID da entrada",
|
||||
deliveries: "Entregas",
|
||||
loadMorePending: "Carregar mais entradas pendentes",
|
||||
noExpiry: "sem expiração",
|
||||
expiry: "Expiração",
|
||||
expiryNone: "Sem expiração",
|
||||
|
||||
@@ -2940,6 +2940,31 @@ export default withEnglishFallback({
|
||||
loadedFields: "已加载 {loaded} / 共 {total} 个字段",
|
||||
loadedMembers: "已加载 {loaded} / 共 {total} 个成员",
|
||||
entries: "{count} 条记录",
|
||||
streamData: "Stream 数据",
|
||||
consumerGroups: "消费组",
|
||||
group: "组",
|
||||
consumers: "消费者",
|
||||
pending: "待处理",
|
||||
lastDeliveredId: "最后投递 ID",
|
||||
lastDelivered: "最后投递时间",
|
||||
entriesRead: "已读取条目",
|
||||
lag: "滞后",
|
||||
noConsumerGroups: "暂无消费组",
|
||||
streamGroupsLoadFailed: "加载消费组失败",
|
||||
backToGroups: "返回消费组",
|
||||
streamConsumers: "消费者",
|
||||
streamConsumersLoadFailed: "加载消费者失败",
|
||||
noStreamConsumers: "暂无消费者",
|
||||
openConsumerDetails: "查看消费者详情",
|
||||
consumer: "消费者",
|
||||
idle: "空闲",
|
||||
inactive: "非活跃",
|
||||
pendingEntries: "待处理条目",
|
||||
pendingEntriesLoadFailed: "加载待处理条目失败",
|
||||
noPendingEntries: "暂无待处理条目",
|
||||
entryId: "条目 ID",
|
||||
deliveries: "投递次数",
|
||||
loadMorePending: "加载更多待处理条目",
|
||||
noExpiry: "永不过期",
|
||||
expiry: "过期时间",
|
||||
expiryNone: "永不过期",
|
||||
|
||||
@@ -2472,6 +2472,31 @@ export default withEnglishFallback({
|
||||
loadedFields: "已載入 {loaded} / 共 {total} 個欄位",
|
||||
loadedMembers: "已載入 {loaded} / 共 {total} 個成員",
|
||||
entries: "{count} 筆項目",
|
||||
streamData: "Stream 資料",
|
||||
consumerGroups: "消費者群組",
|
||||
group: "群組",
|
||||
consumers: "消費者",
|
||||
pending: "待處理",
|
||||
lastDeliveredId: "最後投遞 ID",
|
||||
lastDelivered: "最後投遞時間",
|
||||
entriesRead: "已讀取項目",
|
||||
lag: "延遲",
|
||||
noConsumerGroups: "暫無消費者群組",
|
||||
streamGroupsLoadFailed: "載入消費者群組失敗",
|
||||
backToGroups: "返回消費者群組",
|
||||
streamConsumers: "消費者",
|
||||
streamConsumersLoadFailed: "載入消費者失敗",
|
||||
noStreamConsumers: "暫無消費者",
|
||||
openConsumerDetails: "檢視消費者詳細資料",
|
||||
consumer: "消費者",
|
||||
idle: "閒置",
|
||||
inactive: "非活躍",
|
||||
pendingEntries: "待處理項目",
|
||||
pendingEntriesLoadFailed: "載入待處理項目失敗",
|
||||
noPendingEntries: "暫無待處理項目",
|
||||
entryId: "項目 ID",
|
||||
deliveries: "投遞次數",
|
||||
loadMorePending: "載入更多待處理項目",
|
||||
noExpiry: "永不過期",
|
||||
expiry: "到期時間",
|
||||
expiryNone: "永不過期",
|
||||
|
||||
@@ -243,7 +243,7 @@ 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 refreshButton = findTemplateElement((element) => element.tag === "Button" && directiveExpression(element, "on", "click") === "load");
|
||||
const refreshButton = findTemplateElement((element) => element.tag === "Button" && directiveExpression(element, "on", "click") === "refreshValueAndStreamGroups");
|
||||
|
||||
expect(autoRefresh.getText()).toContain("if (hasUnsavedRedisDraft.value) return;");
|
||||
expect(autoRefresh.getText()).toContain("load({ preserveDraft: true })");
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: mocks.invoke,
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Redis stream monitoring Tauri API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("invokes the group, consumer, and pending read commands with binary-safe raw names", async () => {
|
||||
mocks.invoke.mockResolvedValue(undefined);
|
||||
const { redisGetStreamConsumers, redisGetStreamGroups, redisGetStreamPending } = await import("@/lib/backend/tauri");
|
||||
|
||||
await redisGetStreamGroups("redis-1", 2, "b3JkZXJz");
|
||||
await redisGetStreamConsumers("redis-1", 2, "b3JkZXJz", "Z3JvdXAALQ==");
|
||||
await redisGetStreamPending("redis-1", 2, "b3JkZXJz", "Z3JvdXAALQ==", "1714470000000-17");
|
||||
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(1, "redis_get_stream_groups", {
|
||||
connectionId: "redis-1",
|
||||
db: 2,
|
||||
keyRaw: "b3JkZXJz",
|
||||
});
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(2, "redis_get_stream_consumers", {
|
||||
connectionId: "redis-1",
|
||||
db: 2,
|
||||
keyRaw: "b3JkZXJz",
|
||||
groupRaw: "Z3JvdXAALQ==",
|
||||
});
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(3, "redis_get_stream_pending", {
|
||||
connectionId: "redis-1",
|
||||
db: 2,
|
||||
keyRaw: "b3JkZXJz",
|
||||
groupRaw: "Z3JvdXAALQ==",
|
||||
cursor: "1714470000000-17",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes an optional binary-safe consumer name to the pending command", async () => {
|
||||
mocks.invoke.mockResolvedValue(undefined);
|
||||
const { redisGetStreamPending } = await import("@/lib/backend/tauri");
|
||||
|
||||
await redisGetStreamPending("redis-1", 2, "b3JkZXJz", "Z3JvdXAALQ==", "1714470000000-17", "d29ya2VyLWE=");
|
||||
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("redis_get_stream_pending", {
|
||||
connectionId: "redis-1",
|
||||
db: 2,
|
||||
keyRaw: "b3JkZXJz",
|
||||
groupRaw: "Z3JvdXAALQ==",
|
||||
cursor: "1714470000000-17",
|
||||
consumerRaw: "d29ya2VyLWE=",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Redis stream monitoring HTTP API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubFetch() {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function lastCall(fetchMock: ReturnType<typeof stubFetch>): { url: string; body: Record<string, unknown> } {
|
||||
const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
|
||||
return { url, body: JSON.parse(String(init.body)) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
it("posts group, consumer, and pending queries to their read-only endpoints", async () => {
|
||||
const fetchMock = stubFetch();
|
||||
const { redisGetStreamConsumers, redisGetStreamGroups, redisGetStreamPending } = await import("@/lib/backend/http");
|
||||
|
||||
await redisGetStreamGroups("redis-1", 2, "b3JkZXJz");
|
||||
expect(lastCall(fetchMock)).toEqual({
|
||||
url: "/api/redis/get-stream-groups",
|
||||
body: { connectionId: "redis-1", db: 2, keyRaw: "b3JkZXJz" },
|
||||
});
|
||||
|
||||
await redisGetStreamConsumers("redis-1", 2, "b3JkZXJz", "Z3JvdXAALQ==");
|
||||
expect(lastCall(fetchMock)).toEqual({
|
||||
url: "/api/redis/get-stream-consumers",
|
||||
body: { connectionId: "redis-1", db: 2, keyRaw: "b3JkZXJz", groupRaw: "Z3JvdXAALQ==" },
|
||||
});
|
||||
|
||||
await redisGetStreamPending("redis-1", 2, "b3JkZXJz", "Z3JvdXAALQ==", "1714470000000-17");
|
||||
expect(lastCall(fetchMock)).toEqual({
|
||||
url: "/api/redis/get-stream-pending",
|
||||
body: { connectionId: "redis-1", db: 2, keyRaw: "b3JkZXJz", groupRaw: "Z3JvdXAALQ==", cursor: "1714470000000-17" },
|
||||
});
|
||||
});
|
||||
|
||||
it("posts a consumer-scoped pending query", async () => {
|
||||
const fetchMock = stubFetch();
|
||||
const { redisGetStreamPending } = await import("@/lib/backend/http");
|
||||
|
||||
await redisGetStreamPending("redis-1", 2, "b3JkZXJz", "Z3JvdXAALQ==", "1714470000000-17", "d29ya2VyLWE=");
|
||||
|
||||
expect(lastCall(fetchMock)).toEqual({
|
||||
url: "/api/redis/get-stream-pending",
|
||||
body: {
|
||||
connectionId: "redis-1",
|
||||
db: 2,
|
||||
keyRaw: "b3JkZXJz",
|
||||
groupRaw: "Z3JvdXAALQ==",
|
||||
cursor: "1714470000000-17",
|
||||
consumerRaw: "d29ya2VyLWE=",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -387,6 +387,9 @@ export const redisScanKeys = forward("redisScanKeys");
|
||||
export const redisScanKeysBatch = forward("redisScanKeysBatch");
|
||||
export const redisScanValues = forward("redisScanValues");
|
||||
export const redisGetValue = forward("redisGetValue");
|
||||
export const redisGetStreamGroups = forward("redisGetStreamGroups");
|
||||
export const redisGetStreamConsumers = forward("redisGetStreamConsumers");
|
||||
export const redisGetStreamPending = forward("redisGetStreamPending");
|
||||
export const redisSetString = forward("redisSetString");
|
||||
export const redisDeleteKey = forward("redisDeleteKey");
|
||||
export const redisHashSet = forward("redisHashSet");
|
||||
@@ -627,8 +630,13 @@ export type {
|
||||
RedisKeyInfo,
|
||||
RedisListItem,
|
||||
RedisSetItem,
|
||||
RedisStreamConsumer,
|
||||
RedisStreamEntry,
|
||||
RedisStreamField,
|
||||
RedisStreamGroup,
|
||||
RedisStreamMetric,
|
||||
RedisStreamPendingEntry,
|
||||
RedisStreamPendingPage,
|
||||
RedisValue,
|
||||
RedisValueData,
|
||||
RedisZsetItem,
|
||||
|
||||
@@ -68,6 +68,9 @@ import type {
|
||||
UpdateDownloadSource,
|
||||
RedisCollectionPage,
|
||||
RedisDatabaseInfo,
|
||||
RedisStreamConsumer,
|
||||
RedisStreamGroup,
|
||||
RedisStreamPendingPage,
|
||||
RedisValue,
|
||||
RedisScanResult,
|
||||
RedisCommandResult,
|
||||
@@ -1999,6 +2002,18 @@ export async function redisGetValue(connectionId: string, db: number, keyRaw: st
|
||||
return post("/api/redis/get-value", { connectionId, db, keyRaw });
|
||||
}
|
||||
|
||||
export async function redisGetStreamGroups(connectionId: string, db: number, keyRaw: string): Promise<RedisStreamGroup[]> {
|
||||
return post("/api/redis/get-stream-groups", { connectionId, db, keyRaw });
|
||||
}
|
||||
|
||||
export async function redisGetStreamConsumers(connectionId: string, db: number, keyRaw: string, groupRaw: string): Promise<RedisStreamConsumer[]> {
|
||||
return post("/api/redis/get-stream-consumers", { connectionId, db, keyRaw, groupRaw });
|
||||
}
|
||||
|
||||
export async function redisGetStreamPending(connectionId: string, db: number, keyRaw: string, groupRaw: string, cursor?: string, consumerRaw?: string): Promise<RedisStreamPendingPage> {
|
||||
return post("/api/redis/get-stream-pending", { connectionId, db, keyRaw, groupRaw, cursor, ...(consumerRaw === undefined ? {} : { consumerRaw }) });
|
||||
}
|
||||
|
||||
export async function redisSetString(connectionId: string, db: number, keyRaw: string, value: string, ttl?: number): Promise<void> {
|
||||
return post("/api/redis/set-string", { connectionId, db, keyRaw, value, ttl });
|
||||
}
|
||||
|
||||
@@ -1650,6 +1650,37 @@ export interface RedisStreamEntry {
|
||||
fields: RedisStreamField[];
|
||||
}
|
||||
|
||||
// Redis counters above Number.MAX_SAFE_INTEGER are transported as decimal strings.
|
||||
export type RedisStreamMetric = number | string;
|
||||
|
||||
export interface RedisStreamGroup {
|
||||
name: RedisBlob;
|
||||
consumers: RedisStreamMetric;
|
||||
pending: RedisStreamMetric;
|
||||
last_delivered_id: string;
|
||||
entries_read?: RedisStreamMetric;
|
||||
lag?: RedisStreamMetric;
|
||||
}
|
||||
|
||||
export interface RedisStreamConsumer {
|
||||
name: RedisBlob;
|
||||
pending: RedisStreamMetric;
|
||||
idle_ms: RedisStreamMetric;
|
||||
inactive_ms?: RedisStreamMetric;
|
||||
}
|
||||
|
||||
export interface RedisStreamPendingEntry {
|
||||
id: string;
|
||||
consumer: RedisBlob;
|
||||
idle_ms: RedisStreamMetric;
|
||||
deliveries: RedisStreamMetric;
|
||||
}
|
||||
|
||||
export interface RedisStreamPendingPage {
|
||||
entries: RedisStreamPendingEntry[];
|
||||
next_cursor?: string;
|
||||
}
|
||||
|
||||
export type RedisValueData =
|
||||
| { kind: "string"; content: RedisBlob }
|
||||
| { kind: "json"; value: string }
|
||||
@@ -1718,6 +1749,18 @@ export async function redisGetValue(connectionId: string, db: number, keyRaw: st
|
||||
return invoke("redis_get_value", { connectionId, db, keyRaw });
|
||||
}
|
||||
|
||||
export async function redisGetStreamGroups(connectionId: string, db: number, keyRaw: string): Promise<RedisStreamGroup[]> {
|
||||
return invoke("redis_get_stream_groups", { connectionId, db, keyRaw });
|
||||
}
|
||||
|
||||
export async function redisGetStreamConsumers(connectionId: string, db: number, keyRaw: string, groupRaw: string): Promise<RedisStreamConsumer[]> {
|
||||
return invoke("redis_get_stream_consumers", { connectionId, db, keyRaw, groupRaw });
|
||||
}
|
||||
|
||||
export async function redisGetStreamPending(connectionId: string, db: number, keyRaw: string, groupRaw: string, cursor?: string, consumerRaw?: string): Promise<RedisStreamPendingPage> {
|
||||
return invoke("redis_get_stream_pending", { connectionId, db, keyRaw, groupRaw, cursor, ...(consumerRaw === undefined ? {} : { consumerRaw }) });
|
||||
}
|
||||
|
||||
export async function redisSetString(connectionId: string, db: number, keyRaw: string, value: string, ttl?: number): Promise<void> {
|
||||
return invoke("redis_set_string", { connectionId, db, keyRaw, value, ttl });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user