fix(rabbitmq): preserve queue metrics and features
This commit is contained in:
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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user