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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user