11 files changed
+266
-28
No files matched your search
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user