feat(rabbitmq): show sortable queue message counts

Closes #4286
This commit is contained in:
t8y2 committed 2026-08-12 14:00:36 +08:00
1 parent 4e3a8e4110
commit 1259c34db8
11 files changed
+266 -28

No files matched your search

+42 -3
View File
@@ -96,6 +96,7 @@ const showNamespaceColumn = computed(() => isRabbitMqCluster.value && isAllVhost
// RabbitMQ splits the topics tab into Queues (topics table) and Exchanges views.
const showRabbitMqSubTabs = computed(() => isRabbitMqCluster.value && props.supportsExchanges === true);
const rabbitMqSubTab = ref<"queues" | "exchanges">("queues");
const rabbitMqMessageSort = ref<"asc" | "desc" | null>(null);
const rocketMqTopicTypeOptions = ROCKETMQ_TOPIC_MESSAGE_TYPES;
const rocketMqCreatableTopicTypes = ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES;
const rocketMqClusterName = computed(() => clusterInfo.value?.clusterId ?? "-");
@@ -120,9 +121,18 @@ const filteredTopics = computed(() => {
});
});
const displayedTopics = computed(() => {
if (!isRabbitMqCluster.value || !rabbitMqMessageSort.value) return filteredTopics.value;
const direction = rabbitMqMessageSort.value === "asc" ? 1 : -1;
return [...filteredTopics.value].sort((left, right) => {
const countDifference = (rabbitMqReadyMessageCount(left) - rabbitMqReadyMessageCount(right)) * direction;
return countDifference || left.shortName.localeCompare(right.shortName);
});
});
/** Stable ids for RecycleScroller; avoids mounting all topic rows at once. */
const virtualTopicRows = computed<VirtualTopicRow[]>(() =>
filteredTopics.value.map((topic) => ({
displayedTopics.value.map((topic) => ({
id: showNamespaceColumn.value ? `${topic.namespace ?? ""}:${topic.name}` : topic.name,
topic,
})),
@@ -163,6 +173,14 @@ function topicRowSelected(topic: TopicInfo): boolean {
return selectedTopic.value?.name === topic.name && selectedTopic.value?.namespace === topic.namespace;
}
function rabbitMqReadyMessageCount(topic: TopicInfo): number {
return topic.messagesReady ?? topic.messageCount ?? 0;
}
function toggleRabbitMqMessageSort() {
rabbitMqMessageSort.value = rabbitMqMessageSort.value === "desc" ? "asc" : "desc";
}
function topicTypeLabel(topic: TopicInfo): string {
if (isRocketMqCluster.value) {
const type = resolveRocketMqMessageType(topic);
@@ -534,7 +552,13 @@ watch(newPartitions, () => {
<div class="topics-col">{{ t("mqTopics.name") }}</div>
<div v-if="showNamespaceColumn" class="topics-col">{{ t("mqAdmin.namespace") }}</div>
<div class="topics-col">{{ t("mqTopics.type") }}</div>
<div v-if="!isRocketMqCluster" class="topics-col">{{ t("mqTopics.partitions") }}</div>
<div v-if="isRabbitMqCluster" class="topics-col">
<button type="button" class="topics-sort-button" data-testid="rabbitmq-message-sort" @click="toggleRabbitMqMessageSort">
{{ t("mqTopics.messageCount") }}
<span v-if="rabbitMqMessageSort" aria-hidden="true">{{ rabbitMqMessageSort === "desc" ? "↓" : "↑" }}</span>
</button>
</div>
<div v-else-if="!isRocketMqCluster" class="topics-col">{{ t("mqTopics.partitions") }}</div>
<div class="topics-col">{{ t("mqTopics.actions") }}</div>
</div>
<RecycleScroller class="topics-scroller" :items="virtualTopicRows" :item-size="TOPIC_ROW_HEIGHT" :buffer="200" key-field="id">
@@ -552,7 +576,10 @@ watch(newPartitions, () => {
{{ topicTypeLabel(row.topic) }}
</span>
</div>
<div v-if="!isRocketMqCluster" class="topics-col">
<div v-if="isRabbitMqCluster" class="topics-col" data-testid="rabbitmq-message-count" :title="`${t('mqRabbitMqMonitoring.messagesReady')}: ${rabbitMqReadyMessageCount(row.topic)} · ${t('mqRabbitMqMonitoring.messagesUnacked')}: ${row.topic.messagesUnacked ?? 0}`">
{{ rabbitMqReadyMessageCount(row.topic) }}
</div>
<div v-else-if="!isRocketMqCluster" class="topics-col">
<span v-if="row.topic.partitioned">{{ row.topic.partitions ? t("mqTopics.partitionCount", { count: row.topic.partitions }) : t("mqTopics.partitionsUnknown") }}</span>
<span v-else class="text-muted">-</span>
</div>
@@ -922,6 +949,18 @@ watch(newPartitions, () => {
color: var(--color-text-secondary);
}
.topics-sort-button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.topics-scroller {
flex: 1;
min-height: 0;
@@ -0,0 +1,124 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick, type App } from "vue";
const backend = vi.hoisted(() => ({
mqListTopics: vi.fn(),
mqCreateTopic: vi.fn(),
mqDeleteTopic: vi.fn(),
mqUpdatePartitions: vi.fn(),
mqGetClusterInfo: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/lib/backend/api", () => backend);
vi.mock("vue-virtual-scroller", () => ({
RecycleScroller: defineComponent({
props: { items: { type: Array, default: () => [] } },
setup(props, { slots }) {
return () =>
h(
"div",
(props.items as unknown[]).flatMap((item) => slots.default?.({ item }) ?? []),
);
},
}),
}));
vi.mock("@/composables/useMqMutationGuard", () => ({
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
}));
vi.mock("@/components/mq/rocketmq/RocketMqTopicDialogs.vue", () => ({
default: defineComponent({ setup: () => () => h("div") }),
}));
vi.mock("@/components/mq/SendMessagePanel.vue", () => ({
default: defineComponent({ setup: () => () => h("div") }),
}));
vi.mock("@/components/mq/ExchangesPanel.vue", () => ({
default: defineComponent({ setup: () => () => h("div") }),
}));
vi.mock("@/components/mq/shared/MqTypeFilterBar.vue", () => ({
default: defineComponent({
setup:
(_, { slots }) =>
() =>
h("div", slots.default?.()),
}),
}));
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({
default: defineComponent({ setup: () => () => h("div") }),
}));
import TopicsPanel from "@/components/mq/TopicsPanel.vue";
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
async function flushUi() {
await Promise.resolve();
await Promise.resolve();
await nextTick();
}
async function mountRabbitMqPanel() {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(TopicsPanel, {
connectionId: "rabbit-1",
tenant: "_rabbitmq",
namespace: "/",
mqSystemKind: "rabbitmq",
});
app.mount(root);
await flushUi();
return root;
}
function rowNames(container: ParentNode): string[] {
return [...container.querySelectorAll<HTMLElement>(".topic-name-text")].map((item) => item.textContent ?? "");
}
beforeEach(() => {
Object.values(backend).forEach((mock) => mock.mockReset());
backend.mqListTopics.mockResolvedValue([
{ name: "alpha", shortName: "alpha", partitioned: false, persistent: true, messageCount: 4, messagesReady: 3, messagesUnacked: 1 },
{ name: "beta", shortName: "beta", partitioned: false, persistent: true, messageCount: 12, messagesReady: 12, messagesUnacked: 0 },
]);
});
afterEach(() => {
app?.unmount();
app = null;
root?.remove();
root = null;
});
describe("TopicsPanel RabbitMQ queue messages", () => {
it("shows ready message counts and toggles descending and ascending sorting", async () => {
const panel = await mountRabbitMqPanel();
expect([...panel.querySelectorAll<HTMLElement>('[data-testid="rabbitmq-message-count"]')].map((item) => item.textContent?.trim())).toEqual(["3", "12"]);
expect(rowNames(panel)).toEqual(["alpha", "beta"]);
const sortButton = panel.querySelector<HTMLButtonElement>('[data-testid="rabbitmq-message-sort"]');
if (!sortButton) throw new Error("RabbitMQ message sort button not found");
sortButton.click();
await nextTick();
expect(rowNames(panel)).toEqual(["beta", "alpha"]);
sortButton.click();
await nextTick();
expect(rowNames(panel)).toEqual(["alpha", "beta"]);
});
});
+6
View File
@@ -156,6 +156,12 @@ export interface TopicInfo {
messageType?: RocketMqTopicMessageType | string;
/** RabbitMQ: owning virtual host, present when listing across all vhosts. */
namespace?: string;
/** RabbitMQ total queue messages, including ready and unacknowledged messages. */
messageCount?: number;
/** RabbitMQ messages ready for delivery. */
messagesReady?: number;
/** RabbitMQ messages delivered but not yet acknowledged. */
messagesUnacked?: number;
}
export interface ListTopicsOpts {