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

@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { formatMqRate, rabbitMqArgumentText, rabbitMqArgumentsSummary, rabbitMqFeatureBadges, rabbitMqHasUnknownArguments, rabbitMqQueueType } from "@/lib/mq/rabbitmqQueueFeatures";
import type { TopicInfo } from "@/types/mq";
function topic(partial: Partial<TopicInfo>): TopicInfo {
return { name: "q", shortName: "q", partitioned: false, persistent: true, ...partial };
}
describe("rabbitMqFeatureBadges", () => {
it("maps durability flags and known x-arguments to compact badges", () => {
const badges = rabbitMqFeatureBadges(
topic({
autoDelete: true,
exclusive: true,
arguments: {
"x-queue-type": "quorum",
"x-message-ttl": 60000,
"x-dead-letter-exchange": "dlx",
},
}),
);
expect(badges.map((badge) => badge.label)).toEqual(["D", "AD", "EX", "TTL", "DLX"]);
expect(badges.find((badge) => badge.label === "D")?.title).toBe("Durable");
expect(badges.find((badge) => badge.label === "TTL")?.title).toBe("x-message-ttl: 60000");
});
it("keeps unknown x-arguments in an Args count badge with full details", () => {
const badges = rabbitMqFeatureBadges(
topic({
arguments: { "x-message-ttl": 100, "x-new-feature": true, "x-other": { nested: [1, 2] } },
}),
);
const argsBadge = badges.find((badge) => badge.key === "args");
expect(argsBadge?.label).toBe("2A");
expect(argsBadge?.title).toContain("x-new-feature: true");
expect(argsBadge?.title).toContain('x-other: {"nested":[1,2]}');
});
it("returns no badges for a plain queue", () => {
expect(rabbitMqFeatureBadges(topic({ persistent: false }))).toEqual([]);
});
it("preserves argument value types in tooltips", () => {
expect(rabbitMqArgumentText(60000)).toBe("60000");
expect(rabbitMqArgumentText(true)).toBe("true");
expect(rabbitMqArgumentText(null)).toBe("null");
expect(rabbitMqArgumentText({ nested: 1 })).toBe('{"nested":1}');
});
});
describe("rabbitMqArgumentsSummary", () => {
it("joins sorted key/value pairs", () => {
const summary = rabbitMqArgumentsSummary(topic({ arguments: { "x-max-length": 10, "x-message-ttl": 5 } }));
expect(summary).toBe("x-max-length: 10 · x-message-ttl: 5");
});
it("returns undefined without arguments", () => {
expect(rabbitMqArgumentsSummary(topic({}))).toBeUndefined();
});
});
describe("rabbitMqQueueType", () => {
it("prefers the explicit type field", () => {
expect(rabbitMqQueueType(topic({ queueType: "quorum", arguments: { "x-queue-type": "stream" } }))).toBe("quorum");
});
it("falls back to the x-queue-type argument for older brokers", () => {
expect(rabbitMqQueueType(topic({ arguments: { "x-queue-type": "stream" } }))).toBe("stream");
});
it("never guesses classic when the type is unknown", () => {
expect(rabbitMqQueueType(topic({}))).toBeUndefined();
});
});
describe("formatMqRate", () => {
it("renders a sampled rate with two decimals", () => {
expect(formatMqRate(12.5)).toBe("12.50/s");
});
it("renders a genuine zero rate as zero, not as no-data", () => {
expect(formatMqRate(0)).toBe("0.00/s");
});
it("renders missing data as a dash, never as a fabricated zero", () => {
expect(formatMqRate(undefined)).toBe("-");
});
});
describe("rabbitMqHasUnknownArguments", () => {
it("flags unknown arguments", () => {
expect(rabbitMqHasUnknownArguments(topic({ arguments: { "x-brand-new": 1 } }))).toBe(true);
expect(rabbitMqHasUnknownArguments(topic({ arguments: { "x-message-ttl": 1 } }))).toBe(false);
});
});
@@ -0,0 +1,138 @@
import type { TopicInfo } from "@/types/mq";
/**
* Compact RabbitMQ queue feature badge shown in the queues table.
* `label` is the terse badge text (e.g. "TTL", "DLX"); `title` is the
* full tooltip (argument name + value); `kind` drives the badge color.
*/
export interface RabbitMqFeatureBadge {
key: string;
kind: "feature" | "argument";
label: string;
title: string;
}
/** Arguments with dedicated compact badges; everything else folds into the "Args" count. */
const KNOWN_ARGUMENT_BADGES: ReadonlyArray<{ key: string; label: string }> = [
{ key: "x-message-ttl", label: "TTL" },
{ key: "x-expires", label: "EXP" },
{ key: "x-max-length", label: "ML" },
{ key: "x-max-length-bytes", label: "MB" },
{ key: "x-max-priority", label: "MP" },
{ key: "x-dead-letter-exchange", label: "DLX" },
{ key: "x-dead-letter-routing-key", label: "DLK" },
{ key: "x-single-active-consumer", label: "SAC" },
{ key: "x-queue-mode", label: "QM" },
{ key: "x-queue-master-locator", label: "QML" },
];
const KNOWN_ARGUMENT_KEYS: ReadonlySet<string> = new Set([
// Badged arguments.
...KNOWN_ARGUMENT_BADGES.map((entry) => entry.key),
// Queue type is displayed in its own column, never as a feature badge.
"x-queue-type",
]);
/** Render an argument value for tooltips without dropping its type. */
export function rabbitMqArgumentText(value: unknown): string {
if (value === null) return "null";
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
/**
* Build the compact feature badges for a RabbitMQ queue row: durability flags
* plus the interesting x-arguments. Unknown / future arguments are never
* dropped — they fold into an "Args" count badge so new features keep
* surfacing without hard-coding them into the UI.
*/
export function rabbitMqFeatureBadges(topic: Pick<TopicInfo, "persistent" | "autoDelete" | "exclusive" | "arguments">): RabbitMqFeatureBadge[] {
const badges: RabbitMqFeatureBadge[] = [];
if (topic.persistent) {
badges.push({ key: "durable", kind: "feature", label: "D", title: "Durable" });
}
if (topic.autoDelete) {
badges.push({ key: "autoDelete", kind: "feature", label: "AD", title: "Auto-delete" });
}
if (topic.exclusive) {
badges.push({ key: "exclusive", kind: "feature", label: "EX", title: "Exclusive" });
}
const argumentsMap = topic.arguments ?? {};
const extraKeys: string[] = [];
for (const [key, value] of Object.entries(argumentsMap)) {
const known = KNOWN_ARGUMENT_BADGES.find((entry) => entry.key === key);
if (known) {
badges.push({
key: `arg:${key}`,
kind: "argument",
label: known.label,
title: `${key}: ${rabbitMqArgumentText(value)}`,
});
} else if (!KNOWN_ARGUMENT_KEYS.has(key)) {
extraKeys.push(key);
}
}
if (extraKeys.length > 0) {
badges.push({
key: "args",
kind: "argument",
label: `${extraKeys.length}A`,
title: extraKeys.map((key) => `${key}: ${rabbitMqArgumentText(argumentsMap[key])}`).join(" · "),
});
}
return badges;
}
/**
* Full arguments summary for the row tooltip, e.g.
* "x-message-ttl: 60000 · x-max-length: 1000". Returns undefined when the
* queue has no arguments at all.
*/
export function rabbitMqArgumentsSummary(topic: Pick<TopicInfo, "arguments">): string | undefined {
const argumentsMap = topic.arguments;
if (!argumentsMap || Object.keys(argumentsMap).length === 0) return undefined;
return Object.entries(argumentsMap)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}: ${rabbitMqArgumentText(value)}`)
.join(" · ");
}
/** Whether any x-argument is unknown to the current UI — signals new features. */
export function rabbitMqHasUnknownArguments(topic: Pick<TopicInfo, "arguments">): boolean {
const argumentsMap = topic.arguments ?? {};
return Object.keys(argumentsMap).some((key) => !KNOWN_ARGUMENT_KEYS.has(key));
}
/**
* RabbitMQ queue type for display. Prefers the management API's `type` field,
* falls back to the x-queue-type argument, and never guesses "classic" when
* neither is present.
*/
export function rabbitMqQueueType(topic: Pick<TopicInfo, "queueType" | "arguments">): string | undefined {
if (topic.queueType) return topic.queueType;
const argumentType = topic.arguments?.["x-queue-type"];
return typeof argumentType === "string" && argumentType.trim() ? argumentType.trim() : undefined;
}
/**
* Format a message rate for display. `undefined` (the management API sampled
* no message_stats) renders as "-" — distinct from a genuine sampled rate of
* zero, which renders as "0.00/s".
*/
export function formatMqRate(value: number | undefined): string {
return value === undefined ? "-" : `${value.toFixed(2)}/s`;
}
/**
* Compact per-cell rate value (no unit) for the queues table; the column
* header carries the unit. Missing data renders as "-".
*/
export function formatMqRateCompact(value: number | undefined): string {
return value === undefined ? "-" : value.toFixed(2);
}