* feat(rocketmq): 优化 RocketMQ 消费者组管理和性能监控 - 添加并行订阅组配置收集功能,支持超时控制和并发限制 - 实现消费者滞后监控功能,支持按主题过滤的滞后统计 - 重构消费滞后结果构建逻辑,添加 Broker 客户端映射功能 - 优化 Admin 客户端构建,确保请求超时参数正确传递 - 为 RabbitMQ 面板组件添加 MQ 写入保护确认机制 - 统一 MQ 面板样式表引入,清理重复按钮样式定义 - 增强连接对话框中的生产环境保护逻辑,支持 MQ 类型范围 - 更新测试用例以验证新的消费者滞后计算和客户端映射功能 - 为 RocketMQ 订阅面板添加过滤计数功能和测试覆盖 * feat(rocketmq): 优化 RocketMQ 客户端连接和消费者组管理 - 移除硬编码的 5 秒收集超时,改用连接配置的请求超时 - 添加从第一个可用主节点收集消费者组配置的方法以提高速度 - 重构内部方法访问客户端 API 实现和超时配置 - 在主题过滤模式下保留一个主节点转储以恢复 FIFO 类型准确性 - 改进消费延迟计算,失败时不设置总延迟而是保持未定义 - 为消费者运行信息获取添加 Docker 地址映射支持 - 更新命名服务器探测逻辑以更好地处理高可用配置 - 增加消息队列保活机制的查询超时支持 - 在前端界面中显示部分积压加载失败的错误信息 - 添加卸载主题前的操作确认保护 - 改进空结果集的用户界面提示信息 * fix(rocketmq): 解决消费组延迟统计失败时显示为健康零延迟的问题 - 在 examineConsumeStatsRemapped 中添加成功计数和最后错误记录 - 添加 ensureConsumeStatsProbeSucceeded 方法确保没有主节点响应时抛出异常 - 修改 TopicConsumeDetail 接口使 delay 字段可选以表示探针失败 - 更新 UI 显示逻辑在延迟不可用时显示破折号而不是零 - 添加测试用例验证所有代理失败时的异常处理 - 在 Rust 代码中检查 totalLag 是否存在以避免将缺失值视为零 - 添加消息队列适配器的标识检查防止过期的保活连接被错误清理 * fix(rocketmq): 修复RocketMQ消费组详情加载和统计查询问题 - 为examineConsumeStats调用添加超时参数以支持Docker地址映射 - 更新ensureConsumeStatsProbeSucceeded方法以处理部分失败情况下的空合并结果 - 在消费者组对话框中添加序列号防止陈旧详情/配置响应覆盖新数据 - 修复消费延迟显示逻辑,将null延迟值显示为"-" - 改进错误处理,确保仅在所有代理成功时才接受空偏移表作为有效结果 * fix(rocketmq): 修复消费者组配置收集和消息积压显示问题 - 修改 collectConsumerGroupConfigsForTopicFilter 方法以合并多个主节点的订阅配置 - 优化 Topic-filtered 列表的配置收集逻辑,确保 FIFO 组类型准确性 - 添加 totalLagFailed 标志以正确处理消息积压探测失败情况 - 更新前端组件以在探测失败时显示 "-" 而非 0 - 修复 Docker 环境下多代理地址映射冲突问题 - 添加 RFC1918 私有 IP 验证方法并改进主机可达性判断 - 为积压不可用状态添加数据模型支持 * fix(rocketmq): 修复RocketMQ消费者组配置收集中的FIFO组识别问题 - 重构订阅组配置合并逻辑,确保FIFO组不会被误识别为NORMAL组 - 添加mergeSubscriptionGroupConfigs方法处理重复和冲突的订阅组配置 - 修改collectConsumerGroupConfigsForTopicFilter方法使用新的合并策略 - 添加测试用例验证FIFO组优先级和后续组保持功能 - 修复中间代理返回重复转储时丢失FIFO-only组的问题 * refactor(rocketmq): 优化消费组统计和名称服务器连接逻辑 - 引入 MasterBrokerAddrPlan 类来管理重映射和冲突回退地址 - 将 examineConsumeStatsRemapped 重构为 examineConsumeStatsOnMasters 方法 - 实现对 Docker 环境中重映射地址的更好支持和故障转移处理 - 在连接前预先探测 NameServer 可达性以避免 JVM 启动超时 - 优化消费者组在线成员数显示,未知状态时显示为 '-' - 添加相关单元测试验证地址解析和冲突处理逻辑 * fix(rocketmq): 修复Docker重映射碰撞和消费者组配置问题 - 修复订阅组配置查找逻辑,支持FIFO合并避免顺序消息被隐藏 - 添加超时参数到订阅组查询方法 - 实现Docker重映射碰撞检测和失败关闭机制 - 添加测试验证碰撞空合并失败关闭逻辑 - 修复RocketMQ跟踪详情对话框加载序列控制问题 - 更新消费延迟标签为积压(backlog) - 添加连接缺失和只读写拒绝的国际化消息提示 - 修复CSS样式中按钮主题颜色应用问题 - 优化NameServer探测预算计算逻辑 * fix(rocketmq): 修复消费者组类型分类和成员计数显示问题 - 当订阅配置缺失时返回 UNKNOWN 类型而不是默认 NORMAL - 在删除和更新消费者组时对所有主节点执行操作确保一致性 - 成员计数查询失败时保持字段为空避免显示错误的离线状态 - 添加 UNKNOWN 类型的国际化支持和UI样式适配 - 修改测试用例验证缺失配置时的类型分类逻辑 * style(mq): 移除MQ面板中的主按钮样式覆盖 - 删除了 .mq-btn-primary 和 .btn-primary 的填充颜色样式定义 - 保留了悬停效果样式以维持用户体验一致性 - 更新注释说明MQ Chrome在浅色主题中保持页面表面样式 - 避免与工具栏和表格操作按钮产生视觉冲突 * test(composables): 为 useMqMutationGuard 添加测试并更新实现 - 添加了测试用例验证MQ面板使用的getter连接ID解析 - 将 unref 替换为 toValue 以正确处理getter参数 - 更新类型导入使用 MaybeRefOrGetter 替代 MaybeRef - 添加注释说明使用 toValue 的原因 * fix(rocketmq): 修复消费者连接检查和订阅组变更的容错机制 - 重构 examineConsumerConnectionInfoRemapped 方法以正确处理无主节点响应的情况 - 添加 requireConsumerConnectionProbeResult 方法确保连接探针结果的准确性 - 修改 mutateSubscriptionGroupOnMasters 方法实现全地址覆盖要求防止部分更新 - 新增 ensureSubscriptionGroupMutationSucceeded 和 shouldFailClosedOnCollisionPartialMutation 方法增强订阅组变更的错误处理 - 更新 rocketmq_subscription_from_group 函数默认类型为 UNKNOWN 而非 NORMAL - 添加多个单元测试验证连接探针和订阅组变更的各种异常场景 * test(rocketmq): 为消费者连接探测测试方法添加异常声明 - 在requireConsumerConnectionProbeResultFailsClosedWhenNoMasterAnswers测试方法中添加throws Exception声明 - 确保测试方法能够正确处理可能抛出的异常情况 * fix(i18n): MQ 生产确认 source 本地化并清理 fieldStatus 死键 --------- Co-authored-by: AiLi <aili@aili.com> Co-authored-by: t8y2 <t8y2@users.noreply.github.com> Co-authored-by: skyler <1156263951@qq.com>
271 lines
11 KiB
TypeScript
Vendored
271 lines
11 KiB
TypeScript
Vendored
// @vitest-environment happy-dom
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createApp, nextTick, type App } from "vue";
|
|
|
|
const backend = vi.hoisted(() => ({
|
|
mqListTopics: vi.fn(),
|
|
mqPeekMessages: vi.fn(),
|
|
mqSendMessage: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("vue-i18n", () => ({
|
|
useI18n: () => ({ t: (key: string) => key }),
|
|
}));
|
|
|
|
vi.mock("@/lib/backend/api", () => ({
|
|
mqListTopics: backend.mqListTopics,
|
|
mqPeekMessages: backend.mqPeekMessages,
|
|
mqSendMessage: backend.mqSendMessage,
|
|
}));
|
|
|
|
vi.mock("@/composables/useMqMutationGuard", () => ({
|
|
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/select", async () => (await import("./selectStub")).createSelectStub());
|
|
|
|
import SendMessagePanel from "@/components/mq/SendMessagePanel.vue";
|
|
|
|
const TOPIC = {
|
|
name: "persistent://public/default/events",
|
|
shortName: "events",
|
|
partitioned: false,
|
|
persistent: true,
|
|
};
|
|
|
|
type BrowseableSystem = "kafka" | "rabbitmq";
|
|
|
|
let app: App<Element> | null = null;
|
|
let root: HTMLDivElement | null = null;
|
|
|
|
async function flushUi() {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await nextTick();
|
|
}
|
|
|
|
function buttonByText(container: ParentNode, text: string): HTMLButtonElement {
|
|
const button = [...container.querySelectorAll<HTMLButtonElement>("button")].find((item) => item.textContent?.includes(text));
|
|
if (!button) throw new Error(`Button not found: ${text}`);
|
|
return button;
|
|
}
|
|
|
|
async function setInputValue(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
|
input.value = value;
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
await nextTick();
|
|
}
|
|
|
|
async function setSelectValue(trigger: HTMLElement, value: string) {
|
|
trigger.click();
|
|
await flushUi();
|
|
const item = document.querySelector<HTMLElement>(`[data-slot="select-item"][data-value="${value}"]`);
|
|
if (!item) throw new Error(`Select item not found: ${value}`);
|
|
item.click();
|
|
await flushUi();
|
|
}
|
|
|
|
function expectedTopic(system: BrowseableSystem) {
|
|
return {
|
|
tenant: system === "rabbitmq" ? "_rabbitmq" : "_kafka",
|
|
namespace: "default",
|
|
topic: TOPIC.shortName,
|
|
persistent: true,
|
|
partitioned: false,
|
|
};
|
|
}
|
|
|
|
async function mountPanel(system: BrowseableSystem) {
|
|
root = document.createElement("div");
|
|
document.body.appendChild(root);
|
|
app = createApp(SendMessagePanel, {
|
|
connectionId: "mq-1",
|
|
tenant: system === "rabbitmq" ? "_rabbitmq" : "_kafka",
|
|
namespace: "default",
|
|
topic: TOPIC,
|
|
mqSystemKind: system,
|
|
isFlatMqCluster: true,
|
|
supportsPeekMessages: true,
|
|
});
|
|
app.config.globalProperties.$t = (key: string) => key;
|
|
app.mount(root);
|
|
await flushUi();
|
|
return root;
|
|
}
|
|
|
|
async function sendMessage(container: ParentNode, value: string) {
|
|
const textarea = container.querySelector<HTMLTextAreaElement>(".code-textarea");
|
|
if (!textarea) throw new Error("Message textarea not found");
|
|
await setInputValue(textarea, value);
|
|
buttonByText(container, "mqMessages.sendMessage").click();
|
|
await flushUi();
|
|
}
|
|
|
|
async function loadMessages(container: ParentNode) {
|
|
const button = buttonByText(container, "mqMessages.loadMessages");
|
|
if (button.disabled) throw new Error("Load messages button is disabled");
|
|
button.click();
|
|
await flushUi();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
backend.mqListTopics.mockReset();
|
|
backend.mqPeekMessages.mockReset();
|
|
backend.mqSendMessage.mockReset();
|
|
backend.mqListTopics.mockResolvedValue([TOPIC]);
|
|
backend.mqPeekMessages.mockResolvedValue([
|
|
{
|
|
position: 1,
|
|
messageId: "0",
|
|
payloadBase64: "",
|
|
payloadText: "existing message",
|
|
properties: { partition: "0" },
|
|
headers: {},
|
|
},
|
|
]);
|
|
backend.mqSendMessage.mockResolvedValue({ topic: TOPIC.shortName, partition: 2, offset: 17 });
|
|
});
|
|
|
|
afterEach(() => {
|
|
app?.unmount();
|
|
app = null;
|
|
root?.remove();
|
|
root = null;
|
|
});
|
|
|
|
describe("SendMessagePanel post-send browsing", () => {
|
|
for (const system of ["kafka", "rabbitmq"] as const) {
|
|
it(`keeps ${system} results unchanged after sending`, async () => {
|
|
const panel = await mountPanel(system);
|
|
|
|
expect(panel.querySelector('[data-testid="message-browser"]')?.classList.contains("message-browser")).toBe(true);
|
|
expect(panel.querySelector('[data-testid="message-browser"]')?.classList.contains("is-monitoring")).toBe(false);
|
|
await loadMessages(panel);
|
|
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expectedTopic(system), "__dbx_kafka_viewer__", 20, system === "kafka" ? { startPosition: "latest" } : {});
|
|
expect(panel.textContent).toContain("existing message");
|
|
|
|
await sendMessage(panel, "new message");
|
|
|
|
expect(backend.mqPeekMessages).toHaveBeenCalledTimes(1);
|
|
expect(panel.textContent).toContain("existing message");
|
|
});
|
|
}
|
|
|
|
it("sends Kafka's selected start position and specific offset", async () => {
|
|
const panel = await mountPanel("kafka");
|
|
|
|
const startPosition = panel.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
|
|
if (!startPosition) throw new Error("Kafka start position select not found");
|
|
await setSelectValue(startPosition, "earliest");
|
|
await loadMessages(panel);
|
|
expect(backend.mqPeekMessages).toHaveBeenLastCalledWith("mq-1", expectedTopic("kafka"), "__dbx_kafka_viewer__", 20, { startPosition: "earliest" });
|
|
|
|
await setSelectValue(startPosition, "offset");
|
|
const partitionInput = panel.querySelector<HTMLInputElement>('[data-testid="kafka-peek-partition"]');
|
|
const offsetInput = panel.querySelector<HTMLInputElement>('[data-testid="kafka-peek-offset"]');
|
|
if (!partitionInput || !offsetInput) throw new Error("Kafka offset inputs not found");
|
|
await setInputValue(partitionInput, "2");
|
|
await setInputValue(offsetInput, "17");
|
|
|
|
await sendMessage(panel, "new message");
|
|
|
|
expect(partitionInput.value).toBe("2");
|
|
expect(offsetInput.value).toBe("17");
|
|
expect(backend.mqPeekMessages).toHaveBeenCalledTimes(1);
|
|
|
|
await loadMessages(panel);
|
|
expect(backend.mqPeekMessages).toHaveBeenLastCalledWith("mq-1", expectedTopic("kafka"), "__dbx_kafka_viewer__", 20, { startPosition: "offset", partition: 2, offset: 17 });
|
|
});
|
|
|
|
it("keeps Kafka's result limit within the supported range", async () => {
|
|
const panel = await mountPanel("kafka");
|
|
const countInput = panel.querySelector<HTMLInputElement>('[data-testid="peek-count"]');
|
|
if (!countInput) throw new Error("Peek count input not found");
|
|
|
|
await setInputValue(countInput, "999");
|
|
await loadMessages(panel);
|
|
|
|
expect(countInput.value).toBe("100");
|
|
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expectedTopic("kafka"), "__dbx_kafka_viewer__", 100, { startPosition: "latest" });
|
|
|
|
await setInputValue(countInput, "");
|
|
await loadMessages(panel);
|
|
|
|
expect(countInput.value).toBe("20");
|
|
expect(backend.mqPeekMessages).toHaveBeenLastCalledWith("mq-1", expectedTopic("kafka"), "__dbx_kafka_viewer__", 20, { startPosition: "latest" });
|
|
});
|
|
|
|
it("does not request Kafka offset mode without an offset", async () => {
|
|
const panel = await mountPanel("kafka");
|
|
const startPosition = panel.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
|
|
if (!startPosition) throw new Error("Kafka start position control not found");
|
|
|
|
await setSelectValue(startPosition, "offset");
|
|
await loadMessages(panel);
|
|
expect(backend.mqPeekMessages).not.toHaveBeenCalled();
|
|
expect(panel.textContent).toContain("mqMessages.offsetRequiredForOffset");
|
|
|
|
const offsetInput = panel.querySelector<HTMLInputElement>('[data-testid="kafka-peek-offset"]');
|
|
if (!offsetInput) throw new Error("Kafka offset input not found");
|
|
await setInputValue(offsetInput, "17");
|
|
await loadMessages(panel);
|
|
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expectedTopic("kafka"), "__dbx_kafka_viewer__", 20, { startPosition: "offset", offset: 17 });
|
|
});
|
|
|
|
it("clears Kafka results and validation errors when the start position changes", async () => {
|
|
const panel = await mountPanel("kafka");
|
|
const startPosition = panel.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
|
|
if (!startPosition) throw new Error("Kafka start position select not found");
|
|
|
|
await loadMessages(panel);
|
|
expect(panel.textContent).toContain("existing message");
|
|
|
|
await setSelectValue(startPosition, "offset");
|
|
expect(panel.textContent).not.toContain("existing message");
|
|
|
|
await loadMessages(panel);
|
|
expect(panel.textContent).toContain("mqMessages.offsetRequiredForOffset");
|
|
|
|
await setSelectValue(startPosition, "earliest");
|
|
expect(panel.textContent).not.toContain("existing message");
|
|
expect(panel.textContent).not.toContain("mqMessages.offsetRequiredForOffset");
|
|
});
|
|
|
|
it("does not leak a saved Kafka offset after switching start positions", async () => {
|
|
const panel = await mountPanel("kafka");
|
|
const startPosition = panel.querySelector<HTMLElement>('[data-testid="kafka-peek-start-position"]');
|
|
const partitionInput = panel.querySelector<HTMLInputElement>('[data-testid="kafka-peek-partition"]');
|
|
if (!startPosition || !partitionInput) throw new Error("Kafka start position controls not found");
|
|
|
|
await setSelectValue(startPosition, "offset");
|
|
const offsetInput = panel.querySelector<HTMLInputElement>('[data-testid="kafka-peek-offset"]');
|
|
if (!offsetInput) throw new Error("Kafka offset input not found");
|
|
await setInputValue(partitionInput, "2");
|
|
await setInputValue(offsetInput, "17");
|
|
|
|
await setSelectValue(startPosition, "latest");
|
|
await loadMessages(panel);
|
|
|
|
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expectedTopic("kafka"), "__dbx_kafka_viewer__", 20, { startPosition: "latest", partition: 2 });
|
|
await setSelectValue(startPosition, "offset");
|
|
const restoredOffsetInput = panel.querySelector<HTMLInputElement>('[data-testid="kafka-peek-offset"]');
|
|
expect(restoredOffsetInput?.value).toBe("17");
|
|
});
|
|
|
|
it("keeps RabbitMQ advanced-filter requests unchanged", async () => {
|
|
const panel = await mountPanel("rabbitmq");
|
|
|
|
buttonByText(panel, "mqMessages.advancedFilter").click();
|
|
await flushUi();
|
|
const partitionInput = panel.querySelector<HTMLInputElement>('input[placeholder="mqMessages.partitionPlaceholderAll"]');
|
|
const offsetInput = panel.querySelector<HTMLInputElement>('input[placeholder="mqMessages.offsetPlaceholderEarliest"]');
|
|
if (!partitionInput || !offsetInput) throw new Error("RabbitMQ advanced filter inputs not found");
|
|
await setInputValue(partitionInput, "2");
|
|
await setInputValue(offsetInput, "17");
|
|
|
|
await loadMessages(panel);
|
|
expect(backend.mqPeekMessages).toHaveBeenCalledWith("mq-1", expectedTopic("rabbitmq"), "__dbx_kafka_viewer__", 20, { partition: 2, offset: 17 });
|
|
});
|
|
});
|