fix(rabbitmq): preserve queue metrics and features

This commit is contained in:
jearhe authored and GitHub committed 2026-08-19 15:49:26 +08:00
1 parent 30f5c34729
commit 68d93c9427
25 files changed
+1146 -59

No files matched your search

@@ -25,8 +25,8 @@ interface Props {
interface MetricPoint {
time: string;
msgRateIn: number;
msgRateOut: number;
msgRateIn: number | null;
msgRateOut: number | null;
backlogSize: number;
msgBacklog: number;
consumerLagMs: number;
@@ -72,6 +72,16 @@ const selectedPartitionName = ref<string>();
let refreshTimer: number | undefined;
const history = ref<MetricPoint[]>([]);
const MAX_HISTORY_POINTS = 60;
const messageFlowStatus = computed(() => {
if (!stats.value || stats.value.ratesUnavailable) {
return { kind: "unavailable", label: "-" };
}
const active = stats.value.msgRateIn > 0 || stats.value.msgRateOut > 0;
return {
kind: active ? "healthy" : "idle",
label: active ? t("mqMonitoring.flowActive") : t("mqMonitoring.flowIdle"),
};
});
const partitionRows = computed(() => extractPartitionRows(stats.value?.raw));
const kafkaPartitionRows = computed(() => extractKafkaPartitionRows(stats.value?.raw));
@@ -213,10 +223,11 @@ function refreshNow() {
}
function appendHistoryPoint(statsData: TopicStats, backlogData?: BacklogStats) {
const ratesUnavailable = statsData.ratesUnavailable === true;
const point: MetricPoint = {
time: new Date().toLocaleTimeString(),
msgRateIn: statsData.msgRateIn,
msgRateOut: statsData.msgRateOut,
msgRateIn: ratesUnavailable ? null : statsData.msgRateIn,
msgRateOut: ratesUnavailable ? null : statsData.msgRateOut,
backlogSize: statsData.backlogSize,
msgBacklog: backlogData?.msgBacklog ?? 0,
consumerLagMs: extractConsumerLagMs(statsData.raw),
@@ -351,6 +362,10 @@ function formatNumber(num: number): string {
return num.toLocaleString();
}
function formatMessageRate(rate: number, unavailable?: boolean): string {
return unavailable ? "-" : `${rate.toFixed(2)} msg/s`;
}
watch(
() => props.topic,
() => {
@@ -615,14 +630,14 @@ onUnmounted(() => {
<div class="stat-icon"><Download :size="21" /></div>
<div class="stat-content">
<div class="stat-label">{{ t("mqMonitoring.inboundRate") }}</div>
<div class="stat-value">{{ stats.msgRateIn.toFixed(2) }} msg/s</div>
<div class="stat-value" data-testid="monitoring-rate-in">{{ formatMessageRate(stats.msgRateIn, stats.ratesUnavailable) }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><Upload :size="21" /></div>
<div class="stat-content">
<div class="stat-label">{{ t("mqMonitoring.outboundRate") }}</div>
<div class="stat-value">{{ stats.msgRateOut.toFixed(2) }} msg/s</div>
<div class="stat-value" data-testid="monitoring-rate-out">{{ formatMessageRate(stats.msgRateOut, stats.ratesUnavailable) }}</div>
</div>
</div>
<div class="stat-card">
@@ -645,7 +660,7 @@ onUnmounted(() => {
<div class="charts-grid">
<div class="chart-panel">
<h4>{{ t("mqMonitoring.rateTrend") }}</h4>
<VChart :option="rateChartOption" autoresize class="trend-chart" />
<VChart :option="rateChartOption" autoresize class="trend-chart" data-testid="monitoring-rate-chart" />
</div>
<div class="chart-panel">
<h4>{{ t("mqMonitoring.backlogTrend") }}</h4>
@@ -820,8 +835,8 @@ onUnmounted(() => {
<div class="health-indicators">
<div class="health-item">
<span class="health-label">{{ t("mqMonitoring.messageFlow") }}:</span>
<span :class="['health-badge', stats.msgRateIn > 0 || stats.msgRateOut > 0 ? 'healthy' : 'idle']">
{{ stats.msgRateIn > 0 || stats.msgRateOut > 0 ? t("mqMonitoring.flowActive") : t("mqMonitoring.flowIdle") }}
<span :class="['health-badge', messageFlowStatus.kind]" data-testid="monitoring-flow-status">
{{ messageFlowStatus.label }}
</span>
</div>
<div class="health-item">
@@ -1467,6 +1482,11 @@ onUnmounted(() => {
color: var(--monitor-muted);
}
.health-badge.unavailable {
background: color-mix(in srgb, var(--monitor-muted) 8%, transparent);
color: var(--monitor-muted);
}
.monitoring-message-heading {
display: flex;
align-items: center;
+129 -11
View File
@@ -12,6 +12,7 @@ import ExchangesPanel from "./ExchangesPanel.vue";
import MqTypeFilterBar from "./shared/MqTypeFilterBar.vue";
import type { MqTab } from "@/lib/mq/mqConsoleDefaults";
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
import { formatMqRateCompact, rabbitMqArgumentsSummary, rabbitMqFeatureBadges, rabbitMqQueueType, type RabbitMqFeatureBadge } from "@/lib/mq/rabbitmqQueueFeatures";
import { formatError } from "@/lib/backend/errorUtils";
import { DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS, isProtectedRocketMqTopic, isRocketMqBusinessMessageType, matchesRocketMqTypeFilters, resolveRocketMqMessageType, ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES, ROCKETMQ_TOPIC_MESSAGE_TYPES } from "@/lib/mq/rocketmqTopicTypes";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
@@ -141,22 +142,35 @@ const virtualTopicRows = computed<VirtualTopicRow[]>(() =>
const topicsGridTemplate = computed(() => {
const cols: string[] = ["minmax(180px, 1.6fr)"];
if (showNamespaceColumn.value) cols.push("minmax(100px, 0.8fr)");
cols.push("120px");
if (!isRocketMqCluster.value) cols.push("140px");
if (isRabbitMqCluster.value) {
// RabbitMQ queues: type(+state) | features | messages | consumers | rates | actions
cols.push("110px", "170px", "90px", "70px", "190px");
} else {
cols.push("120px");
}
if (!isRocketMqCluster.value && !isRabbitMqCluster.value) cols.push("140px");
// RocketMQ keeps tiled row actions (status/route/consumers/…) — reserve a wider actions column.
cols.push(isRocketMqCluster.value ? "minmax(560px, 2.2fr)" : "minmax(200px, 1fr)");
cols.push(isRocketMqCluster.value ? "minmax(560px, 2.2fr)" : "minmax(150px, 1fr)");
return cols.join(" ");
});
/** Min content width from grid track mins + gaps + padding; enables shared horizontal scroll. */
const topicsTableMinWidthPx = computed(() => {
let min = 180 + 120; // name + type
let min = 180; // name
if (showNamespaceColumn.value) min += 100;
if (!isRocketMqCluster.value) min += 140;
min += isRocketMqCluster.value ? 560 : 200;
let colCount = 3;
let colCount = 0;
if (isRabbitMqCluster.value) {
// type + features + messages + consumers + rates + actions
min += 110 + 170 + 90 + 70 + 190 + 150;
colCount = 7; // name, type, features, messages, consumers, rates, actions
} else {
min += 120; // type
if (!isRocketMqCluster.value) min += 140; // partitions
min += isRocketMqCluster.value ? 560 : 200; // actions
// RocketMQ: name + type + actions. Others: + partitions.
colCount = isRocketMqCluster.value ? 3 : 4;
}
if (showNamespaceColumn.value) colCount += 1;
if (!isRocketMqCluster.value) colCount += 1;
min += (colCount - 1) * 8; // column-gap
min += 24; // horizontal padding
return min;
@@ -177,6 +191,37 @@ function rabbitMqReadyMessageCount(topic: TopicInfo): number {
return topic.messagesReady ?? topic.messageCount ?? 0;
}
/** Queue type label for the type column; unknown types are never guessed. */
function rabbitMqQueueTypeLabel(topic: TopicInfo): string {
return rabbitMqQueueType(topic) ?? t("mqTopics.rabbitmqQueueTypeUnknown");
}
function rabbitMqQueueTypeBadgeClass(topic: TopicInfo): string {
const type = rabbitMqQueueType(topic);
if (type === "quorum") return "badge-info";
if (type === "stream") return "badge-warning";
return "badge-default";
}
function rabbitMqFeatureBadgesFor(topic: TopicInfo): RabbitMqFeatureBadge[] {
return rabbitMqFeatureBadges(topic);
}
/** Tooltip for the message-count column: Ready / Unacked / Total / Consumers. */
function rabbitMqMessageCountTitle(topic: TopicInfo): string {
const parts = [`${t("mqRabbitMqMonitoring.messagesReady")}: ${topic.messagesReady ?? "-"}`, `${t("mqRabbitMqMonitoring.messagesUnacked")}: ${topic.messagesUnacked ?? "-"}`, `${t("mqTopics.messageCount")}: ${topic.messageCount ?? "-"}`];
if (topic.consumerCount !== undefined) parts.push(`${t("mqTopics.consumers")}: ${topic.consumerCount}`);
if (topic.state) parts.push(`${t("mqTopics.rabbitmqState")}: ${topic.state}`);
const summary = rabbitMqArgumentsSummary(topic);
if (summary) parts.push(`${t("mqTopics.rabbitmqArguments")}: ${summary}`);
return parts.join(" · ");
}
/** Tooltip for the rates column; rates are msg/s sampled by the management API. */
function rabbitMqRatesTitle(): string {
return `${t("mqTopics.rabbitmqPublishRate")} / ${t("mqTopics.rabbitmqDeliverRate")} / ${t("mqTopics.rabbitmqAckRate")} (msg/s)`;
}
function toggleRabbitMqMessageSort() {
rabbitMqMessageSort.value = rabbitMqMessageSort.value === "desc" ? "asc" : "desc";
}
@@ -551,7 +596,9 @@ watch(newPartitions, () => {
<div class="topics-table-header" :style="{ gridTemplateColumns: topicsGridTemplate }">
<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="isRabbitMqCluster" class="topics-col">{{ t("mqTopics.rabbitmqQueueType") }}</div>
<div v-else class="topics-col">{{ t("mqTopics.type") }}</div>
<div v-if="isRabbitMqCluster" class="topics-col">{{ t("mqTopics.rabbitmqFeatures") }}</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") }}
@@ -559,6 +606,8 @@ watch(newPartitions, () => {
</button>
</div>
<div v-else-if="!isRocketMqCluster" class="topics-col">{{ t("mqTopics.partitions") }}</div>
<div v-if="isRabbitMqCluster" class="topics-col">{{ t("mqTopics.consumers") }}</div>
<div v-if="isRabbitMqCluster" class="topics-col" :title="rabbitMqRatesTitle()">{{ t("mqTopics.rabbitmqRates") }}</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">
@@ -571,18 +620,40 @@ watch(newPartitions, () => {
</div>
</div>
<div v-if="showNamespaceColumn" class="topics-col">{{ row.topic.namespace || "-" }}</div>
<div class="topics-col">
<div v-if="isRabbitMqCluster" class="topics-col">
<span class="badge" :class="rabbitMqQueueTypeBadgeClass(row.topic)" :title="t('mqTopics.rabbitmqQueueTypeHint')">
{{ rabbitMqQueueTypeLabel(row.topic) }}
</span>
<span v-if="row.topic.state" class="queue-state" :title="t('mqTopics.rabbitmqState')">{{ row.topic.state }}</span>
</div>
<div v-else class="topics-col">
<span class="badge" :class="topicTypeBadgeClass(row.topic)">
{{ topicTypeLabel(row.topic) }}
</span>
</div>
<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}`">
<div v-if="isRabbitMqCluster" class="topics-col" data-testid="rabbitmq-features">
<span v-if="rabbitMqFeatureBadgesFor(row.topic).length" class="feature-badges">
<span v-for="badge in rabbitMqFeatureBadgesFor(row.topic)" :key="badge.key" class="feature-badge" :class="badge.kind === 'feature' ? 'feature-flag' : ''" :title="badge.title">
{{ badge.label }}
</span>
</span>
<span v-else class="text-muted">-</span>
</div>
<div v-if="isRabbitMqCluster" class="topics-col" data-testid="rabbitmq-message-count" :title="rabbitMqMessageCountTitle(row.topic)">
{{ 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>
<div v-if="isRabbitMqCluster" class="topics-col" data-testid="rabbitmq-consumers">{{ row.topic.consumerCount ?? "-" }}</div>
<div v-if="isRabbitMqCluster" class="topics-col topics-rates" data-testid="rabbitmq-rates" :title="rabbitMqRatesTitle()">
<span>{{ formatMqRateCompact(row.topic.publishRate) }}</span>
<span class="rate-sep">/</span>
<span>{{ formatMqRateCompact(row.topic.deliverRate) }}</span>
<span class="rate-sep">/</span>
<span>{{ formatMqRateCompact(row.topic.ackRate) }}</span>
</div>
<div class="topics-col actions" @click.stop>
<template v-if="isRocketMqCluster">
<button type="button" class="btn-sm" @click="openRocketMqDialog('status', row.topic)">{{ t("mqTopics.actionStatus") }}</button>
@@ -1037,6 +1108,53 @@ watch(newPartitions, () => {
font-style: italic;
}
/* RabbitMQ queue feature badges (compact, tooltip-bearing). */
.queue-state {
margin-left: 4px;
font-size: 11px;
color: var(--color-text-tertiary);
white-space: nowrap;
}
.feature-badges {
display: inline-flex;
gap: 3px;
flex-wrap: nowrap;
overflow: hidden;
}
.feature-badge {
display: inline-block;
padding: 1px 5px;
border-radius: var(--dbx-radius-fixed-4);
font-size: 10px;
font-weight: 600;
line-height: 1.5;
background: var(--color-background-secondary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
white-space: nowrap;
cursor: help;
}
.feature-badge.feature-flag {
background: var(--color-warning-alpha);
border-color: transparent;
color: var(--color-warning);
}
.topics-rates {
font-family: var(--font-mono, monospace);
font-size: 11px;
white-space: nowrap;
cursor: help;
}
.topics-rates .rate-sep {
margin: 0 3px;
color: var(--color-text-tertiary);
}
.actions {
display: flex;
gap: 6px;
@@ -21,7 +21,13 @@ vi.mock("echarts/charts", () => ({ LineChart: {} }));
vi.mock("echarts/components", () => ({ GridComponent: {}, LegendComponent: {}, TooltipComponent: {} }));
vi.mock("vue-echarts", async () => {
const { defineComponent, h } = await import("vue");
return { default: defineComponent({ name: "VChart", setup: () => () => h("div") }) };
return {
default: defineComponent({
name: "VChart",
props: { option: { type: Object, required: true } },
setup: (props) => () => h("div", { "data-chart-option": JSON.stringify(props.option) }),
}),
};
});
vi.mock("@lucide/vue", async () => {
const { defineComponent, h } = await import("vue");
@@ -89,15 +95,15 @@ async function flushUi() {
await nextTick();
}
async function mountPanel() {
async function mountPanel(options: { mqSystemKind?: "kafka" | "rabbitmq"; tenant?: string; namespace?: string } = {}) {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(MonitoringPanel, {
connectionId: "mq-1",
tenant: "_kafka",
namespace: "default",
tenant: options.tenant ?? "_kafka",
namespace: options.namespace ?? "default",
topic: TOPIC,
mqSystemKind: "kafka",
mqSystemKind: options.mqSystemKind ?? "kafka",
onNavigateTab: navigateTab,
});
app.mount(root);
@@ -189,3 +195,52 @@ describe("MonitoringPanel Kafka message browser", () => {
}
});
});
describe("MonitoringPanel unavailable RabbitMQ rates", () => {
it("renders no-data without plotting zeros or reporting an idle flow", async () => {
backend.mqGetTopicStats.mockResolvedValue({
...KAFKA_STATS,
ratesUnavailable: true,
raw: {},
});
backend.mqGetBacklog.mockResolvedValue({ msgBacklog: 5, backlogSize: 128 });
const panel = await mountPanel({ mqSystemKind: "rabbitmq", tenant: "_rabbitmq", namespace: "/" });
expect(panel.querySelector<HTMLElement>('[data-testid="monitoring-rate-in"]')?.textContent?.trim()).toBe("-");
expect(panel.querySelector<HTMLElement>('[data-testid="monitoring-rate-out"]')?.textContent?.trim()).toBe("-");
const flowStatus = panel.querySelector<HTMLElement>('[data-testid="monitoring-flow-status"]');
expect(flowStatus?.textContent?.trim()).toBe("-");
expect(flowStatus?.classList.contains("idle")).toBe(false);
expect(flowStatus?.classList.contains("unavailable")).toBe(true);
const rateChart = panel.querySelector<HTMLElement>('[data-testid="monitoring-rate-chart"]');
const option = JSON.parse(rateChart?.dataset.chartOption ?? "{}");
expect(option.series[0].data).toEqual([null]);
expect(option.series[1].data).toEqual([null]);
expect(backend.mqGetTopicStats).toHaveBeenCalledTimes(1);
expect(backend.mqGetBacklog).toHaveBeenCalledTimes(1);
});
it("keeps a sampled zero as a real idle rate", async () => {
backend.mqGetTopicStats.mockResolvedValue({
...KAFKA_STATS,
ratesUnavailable: false,
raw: {},
});
backend.mqGetBacklog.mockResolvedValue({ msgBacklog: 0, backlogSize: 0 });
const panel = await mountPanel({ mqSystemKind: "rabbitmq", tenant: "_rabbitmq", namespace: "/" });
expect(panel.querySelector<HTMLElement>('[data-testid="monitoring-rate-in"]')?.textContent?.trim()).toBe("0.00 msg/s");
expect(panel.querySelector<HTMLElement>('[data-testid="monitoring-rate-out"]')?.textContent?.trim()).toBe("0.00 msg/s");
const flowStatus = panel.querySelector<HTMLElement>('[data-testid="monitoring-flow-status"]');
expect(flowStatus?.textContent?.trim()).toBe("mqMonitoring.flowIdle");
expect(flowStatus?.classList.contains("idle")).toBe(true);
const rateChart = panel.querySelector<HTMLElement>('[data-testid="monitoring-rate-chart"]');
const option = JSON.parse(rateChart?.dataset.chartOption ?? "{}");
expect(option.series[0].data).toEqual([0]);
expect(option.series[1].data).toEqual([0]);
});
});
@@ -84,6 +84,21 @@ async function mountRabbitMqPanel() {
return root;
}
async function mountRabbitMqAllVhostsPanel() {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(TopicsPanel, {
connectionId: "rabbit-1",
tenant: "_rabbitmq",
namespace: "*",
mqSystemKind: "rabbitmq",
supportsExchanges: true,
});
app.mount(root);
await flushUi();
return root;
}
function rowNames(container: ParentNode): string[] {
return [...container.querySelectorAll<HTMLElement>(".topic-name-text")].map((item) => item.textContent ?? "");
}
@@ -91,8 +106,34 @@ function rowNames(container: ParentNode): string[] {
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 },
{
name: "alpha",
shortName: "alpha",
partitioned: false,
persistent: true,
messageCount: 4,
messagesReady: 3,
messagesUnacked: 1,
queueType: "quorum",
state: "running",
autoDelete: true,
consumerCount: 2,
arguments: { "x-queue-type": "quorum", "x-message-ttl": 60000, "x-dead-letter-exchange": "dlx" },
publishRate: 12.5,
deliverRate: 11.8,
ackRate: 11.2,
},
{
name: "beta",
shortName: "beta",
partitioned: false,
persistent: true,
messageCount: 12,
messagesReady: 12,
messagesUnacked: 0,
// No queue type, no arguments, and no message_stats sample:
// type stays unknown, rates render as "-" (never fabricated zeros).
},
]);
});
@@ -121,4 +162,58 @@ describe("TopicsPanel RabbitMQ queue messages", () => {
await nextTick();
expect(rowNames(panel)).toEqual(["alpha", "beta"]);
});
it("shows queue type, state, features, consumers and message rates", async () => {
const panel = await mountRabbitMqPanel();
const rows = [...panel.querySelectorAll<HTMLElement>(".topic-name-text")].map((name) => name.closest(".topics-row"));
const alpha = rows[0];
if (!alpha) throw new Error("alpha row not found");
// Queue type badge + state label.
const typeCell = alpha.querySelector<HTMLElement>(".badge");
expect(typeCell?.textContent?.trim()).toBe("quorum");
expect(alpha.querySelector<HTMLElement>(".queue-state")?.textContent?.trim()).toBe("running");
// Compact feature badges: D (durable) + AD (auto-delete) + TTL + DLX.
const featureBadges = [...alpha.querySelectorAll<HTMLElement>('[data-testid="rabbitmq-features"] .feature-badge')].map((item) => item.textContent?.trim());
expect(featureBadges).toEqual(["D", "AD", "TTL", "DLX"]);
// Consumers column.
expect(alpha.querySelector<HTMLElement>('[data-testid="rabbitmq-consumers"]')?.textContent?.trim()).toBe("2");
// Rates: publish / deliver / ack.
expect(alpha.querySelector<HTMLElement>('[data-testid="rabbitmq-rates"]')?.textContent?.trim()).toBe("12.50/11.80/11.20");
});
it("renders missing queue data as unknown type and dashed rates, never as zeros", async () => {
const panel = await mountRabbitMqPanel();
const rows = [...panel.querySelectorAll<HTMLElement>(".topic-name-text")].map((name) => name.closest(".topics-row"));
const beta = rows[1];
if (!beta) throw new Error("beta row not found");
// No type and no x-queue-type: the label must not guess "classic".
expect(beta.querySelector<HTMLElement>(".badge")?.textContent?.trim()).toBe("mqTopics.rabbitmqQueueTypeUnknown");
// Durable remains visible; consumers and rates have no sample.
expect(beta.querySelector<HTMLElement>('[data-testid="rabbitmq-features"]')?.textContent?.trim()).toBe("D");
expect(beta.querySelector<HTMLElement>('[data-testid="rabbitmq-consumers"]')?.textContent?.trim()).toBe("-");
expect(beta.querySelector<HTMLElement>('[data-testid="rabbitmq-rates"]')?.textContent?.trim()).toBe("-/-/-");
});
it("keeps per-queue vhosts separate in all-vhosts mode (#5984 regression guard)", async () => {
backend.mqListTopics.mockResolvedValue([
{ name: "orders", shortName: "orders", partitioned: false, persistent: true, namespace: "/", messageCount: 3, messagesReady: 3 },
{ name: "orders", shortName: "orders", partitioned: false, persistent: true, namespace: "/staging", messageCount: 7, messagesReady: 7 },
]);
const panel = await mountRabbitMqAllVhostsPanel();
// Both rows are named "orders" but must stay distinct per virtual host;
// the namespace column shows each queue's own vhost.
const namespaceCells = [...panel.querySelectorAll<HTMLElement>(".topics-row .topics-col:nth-child(2)")].map((item) => item.textContent?.trim());
expect(namespaceCells).toEqual(["/", "/staging"]);
const counts = [...panel.querySelectorAll<HTMLElement>('[data-testid="rabbitmq-message-count"]')].map((item) => item.textContent?.trim());
expect(counts).toEqual(["3", "7"]);
});
});