fix(rocketmq): 优化消费组加载/连接并修复积压、FIFO 与 Docker remap

* 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>
This commit is contained in:
AiLi 2026-08-06 01:12:31 +08:00 committed by GitHub
parent 5c281b1653
commit 2f9ca313cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 3400 additions and 1099 deletions

View File

@ -2,14 +2,18 @@ package com.dbx.agent.rocketmq;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.apache.rocketmq.client.exception.MQBrokerException;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.TopicConfig;
import org.apache.rocketmq.common.MixAll;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
import org.apache.rocketmq.remoting.protocol.admin.TopicStatsTable;
import org.apache.rocketmq.remoting.protocol.admin.TopicOffset;
import org.apache.rocketmq.remoting.protocol.body.ProducerInfo;
@ -23,6 +27,7 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@ -129,6 +134,21 @@ class RocketMqAgentTest {
assertEquals("127.0.0.1:10911", RocketMqAgent.remapBrokerAddrForClient("172.18.0.3:10911", conn));
assertEquals("127.0.0.1:10911", RocketMqAgent.remapBrokerAddrForClient("10.0.0.5:10911", conn));
assertEquals("broker.example.com:10911", RocketMqAgent.remapBrokerAddrForClient("broker.example.com:10911", conn));
// 172.15/172.32 are not RFC1918 leave reachable public/non-private hosts alone.
assertEquals("172.15.0.1:10911", RocketMqAgent.remapBrokerAddrForClient("172.15.0.1:10911", conn));
assertEquals("172.32.0.1:10911", RocketMqAgent.remapBrokerAddrForClient("172.32.0.1:10911", conn));
}
@Test
void isRfc1918PrivateIpv4MatchesOnlyPrivateRanges() {
assertTrue(RocketMqAgent.isRfc1918PrivateIpv4("10.0.0.1"));
assertTrue(RocketMqAgent.isRfc1918PrivateIpv4("172.16.0.1"));
assertTrue(RocketMqAgent.isRfc1918PrivateIpv4("172.31.255.255"));
assertTrue(RocketMqAgent.isRfc1918PrivateIpv4("192.168.1.1"));
assertFalse(RocketMqAgent.isRfc1918PrivateIpv4("172.15.0.1"));
assertFalse(RocketMqAgent.isRfc1918PrivateIpv4("172.32.0.1"));
assertFalse(RocketMqAgent.isRfc1918PrivateIpv4("8.8.8.8"));
assertFalse(RocketMqAgent.isRfc1918PrivateIpv4("broker.example.com"));
}
@Test
@ -255,6 +275,8 @@ class RocketMqAgentTest {
SubscriptionGroupConfig normal = new SubscriptionGroupConfig();
normal.setConsumeMessageOrderly(false);
assertEquals("NORMAL", RocketMqAgent.classifyConsumerGroupType("MyGroup", normal));
// Missing dump must not look like NORMAL (FIFO groups would be mislabeled).
assertEquals("UNKNOWN", RocketMqAgent.classifyConsumerGroupType("MissingConfigGroup", null));
}
@Test
@ -291,13 +313,94 @@ class RocketMqAgentTest {
assertTrue(maxActive.get() > 1);
for (Map<String, Object> row : rows) {
// Successful empty connection probe genuine offline 0.
assertEquals(0, row.get("memberCount"));
assertEquals(List.of(), row.get("topics"));
}
}
@Test
void enrichConsumerGroupRowsReturnsDefaultsWhenBudgetExpires() {
void requireConsumerConnectionProbeResultFailsClosedWhenNoMasterAnswers() throws Exception {
assertThrows(
MQClientException.class,
() -> RocketMqAgent.requireConsumerConnectionProbeResult(
1, 0, 0, 0, null, new IllegalStateException("timeout"), "g1")
);
ConsumerConnection offline = new ConsumerConnection();
assertSame(
offline,
RocketMqAgent.requireConsumerConnectionProbeResult(1, 1, 0, 0, offline, null, "g1")
);
}
@Test
void requireConsumerConnectionProbeResultFailsClosedOnRemapped206WithUnreachableFallback() {
Exception cause = new IllegalStateException("fallback unreachable");
MQClientException err = assertThrows(
MQClientException.class,
() -> RocketMqAgent.requireConsumerConnectionProbeResult(
1, 1, 1, 0, new ConsumerConnection(), cause, "g1")
);
assertTrue(err.getMessage().contains("Docker remap address collision"));
assertSame(cause, err.getCause());
}
@Test
void requireConsumerConnectionProbeResultFailsClosedOnPartialRemappedOffline() {
Exception cause = new IllegalStateException("broker-b timeout");
MQClientException err = assertThrows(
MQClientException.class,
() -> RocketMqAgent.requireConsumerConnectionProbeResult(
2, 1, 0, 0, new ConsumerConnection(), cause, "g1")
);
assertTrue(err.getMessage().contains("partial remapped probe"));
assertSame(cause, err.getCause());
}
@Test
void requireConsumerConnectionProbeResultAllowsFullOfflineCoverage() throws Exception {
ConsumerConnection offline = new ConsumerConnection();
assertSame(
offline,
RocketMqAgent.requireConsumerConnectionProbeResult(2, 2, 0, 0, offline, null, "g1")
);
assertSame(
offline,
RocketMqAgent.requireConsumerConnectionProbeResult(1, 1, 1, 1, offline, null, "g1")
);
}
@Test
void isConsumerGroupNotOnlineDetectsBrokerCode206() {
assertTrue(RocketMqAgent.isConsumerGroupNotOnline(
new MQBrokerException(206, "the consumer group[g1] not online")));
assertTrue(RocketMqAgent.isConsumerGroupNotOnline(
new MQClientException(206, "the consumer group[g1] not online")));
assertTrue(RocketMqAgent.isConsumerGroupNotOnline(
new RuntimeException(new MQBrokerException(206, "not online"))));
assertFalse(RocketMqAgent.isConsumerGroupNotOnline(
new MQBrokerException(1, "system error")));
assertFalse(RocketMqAgent.isConsumerGroupNotOnline(new IllegalStateException("timeout")));
}
@Test
void enrichConsumerGroupRowsOmitsMemberCountWhenConnectionProbeFails() {
List<Map<String, Object>> rows = new ArrayList<>();
Map<String, Object> row = new LinkedHashMap<>();
row.put("groupId", "unreachable-group");
rows.add(row);
RocketMqAgent.enrichConsumerGroupRows(rows, groupId -> {
throw new MQClientException("Failed to examine consumer connection on all masters", null);
}, 1, 1_000);
// All-master connection probe failure must not look like offline 0.
assertFalse(row.containsKey("memberCount"));
assertEquals(List.of(), row.get("topics"));
}
@Test
void enrichConsumerGroupRowsOmitsMemberCountWhenBudgetExpires() {
List<Map<String, Object>> rows = IntStream.range(0, 4)
.mapToObj(index -> {
Map<String, Object> row = new LinkedHashMap<>();
@ -316,7 +419,8 @@ class RocketMqAgentTest {
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
assertTrue(elapsedMs < 1_000, "enrichment exceeded its response budget: " + elapsedMs + "ms");
for (Map<String, Object> row : rows) {
assertEquals(0, row.get("memberCount"));
// Cancelled probes must not look offline (0); UI shows '-' when memberCount is absent.
assertFalse(row.containsKey("memberCount"));
assertEquals(List.of(), row.get("topics"));
}
}
@ -575,4 +679,230 @@ class RocketMqAgentTest {
assertTrue(RocketMqAgent.connectionMatches(base, same));
assertFalse(RocketMqAgent.connectionMatches(base, differentNamesrv));
}
@Test
void buildConsumerLagResultIncludesBrokerClientAndTimestamp() {
MessageQueue mq0 = new MessageQueue("TX_TOPIC", "broker-a", 0);
MessageQueue mq1 = new MessageQueue("TX_TOPIC", "broker-a", 1);
OffsetWrapper offset0 = new OffsetWrapper();
offset0.setBrokerOffset(100);
offset0.setConsumerOffset(90);
offset0.setLastTimestamp(1_725_000_000_000L);
OffsetWrapper offset1 = new OffsetWrapper();
offset1.setBrokerOffset(50);
offset1.setConsumerOffset(50);
offset1.setLastTimestamp(0L);
ConsumeStats stats = new ConsumeStats();
stats.getOffsetTable().put(mq0, offset0);
stats.getOffsetTable().put(mq1, offset1);
Map<MessageQueue, String> clients = new HashMap<>();
clients.put(mq0, "172.18.2.212@7#1");
Map<String, Object> result = RocketMqAgent.buildConsumerLagResult(stats, clients);
assertEquals(10L, result.get("totalLag"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> partitions = (List<Map<String, Object>>) result.get("partitions");
assertEquals(2, partitions.size());
assertEquals("broker-a", partitions.get(0).get("brokerName"));
assertEquals(0, partitions.get(0).get("partition"));
assertEquals(90L, partitions.get(0).get("currentOffset"));
assertEquals(100L, partitions.get(0).get("endOffset"));
assertEquals(10L, partitions.get(0).get("lag"));
assertEquals(1_725_000_000_000L, partitions.get(0).get("lastTimestamp"));
assertEquals("172.18.2.212@7#1", partitions.get(0).get("consumerClient"));
// Missing client mapping still returns offsets with empty consumerClient.
assertEquals("", partitions.get(1).get("consumerClient"));
assertEquals(0L, partitions.get(1).get("lastTimestamp"));
}
@Test
void buildConsumerLagResultHandlesNullStatsAndEmptyClientMap() {
Map<String, Object> empty = RocketMqAgent.buildConsumerLagResult(null, null);
assertEquals(0L, empty.get("totalLag"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> partitions = (List<Map<String, Object>>) empty.get("partitions");
assertTrue(partitions.isEmpty());
}
@Test
void mergeSubscriptionGroupConfigsPrefersFifoAndKeepsLaterOnlyGroups() {
Map<String, SubscriptionGroupConfig> merged = new LinkedHashMap<>();
SubscriptionGroupConfig normalA = new SubscriptionGroupConfig();
normalA.setGroupName("group-a");
normalA.setConsumeMessageOrderly(false);
SubscriptionGroupConfig fifoA = new SubscriptionGroupConfig();
fifoA.setGroupName("group-a");
fifoA.setConsumeMessageOrderly(true);
SubscriptionGroupConfig onlyOnLater = new SubscriptionGroupConfig();
onlyOnLater.setGroupName("group-b");
onlyOnLater.setConsumeMessageOrderly(true);
// Intermediate broker repeats group-a as NORMAL (size would not grow), then later adds FIFO-only group-b.
RocketMqAgent.mergeSubscriptionGroupConfigs(merged, Map.of("group-a", normalA));
RocketMqAgent.mergeSubscriptionGroupConfigs(merged, Map.of("group-a", normalA));
RocketMqAgent.mergeSubscriptionGroupConfigs(
merged,
Map.of("group-a", fifoA, "group-b", onlyOnLater)
);
assertEquals(2, merged.size());
assertTrue(merged.get("group-a").isConsumeMessageOrderly());
assertTrue(merged.get("group-b").isConsumeMessageOrderly());
assertEquals("FIFO", RocketMqAgent.classifyConsumerGroupType("group-a", merged.get("group-a")));
assertEquals("FIFO", RocketMqAgent.classifyConsumerGroupType("group-b", merged.get("group-b")));
}
@Test
void mergeSubscriptionGroupConfigsDoesNotDowngradeFifoToNormal() {
Map<String, SubscriptionGroupConfig> merged = new LinkedHashMap<>();
SubscriptionGroupConfig fifo = new SubscriptionGroupConfig();
fifo.setGroupName("ordered");
fifo.setConsumeMessageOrderly(true);
SubscriptionGroupConfig normal = new SubscriptionGroupConfig();
normal.setGroupName("ordered");
normal.setConsumeMessageOrderly(false);
RocketMqAgent.mergeSubscriptionGroupConfigs(merged, Map.of("ordered", fifo));
RocketMqAgent.mergeSubscriptionGroupConfigs(merged, Map.of("ordered", normal));
assertTrue(merged.get("ordered").isConsumeMessageOrderly());
}
@Test
void ensureConsumeStatsProbeSucceededRejectsAllBrokerFailures() {
MQClientException noMasters = assertThrows(
MQClientException.class,
() -> RocketMqAgent.ensureConsumeStatsProbeSucceeded(0, 0, true, null, "GID_A"));
assertTrue(noMasters.getMessage().contains("No reachable RocketMQ master"));
Exception cause = new RuntimeException("broker down");
MQClientException allFailed = assertThrows(
MQClientException.class,
() -> RocketMqAgent.ensureConsumeStatsProbeSucceeded(2, 0, true, cause, "GID_A"));
assertTrue(allFailed.getMessage().contains("Failed to examine consume stats"));
assertSame(cause, allFailed.getCause());
}
@Test
void ensureConsumeStatsProbeSucceededRejectsPartialFailureWithEmptyMerge() {
Exception cause = new RuntimeException("one broker down");
MQClientException partialEmpty = assertThrows(
MQClientException.class,
() -> RocketMqAgent.ensureConsumeStatsProbeSucceeded(3, 1, true, cause, "GID_A"));
assertTrue(partialEmpty.getMessage().contains("partial failure"));
assertSame(cause, partialEmpty.getCause());
}
@Test
void ensureConsumeStatsProbeSucceededAllowsEmptyOffsetsWhenAllBrokersSucceed() throws Exception {
// Genuine offline / unused group: every broker answered, offset table empty.
RocketMqAgent.ensureConsumeStatsProbeSucceeded(1, 1, true, null, "GID_A");
RocketMqAgent.ensureConsumeStatsProbeSucceeded(3, 3, true, null, "GID_A");
}
@Test
void ensureConsumeStatsProbeSucceededAllowsPartialFailureWhenMergeHasOffsets() throws Exception {
// One broker failed but another returned queue offsets keep partial lag.
RocketMqAgent.ensureConsumeStatsProbeSucceeded(
3, 1, false, new RuntimeException("partial"), "GID_A");
}
@Test
void shouldFailClosedOnCollisionEmptyWhenSiblingFallbacksUnreachable() {
assertTrue(RocketMqAgent.shouldFailClosedOnCollisionEmpty(true, 1, 0));
assertTrue(RocketMqAgent.shouldFailClosedOnCollisionEmpty(true, 2, 1));
assertFalse(RocketMqAgent.shouldFailClosedOnCollisionEmpty(false, 1, 0));
assertFalse(RocketMqAgent.shouldFailClosedOnCollisionEmpty(true, 0, 0));
assertFalse(RocketMqAgent.shouldFailClosedOnCollisionEmpty(true, 2, 2));
}
@Test
void shouldFailClosedOnCollisionPartialMutationWhenSiblingFallbacksUnreachable() {
assertTrue(RocketMqAgent.shouldFailClosedOnCollisionPartialMutation(1, 0));
assertTrue(RocketMqAgent.shouldFailClosedOnCollisionPartialMutation(2, 1));
assertFalse(RocketMqAgent.shouldFailClosedOnCollisionPartialMutation(0, 0));
assertFalse(RocketMqAgent.shouldFailClosedOnCollisionPartialMutation(2, 2));
}
@Test
void ensureSubscriptionGroupMutationSucceededRejectsCollisionPartialUpdate() {
Exception cause = new RuntimeException("fallback unreachable");
MQClientException err = assertThrows(
MQClientException.class,
() -> RocketMqAgent.ensureSubscriptionGroupMutationSucceeded(
1, 1, 1, 0, cause, "delete", "GID_A"));
assertTrue(err.getMessage().contains("Docker remap address collision"));
assertSame(cause, err.getCause());
}
@Test
void ensureSubscriptionGroupMutationSucceededRejectsPartialRemappedUpdate() {
Exception cause = new RuntimeException("broker-b down");
MQClientException err = assertThrows(
MQClientException.class,
() -> RocketMqAgent.ensureSubscriptionGroupMutationSucceeded(
2, 1, 0, 0, cause, "update", "GID_A"));
assertTrue(err.getMessage().contains("partial remapped update"));
assertSame(cause, err.getCause());
}
@Test
void ensureSubscriptionGroupMutationSucceededAllowsFullCoverage() throws Exception {
RocketMqAgent.ensureSubscriptionGroupMutationSucceeded(2, 2, 0, 0, null, "delete", "GID_A");
RocketMqAgent.ensureSubscriptionGroupMutationSucceeded(1, 1, 1, 1, null, "update", "GID_A");
}
@Test
void masterBrokerAddrsFromClusterInfoKeepsCollisionFallbacksSeparate() {
org.apache.rocketmq.remoting.protocol.body.ClusterInfo clusterInfo =
new org.apache.rocketmq.remoting.protocol.body.ClusterInfo();
HashMap<String, org.apache.rocketmq.remoting.protocol.route.BrokerData> table = new HashMap<>();
table.put("broker-a", brokerData("broker-a", "172.18.0.2:10911"));
table.put("broker-b", brokerData("broker-b", "172.18.0.3:10911"));
clusterInfo.setBrokerAddrTable(table);
JsonObject conn = JsonParser.parseString("""
{"namesrvAddr":"127.0.0.1:9876"}
""").getAsJsonObject();
RocketMqAgent.MasterBrokerAddrPlan plan =
RocketMqAgent.masterBrokerAddrsFromClusterInfo(clusterInfo, conn, null);
// Both masters remap to the same published host:port; one remapped + one fallback.
assertEquals(1, plan.remappedCount());
assertEquals(1, plan.fallbackCount());
assertTrue(plan.allAddrs().contains("127.0.0.1:10911"));
assertTrue(plan.isCollisionFallback("172.18.0.3:10911") || plan.isCollisionFallback("172.18.0.2:10911"));
// Remapped answered with offsets: collision fallbacks may stay unreachable on host agents.
try {
RocketMqAgent.ensureConsumeStatsProbeSucceeded(
plan.remappedCount(), 1, false, new RuntimeException("fallback unreachable"), "GID_A");
} catch (MQClientException e) {
throw new AssertionError("non-empty remapped merge should be allowed", e);
}
// Empty merge is still allowed by the shared gate when remappedCount==success; the
// examineConsumeStatsOnMasters collision check rejects empty+unreachable siblings.
try {
RocketMqAgent.ensureConsumeStatsProbeSucceeded(
plan.remappedCount(), 1, true, null, "GID_A");
} catch (MQClientException e) {
throw new AssertionError("empty remapped-only success remains allowed at gate layer", e);
}
}
private static org.apache.rocketmq.remoting.protocol.route.BrokerData brokerData(
String name, String masterAddr) {
org.apache.rocketmq.remoting.protocol.route.BrokerData data =
new org.apache.rocketmq.remoting.protocol.route.BrokerData();
data.setBrokerName(name);
HashMap<Long, String> addrs = new HashMap<>();
addrs.put(0L, masterAddr);
data.setBrokerAddrs(addrs);
return data;
}
}

View File

@ -2896,12 +2896,13 @@ const productionDatabaseSummary = computed(() => {
return t("production.databasesSelectedCount", { selected, total: productionDatabaseNames.value.length });
});
const productionScope = computed<ProductionScope>({
get: () => (isSingleDatabase(form.value.db_type) || form.value.is_production ? "connection" : "databases"),
get: () => (isSingleDatabase(form.value.db_type) || form.value.db_type === "mq" || form.value.db_type === "mqtt" || form.value.is_production ? "connection" : "databases"),
set: (scope) => {
form.value.is_production = isSingleDatabase(form.value.db_type) || scope === "connection";
form.value.is_production = isSingleDatabase(form.value.db_type) || form.value.db_type === "mq" || form.value.db_type === "mqtt" || scope === "connection";
},
});
const canSelectProductionDatabases = computed(() => !isSingleDatabase(form.value.db_type));
// MQ/MQTT have no database list production protection is always connection-scoped.
const canSelectProductionDatabases = computed(() => !isSingleDatabase(form.value.db_type) && form.value.db_type !== "mq" && form.value.db_type !== "mqtt");
function setProductionProtectionEnabled(enabled: boolean) {
productionProtectionEnabled.value = enabled;
@ -3415,8 +3416,8 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
}
if (!config.one_time) config.one_time = undefined;
if (!config.read_only) config.read_only = undefined;
if (isSingleDatabase(config.db_type) && config.production_databases?.length) {
// Single-database drivers expose schemas or internal names, not independently selectable databases.
if ((isSingleDatabase(config.db_type) || config.db_type === "mq" || config.db_type === "mqtt") && config.production_databases?.length) {
// Single-database / MQ drivers expose no independently selectable database list for PROD scope.
config.is_production = true;
config.production_databases = [];
}

View File

@ -205,6 +205,8 @@ onUnmounted(() => {
</template>
<style scoped>
@import "./shared/mqPanel.css";
.broker-panel {
height: 100%;
display: flex;
@ -253,26 +255,6 @@ onUnmounted(() => {
cursor: not-allowed;
}
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-sm:hover:not(:disabled) {
background: var(--color-hover);
}
.btn-sm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.panel-error,
.panel-loading,
.empty-state {
@ -408,20 +390,6 @@ onUnmounted(() => {
color: var(--color-text-secondary);
}
.btn-secondary {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;

View File

@ -6,6 +6,7 @@ import { mqBind, mqCreateExchange, mqDeleteExchange, mqListBindings, mqListExcha
import { formatError } from "@/lib/backend/errorUtils";
import { isBuiltinRabbitMqExchange, rabbitMqExchangeDisplayName, RABBITMQ_EXCHANGE_TYPES } from "@/lib/mq/rabbitmqExchanges";
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props {
@ -17,6 +18,7 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const exchanges = ref<MqExchangeInfo[]>([]);
const loading = ref(false);
@ -79,12 +81,12 @@ function nsRefFor(row: { namespace?: string } | undefined): NamespaceRef | null
return { tenant: props.tenant, namespace };
}
function guardWritable() {
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = t("mqExchanges.readOnly");
return false;
}
return true;
return confirmMqWrite(operation);
}
async function loadExchanges() {
@ -143,14 +145,17 @@ function selectExchange(exchange: MqExchangeInfo) {
}
function openCreateDialog() {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqExchanges.readOnly");
return;
}
dialogError.value = undefined;
createForm.value = { name: "", type: "direct", durable: true, autoDelete: false };
showCreateDialog.value = true;
}
async function handleCreate() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqExchanges.create")))) return;
const ns = nsRef();
if (!createForm.value.name.trim() || !ns) {
dialogError.value = t("mqExchanges.nameRequired");
@ -176,13 +181,17 @@ async function handleCreate() {
}
function openDeleteDialog(exchange: MqExchangeInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqExchanges.readOnly");
return;
}
if (isBuiltinRabbitMqExchange(exchange)) return;
deleteTarget.value = exchange;
showDeleteDialog.value = true;
}
async function confirmDelete() {
if (!(await guardWritable(t("mqExchanges.delete")))) return;
const target = deleteTarget.value;
if (!target) return;
const ns = nsRefFor(target);
@ -208,7 +217,10 @@ async function confirmDelete() {
}
function openBindDialog() {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqExchanges.readOnly");
return;
}
if (!selectedExchange.value) return;
dialogError.value = undefined;
bindForm.value = { destinationType: "queue", destination: "", routingKey: "", argumentsText: "" };
@ -227,7 +239,7 @@ function parseBindingArguments(): Record<string, unknown> | undefined {
}
async function handleBind() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqExchanges.bind")))) return;
const exchange = selectedExchange.value;
if (!exchange) return;
const ns = nsRefFor(exchange);
@ -260,12 +272,16 @@ async function handleBind() {
}
function openUnbindDialog(bindingRow: MqBindingInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqExchanges.readOnly");
return;
}
unbindTarget.value = bindingRow;
showUnbindDialog.value = true;
}
async function confirmUnbind() {
if (!(await guardWritable(t("mqExchanges.unbind")))) return;
const target = unbindTarget.value;
if (!target) return;
const ns = nsRefFor(target ?? selectedExchange.value);
@ -522,6 +538,8 @@ watch(
</template>
<style scoped>
@import "./shared/mqPanel.css";
.exchanges-panel {
display: flex;
flex-direction: column;
@ -700,45 +718,6 @@ td {
font-weight: 600;
}
.btn-primary,
.btn-secondary,
.btn-sm,
.btn-danger {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
@ -778,16 +757,6 @@ button:disabled {
font-size: 18px;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
max-height: 60vh;

View File

@ -225,6 +225,8 @@ watch(kafkaStartPosition, () => {
</template>
<style scoped>
@import "./shared/mqPanel.css";
.message-browser {
margin-top: 4px;
padding: 14px;
@ -257,20 +259,6 @@ watch(kafkaStartPosition, () => {
font-weight: 600;
}
.btn-sm {
padding: 4px 10px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 12px;
}
.btn-sm:hover:not(:disabled) {
background: var(--color-background-secondary);
}
.btn-sm:disabled,
.peek-controls input:disabled,
.peek-controls :deep(.message-browser-start-position[data-disabled]) {

View File

@ -7,6 +7,7 @@ import { formatRocketMqMessagePayload, formatRocketMqTimestamp, parseRocketMqMes
import { formatError } from "@/lib/backend/errorUtils";
import { copyToClipboard } from "@/lib/common/clipboard";
import { buildRocketMqTraceTopicOptions, DEFAULT_ROCKETMQ_TRACE_TOPIC, resolveRocketMqMessageType } from "@/lib/mq/rocketmqTopicTypes";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import RocketMqTraceDetailDialog from "./RocketMqTraceDetailDialog.vue";
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
import RocketMqMessageDetailDialog from "./shared/RocketMqMessageDetailDialog.vue";
@ -25,6 +26,7 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const topicSelectRef = ref<InstanceType<typeof RocketMqTopicSelect>>();
const availableTopics = ref<TopicInfo[]>([]);
@ -258,6 +260,7 @@ async function resendMessage() {
resendError.value = t("mqMessages.readOnlyCannotSend");
return;
}
if (!(await confirmMqWrite(t("mqMessages.resendMessage")))) return;
const topic = resendTopic.value;
const payloadText = detailPayload.value.trim();
if (!topic) {
@ -493,6 +496,8 @@ watch(topicName, () => {
</template>
<style scoped>
@import "./shared/mqPanel.css";
.message-query-panel {
height: 100%;
display: flex;
@ -587,32 +592,6 @@ watch(topicName, () => {
min-width: 0;
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 16px;
transition: all 0.15s;
flex-shrink: 0;
}
.btn-icon:hover:not(:disabled) {
background: var(--color-background-secondary);
color: var(--color-text);
}
.btn-icon:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.spin {
display: inline-block;
animation: spin-anim 0.8s linear infinite;
@ -711,39 +690,12 @@ watch(topicName, () => {
word-break: break-all;
}
.btn-primary,
.btn-sm {
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.operation-cell {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.btn-sm:hover:not(:disabled) {
background: var(--color-background-secondary);
}
.btn-primary {
padding: 7px 16px;
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
font-weight: 500;
}
.dialog-overlay {
position: fixed;
inset: 0;
@ -791,15 +743,6 @@ watch(topicName, () => {
border-top: 1px solid var(--color-border);
}
.btn-close {
border: none;
background: none;
color: var(--color-text-secondary);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.dialog-body {
padding: 16px 20px;
display: flex;
@ -843,16 +786,6 @@ watch(topicName, () => {
font-size: 13px;
}
.btn-secondary {
padding: 7px 16px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
cursor: pointer;
}
.detail-payload {
margin: 0;
padding: 12px;

View File

@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
import type { MqAdminConfig, MqClusterInfo, MqSystemKind, NamespaceRef, TopicInfo } from "@/types/mq";
import { mqCreateNamespace, mqListNamespaces, mqTestConnection } from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import { mqClusterOptionsFromExtra } from "@/lib/mq/mqTenantForm";
import {
defaultMqCapabilitiesForSystemKind,
@ -50,6 +51,8 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const isProductionConnection = computed(() => !!connectionStore.getConfig(props.connectionId)?.is_production);
const configuredSystemKind = computed(() => resolveMqSystemKindFromConnection(connectionStore.getConfig(props.connectionId)));
const FLAT_MQ_CONTEXT = "_flat_mq";
@ -225,6 +228,7 @@ function handleNamespaceSelect(value: AcceptableValue) {
async function handleCreateNamespace() {
if (props.readOnly) return;
if (!(await confirmMqWrite(t("mqNamespaces.create")))) return;
const name = createNamespaceName.value.trim();
if (!name) {
createNamespaceError.value = t("mqNamespaces.namespaceNameRequired");
@ -398,6 +402,7 @@ onMounted(async () => {
<button v-if="selectedTopic" class="breadcrumb-button" @click="goToTopicLevel" :title="t('mqAdmin.viewTopic')">{{ selectedTopic.shortName }}</button>
</div>
<div class="toolbar-status">
<span v-if="isProductionConnection" class="prod-badge">PROD</span>
<span v-if="readOnly" class="readonly-badge">{{ t("mqAdmin.readOnly") }}</span>
<span v-if="error" class="toolbar-error">{{ error }}</span>
</div>
@ -560,6 +565,8 @@ onMounted(async () => {
</template>
<style scoped>
@import "./shared/mqPanel.css";
.mq-admin-console {
display: flex;
flex-direction: column;
@ -635,6 +642,15 @@ onMounted(async () => {
font-weight: 500;
}
.prod-badge {
padding: 2px 8px;
border: 1px solid rgb(220 38 38 / 0.55);
border-radius: var(--dbx-radius-fixed-4);
color: rgb(185 28 28);
font-size: 12px;
font-weight: 600;
}
.mq-tabs {
display: flex;
align-items: center;
@ -735,16 +751,6 @@ onMounted(async () => {
font-size: 18px;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
}
@ -784,28 +790,6 @@ onMounted(async () => {
font-size: 13px;
}
.btn-primary,
.btn-secondary {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;

View File

@ -4,6 +4,7 @@ import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { NamespaceRef, NamespaceInfo, NamespaceConfig } from "@/types/mq";
import { mqListNamespaces, mqCreateNamespace, mqDeleteNamespace } from "@/lib/backend/api";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props {
@ -23,6 +24,7 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const namespaces = ref<NamespaceInfo[]>([]);
const loading = ref(false);
@ -37,12 +39,12 @@ const formData = ref({
namespace: "",
});
function guardWritable() {
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = t("mqNamespaces.readOnly");
return false;
}
return true;
return confirmMqWrite(operation);
}
async function loadNamespaces() {
@ -62,7 +64,10 @@ async function loadNamespaces() {
}
function openCreateDialog() {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqNamespaces.readOnly");
return;
}
formData.value = {
namespace: "",
};
@ -70,7 +75,7 @@ function openCreateDialog() {
}
async function handleCreate() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqNamespaces.create")))) return;
if (!formData.value.namespace.trim() || !props.tenant) {
error.value = t("mqNamespaces.namespaceNameRequired");
return;
@ -94,12 +99,16 @@ async function handleCreate() {
}
function handleDelete(ns: NamespaceInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqNamespaces.readOnly");
return;
}
deleteTarget.value = ns;
showDeleteDialog.value = true;
}
async function confirmDelete() {
if (!(await guardWritable(t("mqNamespaces.delete")))) return;
const ns = deleteTarget.value;
if (!ns || !props.tenant) return;
deleting.value = true;
@ -217,6 +226,8 @@ watch(
</template>
<style scoped>
@import "./shared/mqPanel.css";
.namespaces-panel {
height: 100%;
display: flex;
@ -307,45 +318,6 @@ td {
gap: 8px;
}
.btn-primary,
.btn-secondary,
.btn-sm,
.btn-danger {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
@ -414,16 +386,6 @@ button:disabled {
font-size: 18px;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
max-height: 60vh;

View File

@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
import type { AuthAction, MqIssuedToken, MqSystemKind, MqTokenRecord, PermissionMap, PolicyScope, TopicInfo } from "@/types/mq";
import { mqGrantPermission, mqIssueToken, mqListPermissions, mqListTokenRecords, mqRevokePermission } from "@/lib/backend/api";
import { formatMqTokenIssueError, type MqTokenIssueErrorView } from "@/lib/mq/mqTokenErrors";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
interface Props {
connectionId: string;
@ -17,6 +18,7 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const actionOptions: AuthAction[] = ["produce", "consume", "functions", "sources", "sinks", "packages"];
@ -78,13 +80,13 @@ const permissionRows = computed(() => {
return Object.entries(permissions.value).map(([role, actions]) => ({ role, actions }));
});
function guardWritable() {
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = readOnlyMessage.value;
notice.value = undefined;
return false;
}
return true;
return confirmMqWrite(operation);
}
function formatActionLabel(action: AuthAction): string {
@ -120,7 +122,7 @@ async function loadPermissions() {
}
async function grantPermission() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqPermissions.grant")))) return;
const current = scope.value;
const role = roleName.value.trim();
roleNameError.value = "";
@ -163,7 +165,7 @@ async function grantPermission() {
}
async function revokePermission(role: string) {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqPermissions.revoke")))) return;
const current = scope.value;
if (!current) {
error.value = t("mqPermissions.selectNamespaceOrTopic");
@ -228,6 +230,10 @@ async function issueToken() {
tokenIssueError.value = undefined;
return;
}
if (!(await confirmMqWrite(t("mqPermissions.generateToken")))) {
tokenIssueError.value = undefined;
return;
}
const current = scope.value;
if (!current) {
tokenError.value = t("mqPermissions.selectNamespaceOrTopic");
@ -475,6 +481,8 @@ watch(
</template>
<style scoped>
@import "./shared/mqPanel.css";
.permissions-panel {
height: 100%;
display: flex;
@ -677,30 +685,6 @@ th {
color: var(--color-warning);
}
.btn-primary,
.btn-secondary,
.btn-sm,
.btn-danger {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.dialog-overlay {
position: fixed;
inset: 0;
@ -736,15 +720,6 @@ th {
font-weight: 600;
}
.btn-close {
border: none;
background: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 22px;
line-height: 1;
}
.dialog-body {
padding: 18px 20px;
overflow: auto;

View File

@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
import type { BacklogQuota, DispatchRate, PolicyScope, PublishRate, RetentionPolicy, SubscribeRate, TopicInfo } from "@/types/mq";
import { mqGetEffectivePolicies, mqSetBacklogQuota, mqSetDispatchRate, mqSetPublishRate, mqSetRetention, mqSetSubscribeRate } from "@/lib/backend/api";
import { defaultMqPolicyForms, policyFormsFromEffectivePolicies } from "@/lib/mq/mqPolicyForms";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
interface Props {
connectionId: string;
@ -20,6 +21,7 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const policies = ref<unknown>();
const loading = ref(false);
@ -70,13 +72,13 @@ const scopeLabel = computed(() => {
const formattedPolicies = computed(() => JSON.stringify(policies.value ?? {}, null, 2));
function guardWritable() {
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = readOnlyMessage.value;
notice.value = undefined;
return false;
}
return true;
return confirmMqWrite(operation);
}
async function loadPolicies() {
@ -100,7 +102,7 @@ async function loadPolicies() {
}
async function applyPolicy(kind: string, action: (current: PolicyScope) => Promise<void>) {
if (!guardWritable()) return;
if (!(await guardWritable(kind))) return;
const current = scope.value;
if (!current) {
error.value = scopePlaceholderMessage.value;
@ -279,6 +281,8 @@ watch(
</template>
<style scoped>
@import "./shared/mqPanel.css";
.policies-panel {
height: 100%;
display: flex;
@ -404,23 +408,6 @@ pre {
color: var(--color-warning);
}
.btn-primary,
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;

View File

@ -5,6 +5,7 @@ import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { ConsumerInfo, ProducerInfo, SubscriptionInfo, TopicInfo, TopicRef, TopicStats, MqSystemKind } from "@/types/mq";
import { mqGetTopicStats, mqListConsumers, mqListProducers, mqListSubscriptions, mqUnloadTopic } from "@/lib/backend/api";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
interface Props {
@ -26,6 +27,7 @@ const emit = defineEmits<{
topicSelected: [topic: TopicInfo | undefined];
}>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
interface PartitionClientRow {
name: string;
@ -273,6 +275,7 @@ async function loadRuntimeClients() {
async function unloadTopic() {
const current = topicRef.value;
if (!current || props.readOnly || unloading.value) return;
if (!(await confirmMqWrite(t("mqClients.unloadTopic")))) return;
if (!confirm(t("mqClients.confirmUnload"))) return;
unloading.value = true;
@ -762,6 +765,8 @@ watch(
</template>
<style scoped>
@import "./shared/mqPanel.css";
.producer-consumer-panel {
display: flex;
flex-direction: column;
@ -828,44 +833,6 @@ watch(
gap: 8px;
}
.btn-secondary {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-sm:hover:not(:disabled) {
background: var(--color-hover);
}
.btn-sm.danger {
border-color: var(--color-error);
color: var(--color-error);
}
.btn-sm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.runtime-content {
flex: 1;
overflow-y: auto;

View File

@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
import type { MqRawResponse, TopicInfo } from "@/types/mq";
import { mqRawRequest } from "@/lib/backend/api";
import { safeJsonFormat } from "@/lib/common/safeJsonFormat";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
interface Props {
connectionId: string;
@ -16,6 +17,7 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const methods = ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"];
type RawApiPreset = {
@ -184,6 +186,10 @@ async function executeRequest() {
error.value = readOnlyMessage.value;
return;
}
// Mutating raw admin calls need the same production confirmation as console writes.
if (!isReadMethod.value && !(await confirmMqWrite(t("mqRaw.sendRequest")))) {
return;
}
if (!path.value.trim()) {
error.value = t("mqRaw.pathRequired");
return;
@ -277,6 +283,8 @@ async function executeRequest() {
</template>
<style scoped>
@import "./shared/mqPanel.css";
.raw-api-panel {
height: 100%;
display: flex;
@ -514,31 +522,6 @@ pre {
color: var(--color-warning);
}
.btn-primary {
padding: 6px 12px;
border: 1px solid var(--color-primary);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-primary);
color: white;
cursor: pointer;
font-size: 13px;
}
.btn-secondary {
padding: 5px 10px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 12px;
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-secondary.compact {
padding: 3px 8px;
}

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { mqQueryMessageTrace } from "@/lib/backend/api";
import { formatRocketMqTraceError } from "@/lib/mq/rocketmqTraceUtils";
import { formatRocketMqTimestamp, parseRocketMqMessagesFromResult, rocketMqMessagePayload, type RocketMqDisplayMessage } from "@/lib/mq/rocketmqMessageUtils";
import { formatRocketMqTraceError, isParsedRocketMqTraceRecord, parseRocketMqTracePayload, rocketMqTraceRecordTimeLabel, splitRocketMqTraceKeys, type RocketMqTraceFieldKey, type RocketMqTraceRecord, type RocketMqTraceType } from "@/lib/mq/rocketmqTraceUtils";
import { parseRocketMqMessagesFromResult, rocketMqMessagePayload, type RocketMqDisplayMessage } from "@/lib/mq/rocketmqMessageUtils";
import { DEFAULT_ROCKETMQ_TRACE_TOPIC } from "@/lib/mq/rocketmqTopicTypes";
interface Props {
@ -13,6 +13,15 @@ interface Props {
traceTopic?: string;
}
interface TraceCardModel {
index: number;
message: RocketMqDisplayMessage;
record: RocketMqTraceRecord;
parsed: boolean;
rawPayload: string;
headerEntries: Array<[string, string]>;
}
const props = withDefaults(defineProps<Props>(), {
traceTopic: DEFAULT_ROCKETMQ_TRACE_TOPIC,
});
@ -26,6 +35,71 @@ const { t } = useI18n();
const loading = ref(false);
const error = ref<string>();
const messages = ref<RocketMqDisplayMessage[]>([]);
let loadSeq = 0;
const FIELD_I18N: Record<RocketMqTraceFieldKey, string> = {
regionId: "mqTrace.fieldRegionId",
group: "mqTrace.fieldGroup",
topic: "mqTrace.fieldTopic",
msgId: "mqTrace.fieldMsgId",
tags: "mqTrace.fieldTags",
keys: "mqTrace.fieldKeys",
storeHost: "mqTrace.fieldStoreHost",
clientHost: "mqTrace.fieldClientHost",
bodyLength: "mqTrace.fieldBodyLength",
costTime: "mqTrace.fieldCostTime",
msgType: "mqTrace.fieldMsgType",
offsetMsgId: "mqTrace.fieldOffsetMsgId",
requestId: "mqTrace.fieldRequestId",
retryTimes: "mqTrace.fieldRetryTimes",
contextCode: "mqTrace.fieldContextCode",
};
function typeLabel(type: RocketMqTraceType): string {
switch (type) {
case "Pub":
return t("mqTrace.typePub");
case "SubBefore":
return t("mqTrace.typeSubBefore");
case "SubAfter":
return t("mqTrace.typeSubAfter");
case "EndTransaction":
return t("mqTrace.typeEndTransaction");
default:
return t("mqTrace.typeUnknown");
}
}
function fieldLabel(key: RocketMqTraceFieldKey): string {
return t(FIELD_I18N[key]);
}
function headerEntries(message: RocketMqDisplayMessage): Array<[string, string]> {
if (!message.headers) return [];
return Object.entries(message.headers);
}
const cards = computed<TraceCardModel[]>(() => {
const result: TraceCardModel[] = [];
let index = 0;
for (const message of messages.value) {
const rawPayload = rocketMqMessagePayload(message);
const records = parseRocketMqTracePayload(rawPayload);
const units = records.length ? records : [{ type: "Unknown" as const, fields: [], raw: rawPayload }];
for (const record of units) {
index += 1;
result.push({
index,
message,
record,
parsed: isParsedRocketMqTraceRecord(record),
rawPayload: record.raw || rawPayload,
headerEntries: headerEntries(message),
});
}
}
return result;
});
async function loadTrace() {
const msgId = props.msgId.trim();
@ -35,17 +109,20 @@ async function loadTrace() {
return;
}
const seq = ++loadSeq;
loading.value = true;
error.value = undefined;
messages.value = [];
try {
const traceTopic = props.traceTopic.trim() || DEFAULT_ROCKETMQ_TRACE_TOPIC;
const result = await mqQueryMessageTrace(props.connectionId, msgId, traceTopic);
if (seq !== loadSeq) return;
messages.value = parseRocketMqMessagesFromResult(result);
} catch (e: unknown) {
if (seq !== loadSeq) return;
error.value = formatRocketMqTraceError(e, t("mqTrace.traceTopicRouteMissing"));
} finally {
loading.value = false;
if (seq === loadSeq) loading.value = false;
}
}
@ -55,6 +132,7 @@ watch(
if (open) {
void loadTrace();
} else {
loadSeq += 1;
loading.value = false;
error.value = undefined;
messages.value = [];
@ -80,19 +158,49 @@ watch(
<div v-if="loading" class="panel-placeholder">{{ t("mqTrace.querying") }}</div>
<div v-else-if="error" class="panel-error">{{ error }}</div>
<div v-else-if="!messages.length" class="panel-placeholder">{{ t("mqTrace.noTrace") }}</div>
<div v-else-if="!cards.length" class="panel-placeholder">{{ t("mqTrace.noTrace") }}</div>
<div v-else class="message-list">
<article v-for="(message, index) in messages" :key="`${message.messageId ?? index}-${message.timestamp ?? 0}`" class="message-row">
<article v-for="card in cards" :key="`${card.index}-${card.record.type}-${card.record.timestamp ?? 0}`" class="message-row">
<div class="message-meta">
<span>#{{ index + 1 }}</span>
<span v-if="message.topic">{{ message.topic }}</span>
<span v-if="message.partition != null">{{ t("mqMessages.metaPartition", { partition: message.partition }) }}</span>
<span>{{ formatRocketMqTimestamp(message.timestamp) }}</span>
<span>#{{ card.index }}</span>
<span class="type-badge" :data-type="card.record.type">{{ typeLabel(card.record.type) }}</span>
<span>{{ rocketMqTraceRecordTimeLabel(card.record, card.message.timestamp) }}</span>
<span v-if="card.message.partition != null">{{ t("mqMessages.metaPartition", { partition: card.message.partition }) }}</span>
<span v-if="card.record.success != null" class="status-badge" :class="card.record.success ? 'status-success' : 'status-fail'">
{{ card.record.success ? t("mqTrace.statusSuccess") : t("mqTrace.statusFail") }}
</span>
</div>
<pre class="message-payload">{{ rocketMqMessagePayload(message) }}</pre>
<div v-if="message.headers && Object.keys(message.headers).length" class="message-headers">
<span v-for="(value, key) in message.headers" :key="key">{{ key }}: {{ value }}</span>
<div v-if="card.parsed" class="detail-grid trace-fields">
<template v-for="field in card.record.fields" :key="field.key">
<span>{{ fieldLabel(field.key) }}</span>
<span v-if="field.key === 'keys'" class="keys-chips">
<template v-if="splitRocketMqTraceKeys(field.value).length">
<span v-for="key in splitRocketMqTraceKeys(field.value)" :key="key" class="key-chip mono">{{ key }}</span>
</template>
<span v-else class="mono">{{ field.value }}</span>
</span>
<span v-else class="mono">{{ field.value }}</span>
</template>
</div>
<pre v-else class="message-payload">{{ card.rawPayload || "-" }}</pre>
<div v-if="card.headerEntries.length" class="message-headers">
<template v-for="[key, value] in card.headerEntries" :key="key">
<div v-if="key === 'KEYS'" class="header-keys">
<span class="header-keys-label">{{ key }}:</span>
<span class="keys-chips">
<span v-for="chip in splitRocketMqTraceKeys(value)" :key="chip" class="key-chip mono">{{ chip }}</span>
</span>
</div>
<span v-else>{{ key }}: {{ value }}</span>
</template>
</div>
<details v-if="card.parsed && card.rawPayload" class="raw-payload">
<summary>{{ t("mqTrace.rawPayload") }}</summary>
<pre class="message-payload">{{ card.rawPayload }}</pre>
</details>
</article>
</div>
</div>
@ -176,6 +284,10 @@ watch(
font-weight: 500;
}
.trace-fields {
margin-top: 10px;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
@ -213,11 +325,54 @@ watch(
.message-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
gap: 8px 10px;
font-size: 12px;
color: var(--color-text-tertiary);
}
.type-badge,
.status-badge {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: var(--dbx-radius-fixed-4);
font-size: 12px;
font-weight: 600;
line-height: 1.4;
}
.type-badge {
border: 1px solid var(--color-border);
background: var(--color-background);
color: var(--color-text);
}
.type-badge[data-type="Pub"] {
border-color: color-mix(in srgb, var(--color-primary, #3b82f6) 40%, transparent);
background: color-mix(in srgb, var(--color-primary, #3b82f6) 12%, transparent);
color: var(--color-primary, #2563eb);
}
.type-badge[data-type="SubBefore"],
.type-badge[data-type="SubAfter"] {
border-color: color-mix(in srgb, var(--color-info, #0ea5e9) 40%, transparent);
background: color-mix(in srgb, var(--color-info, #0ea5e9) 12%, transparent);
color: var(--color-info, #0284c7);
}
.status-success {
border: 1px solid color-mix(in srgb, var(--color-success, #22c55e) 40%, transparent);
background: color-mix(in srgb, var(--color-success, #22c55e) 12%, transparent);
color: var(--color-success, #16a34a);
}
.status-fail {
border: 1px solid color-mix(in srgb, var(--color-error, #ef4444) 40%, transparent);
background: color-mix(in srgb, var(--color-error, #ef4444) 12%, transparent);
color: var(--color-error, #dc2626);
}
.message-payload {
margin: 8px 0 0;
padding: 10px;
@ -238,12 +393,62 @@ watch(
margin-top: 8px;
}
.message-headers span {
.message-headers > span {
padding: 2px 6px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
font-size: 12px;
color: var(--color-text-secondary);
word-break: break-all;
}
.header-keys {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 6px;
width: 100%;
padding: 4px 6px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
font-size: 12px;
color: var(--color-text-secondary);
}
.header-keys-label {
flex: 0 0 auto;
font-weight: 500;
}
.keys-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
}
.key-chip {
padding: 1px 6px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
font-size: 11px;
color: var(--color-text-secondary);
}
.raw-payload {
margin-top: 8px;
font-size: 12px;
color: var(--color-text-secondary);
}
.raw-payload summary {
cursor: pointer;
user-select: none;
}
.raw-payload .message-payload {
margin-top: 6px;
}
.btn-secondary {

View File

@ -5,6 +5,7 @@ import type { MqSystemKind, TopicInfo, TopicRef, SendMessageRequest, SendMessage
import { mqSendMessage, mqListTopics } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
import { resolveRabbitMqSendNamespace } from "@/lib/mq/mqConsoleDefaults";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import MessageBrowser from "./MessageBrowser.vue";
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
@ -23,6 +24,7 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const topicName = ref("");
const messageKey = ref("");
@ -82,12 +84,12 @@ onUnmounted(() => {
if (successTimer) clearTimeout(successTimer);
});
function guardWritable() {
async function guardWritable() {
if (props.readOnly) {
error.value = readOnlyMessage.value;
return false;
}
return true;
return confirmMqWrite(t("mqMessages.sendMessage"));
}
async function loadTopics() {
@ -138,7 +140,7 @@ function parseHeaders(): Record<string, string> {
}
async function sendMessage() {
if (!guardWritable()) return;
if (!(await guardWritable())) return;
error.value = undefined;
success.value = undefined;
@ -327,6 +329,8 @@ watch(
</template>
<style scoped>
@import "./shared/mqPanel.css";
.send-message-panel {
height: 100%;
display: flex;
@ -463,27 +467,6 @@ watch(
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 16px;
transition: all 0.15s;
flex-shrink: 0;
}
.btn-icon:hover:not(:disabled) {
background: var(--color-background-secondary);
color: var(--color-text);
}
.spin {
display: inline-block;
animation: spin-anim 0.8s linear infinite;
@ -587,35 +570,11 @@ input[type="number"]:focus {
padding-top: 4px;
}
.btn-primary,
.btn-sm {
padding: 7px 16px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
font-weight: 500;
/* Keep send CTA visually balanced next to optional secondary actions */
.form-actions .btn-primary {
min-width: 100px;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
button:disabled,
input:disabled,
textarea:disabled,

View File

@ -3,11 +3,12 @@ import { formatError } from "@/lib/backend/errorUtils";
import { ref, watch, computed } from "vue";
import { useI18n } from "vue-i18n";
import type { TopicRef, TopicInfo, SubscriptionInfo, ResetPosition, SkipCount, PeekedMessage, MqSystemKind } from "@/types/mq";
import { mqListSubscriptions, mqCreateSubscription, mqDeleteSubscription, mqResetCursor, mqSkipMessages, mqClearBacklog, mqPeekMessages, mqExpireMessages } from "@/lib/backend/api";
import { mqListSubscriptions, mqEnrichSubscriptions, mqCreateSubscription, mqDeleteSubscription, mqResetCursor, mqSkipMessages, mqClearBacklog, mqPeekMessages, mqExpireMessages } from "@/lib/backend/api";
import RocketMqConsumerGroupDialogs, { type RocketMqConsumerGroupDialogKind } from "./rocketmq/RocketMqConsumerGroupDialogs.vue";
import MqTypeFilterBar from "./shared/MqTypeFilterBar.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { DEFAULT_ROCKETMQ_CONSUMER_GROUP_TYPE_FILTERS, matchesRocketMqConsumerGroupTypeFilters, resolveRocketMqConsumerGroupMessageModel, resolveRocketMqConsumerGroupType, ROCKETMQ_CONSUMER_GROUP_TYPES, type RocketMqConsumerGroupType } from "@/lib/mq/rocketmqConsumerGroupTypes";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
interface Props {
connectionId: string;
@ -31,10 +32,15 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const subscriptions = ref<SubscriptionInfo[]>([]);
const loading = ref(false);
const enriching = ref(false);
const truncatedHint = ref<string>();
const enrichFailedHint = ref<string>();
const error = ref<string>();
let loadSeq = 0;
const showCreateDialog = ref(false);
const showResetDialog = ref(false);
const showSkipDialog = ref(false);
@ -82,14 +88,23 @@ const canShowPanel = computed(() => isClusterWideMode.value || !!props.topic);
const rocketMqConsumerGroupTypeOptions = ROCKETMQ_CONSUMER_GROUP_TYPES;
const panelTitle = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.consumerGroupTitle") : t("mqSubscriptions.title")));
const searchPlaceholder = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.searchConsumerGroup") : t("mqSubscriptions.searchPlaceholder")));
const filteredSubscriptions = computed(() => {
// Match TopicsPanel: denominator tracks type filters; numerator also applies search.
const typeFilteredSubscriptions = computed(() => {
let rows = subscriptions.value;
if (isRocketMqCluster.value) {
rows = rows.filter((sub) => matchesRocketMqConsumerGroupTypeFilters(sub, consumerGroupTypeFilters.value));
const filters = consumerGroupTypeFilters.value;
// Read each flag so checkbox toggles always invalidate this computed.
for (const type of ROCKETMQ_CONSUMER_GROUP_TYPES) {
void filters[type];
}
rows = rows.filter((sub) => matchesRocketMqConsumerGroupTypeFilters(sub, filters));
}
return rows;
});
const filteredSubscriptions = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase();
if (!keyword) return rows;
return rows.filter((sub) => sub.name.toLowerCase().includes(keyword));
if (!keyword) return typeFilteredSubscriptions.value;
return typeFilteredSubscriptions.value.filter((sub) => sub.name.toLowerCase().includes(keyword));
});
function consumerGroupTypeLabel(sub: SubscriptionInfo): string {
@ -100,7 +115,7 @@ function consumerGroupTypeLabel(sub: SubscriptionInfo): string {
function consumerGroupTypeBadgeClass(sub: SubscriptionInfo): string {
const type = resolveRocketMqConsumerGroupType(sub);
if (type === "FIFO") return "badge badge-info";
if (type === "SYSTEM") return "badge badge-muted";
if (type === "SYSTEM" || type === "UNKNOWN") return "badge badge-muted";
return "badge";
}
@ -109,12 +124,12 @@ function consumerGroupModeLabel(sub: SubscriptionInfo): string {
return t(`mqSubscriptions.rocketmqGroupMode.${mode.toLowerCase()}`);
}
function guardWritable() {
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return false;
}
return true;
return confirmMqWrite(operation);
}
function getListTopicRef(): TopicRef | null {
@ -153,21 +168,57 @@ async function loadSubscriptions() {
const topicRef = getListTopicRef();
if (!topicRef) {
subscriptions.value = [];
truncatedHint.value = undefined;
enrichFailedHint.value = undefined;
return;
}
const seq = ++loadSeq;
loading.value = true;
enriching.value = false;
error.value = undefined;
truncatedHint.value = undefined;
enrichFailedHint.value = undefined;
try {
subscriptions.value = await mqListSubscriptions(props.connectionId, topicRef);
// Fast list first (no enrich / online members) so large clusters paint quickly.
const page = await mqListSubscriptions(props.connectionId, topicRef);
if (seq !== loadSeq) return;
subscriptions.value = page;
syncSelectedSubscription(page);
if (isClusterWideMode.value && page.length >= 500) {
truncatedHint.value = t("mqSubscriptions.truncatedHint", { count: page.length });
}
if (isClusterWideMode.value) {
enriching.value = true;
try {
const enriched = await mqEnrichSubscriptions(props.connectionId, topicRef);
if (seq !== loadSeq) return;
subscriptions.value = enriched;
// Detail dialog holds a snapshot; refresh so topics/members arrive after enrich.
syncSelectedSubscription(enriched);
if (enriched.length >= 500) {
truncatedHint.value = t("mqSubscriptions.truncatedHint", { count: enriched.length });
}
} catch (e: unknown) {
// Keep the fast list if enrichment times out; surface why online columns stayed empty.
if (seq === loadSeq) {
enrichFailedHint.value = t("mqSubscriptions.enrichFailedHint", { error: formatError(e) });
}
} finally {
if (seq === loadSeq) enriching.value = false;
}
}
} catch (e: unknown) {
error.value = formatError(e);
if (seq === loadSeq) error.value = formatError(e);
} finally {
loading.value = false;
if (seq === loadSeq) loading.value = false;
}
}
function openCreateDialog() {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
formData.value = {
subName: "",
startFrom: "latest",
@ -175,13 +226,24 @@ function openCreateDialog() {
showCreateDialog.value = true;
}
/** Keep open dialog/selection pointing at the latest list row for the same group name. */
function syncSelectedSubscription(rows: SubscriptionInfo[]) {
const current = selectedSub.value;
if (!current) return;
const updated = rows.find((row) => row.name === current.name);
if (updated) selectedSub.value = updated;
}
function openRocketMqDetail(sub: SubscriptionInfo) {
selectedSub.value = sub;
activeRocketMqDialog.value = "detail";
}
function openRocketMqConfig(sub: SubscriptionInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
selectedSub.value = sub;
activeRocketMqDialog.value = "config";
}
@ -191,7 +253,10 @@ function closeRocketMqDialog() {
}
function openResetDialog(sub: SubscriptionInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
selectedSub.value = sub;
resetFormData.value = {
position: "latest",
@ -201,7 +266,10 @@ function openResetDialog(sub: SubscriptionInfo) {
}
function openSkipDialog(sub: SubscriptionInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
selectedSub.value = sub;
skipFormData.value = {
mode: "count",
@ -220,7 +288,10 @@ function openPeekDialog(sub: SubscriptionInfo) {
}
function openExpireDialog(sub: SubscriptionInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
selectedSub.value = sub;
expireSeconds.value = 3600;
showExpireDialog.value = true;
@ -236,7 +307,7 @@ function selectSubscription(sub: SubscriptionInfo) {
}
async function handleCreate() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqSubscriptions.create")))) return;
const topicRef = getPulsarTopicRef();
if (!formData.value.subName.trim() || !topicRef) {
error.value = t("mqSubscriptions.subscriptionNameRequired");
@ -257,12 +328,16 @@ async function handleCreate() {
}
function handleDelete(sub: SubscriptionInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
deleteTarget.value = sub;
showDeleteDialog.value = true;
}
async function confirmDelete() {
if (!(await guardWritable(t("mqSubscriptions.delete")))) return;
const sub = deleteTarget.value;
if (!sub) return;
const topicRef = getListTopicRef();
@ -281,7 +356,7 @@ async function confirmDelete() {
}
async function handleResetCursor() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqSubscriptions.reset")))) return;
const topicRef = getPulsarTopicRef();
if (!selectedSub.value || !topicRef) return;
loading.value = true;
@ -304,7 +379,7 @@ async function handleResetCursor() {
}
async function handleSkipMessages() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqSubscriptions.skip")))) return;
const topicRef = getPulsarTopicRef();
if (!selectedSub.value || !topicRef) return;
loading.value = true;
@ -322,12 +397,16 @@ async function handleSkipMessages() {
}
function handleClearBacklog(sub: SubscriptionInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
clearBacklogTarget.value = sub;
showClearBacklogDialog.value = true;
}
async function confirmClearBacklog() {
if (!(await guardWritable(t("mqSubscriptions.clearBacklog")))) return;
const sub = clearBacklogTarget.value;
if (!sub) return;
const topicRef = getPulsarTopicRef();
@ -381,7 +460,7 @@ function invalidatePeekRequest() {
}
async function handleExpireMessages() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqSubscriptions.expire")))) return;
const topicRef = getPulsarTopicRef();
if (!selectedSub.value || !topicRef) return;
loading.value = true;
@ -414,10 +493,11 @@ watch(
<div class="toolbar-left">
<h3>{{ panelTitle }}</h3>
<input v-if="isClusterWideMode" v-model="searchKeyword" type="search" class="topic-search" :placeholder="searchPlaceholder" />
<span v-if="isClusterWideMode && subscriptions.length" class="topic-count"> {{ filteredSubscriptions.length }} / {{ subscriptions.length }} </span>
<span v-if="isClusterWideMode && subscriptions.length" class="topic-count" data-testid="subscription-count"> {{ filteredSubscriptions.length }} / {{ typeFilteredSubscriptions.length }} </span>
<span v-if="enriching" class="topic-count">{{ t("mqSubscriptions.enriching") }}</span>
</div>
<div class="toolbar-actions">
<button v-if="isClusterWideMode" class="btn-secondary" :disabled="loading" @click="loadSubscriptions">
<button v-if="isClusterWideMode" class="btn-secondary" :disabled="loading || enriching" @click="loadSubscriptions">
{{ loading ? t("mqSubscriptions.refreshing") : t("mqSubscriptions.refresh") }}
</button>
<button v-if="supportsCreateSubscription !== false && !isRocketMqCluster" @click="openCreateDialog" :disabled="loading || readOnly || !topic" class="btn-primary">+ {{ t("mqSubscriptions.createSubscription") }}</button>
@ -435,14 +515,16 @@ watch(
<template v-else>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-if="truncatedHint && !error" class="panel-hint">{{ truncatedHint }}</div>
<div v-if="enrichFailedHint && !error" class="panel-hint">{{ enrichFailedHint }}</div>
<div v-else-if="loading && !subscriptions.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
<div v-if="!error && loading && !subscriptions.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
<div v-else-if="!filteredSubscriptions.length" class="panel-placeholder">
{{ isClusterWideMode ? t("mqSubscriptions.noConsumerGroups") : t("mqSubscriptions.noSubscriptions") }}
<div v-else-if="!error && !filteredSubscriptions.length" class="panel-placeholder">
{{ isClusterWideMode ? (subscriptions.length ? t("mqSubscriptions.noMatches") : t("mqSubscriptions.noConsumerGroups")) : t("mqSubscriptions.noSubscriptions") }}
</div>
<div v-else class="subscriptions-table">
<div v-else-if="!error && filteredSubscriptions.length" class="subscriptions-table">
<table>
<thead>
<tr>
@ -471,12 +553,19 @@ watch(
<span v-else class="text-muted">-</span>
</td>
<td v-if="!isClusterWideMode">
<span :class="{ 'text-warning': sub.msgBacklog > 1000 }">
<span v-if="sub.backlogUnavailable" class="text-muted">-</span>
<span v-else :class="{ 'text-warning': sub.msgBacklog > 1000 }">
{{ sub.msgBacklog.toLocaleString() }}
</span>
</td>
<td v-if="!isClusterWideMode">{{ t("mqSubscriptions.msgRate", { rate: sub.msgRateOut.toFixed(2) }) }}</td>
<td>{{ isClusterWideMode ? (sub.onlineMembers ?? 0) : t("mqSubscriptions.consumerCount", { count: sub.consumers.length }) }}</td>
<td data-testid="online-members">
<template v-if="isClusterWideMode">
<span v-if="sub.onlineMembers == null" class="text-muted">-</span>
<span v-else>{{ sub.onlineMembers }}</span>
</template>
<template v-else>{{ t("mqSubscriptions.consumerCount", { count: sub.consumers.length }) }}</template>
</td>
<td class="actions">
<template v-if="isRocketMqCluster">
<button @click.stop="openRocketMqDetail(sub)" class="btn-sm">{{ t("mqSubscriptions.viewDetail") }}</button>
@ -692,6 +781,8 @@ watch(
</template>
<style scoped>
@import "./shared/mqPanel.css";
.subscriptions-panel {
height: 100%;
display: flex;
@ -744,20 +835,6 @@ watch(
font-size: 12px;
}
.btn-secondary {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
.subscription-type-filters {
display: flex;
flex-wrap: wrap;
@ -829,6 +906,7 @@ watch(
.panel-placeholder,
.panel-error,
.panel-hint,
.panel-loading {
padding: 24px;
text-align: center;
@ -839,6 +917,12 @@ watch(
color: var(--color-error);
}
.panel-hint {
padding: 8px 16px;
text-align: left;
font-size: 12px;
}
.subscriptions-table {
flex: 1;
overflow: auto;
@ -902,52 +986,16 @@ td {
font-weight: 500;
}
.text-muted {
color: var(--color-text-secondary);
}
.actions {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.btn-primary,
.btn-secondary,
.btn-sm,
.btn-danger {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
white-space: nowrap;
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
@ -992,16 +1040,6 @@ button:disabled {
font-size: 18px;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
max-height: 60vh;

View File

@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
import type { TenantInfo, TenantConfig } from "@/types/mq";
import { mqListTenants, mqCreateTenant, mqUpdateTenant, mqDeleteTenant } from "@/lib/backend/api";
import { defaultTenantConfig, normalizeClusterOptions, validateTenantForm } from "@/lib/mq/mqTenantForm";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
interface Props {
connectionId: string;
@ -19,6 +20,7 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const tenants = ref<TenantInfo[]>([]);
const loading = ref(false);
@ -46,12 +48,12 @@ const selectedAllowedClusters = computed(() => normalizeClusterOptions(formData.
const displayedSelectedClusters = computed(() => [...normalizedClusterOptions.value.filter((cluster) => selectedAllowedClusters.value.includes(cluster)), ...selectedAllowedClusters.value.filter((cluster) => !clusterOptionSet.value.has(cluster))]);
const canSubmitTenant = computed(() => Boolean(formData.value.name.trim()) && selectedAllowedClusters.value.length > 0);
function guardWritable() {
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = t("mqTenants.readOnly");
return false;
}
return true;
return confirmMqWrite(operation);
}
async function loadTenants() {
@ -67,7 +69,10 @@ async function loadTenants() {
}
function openCreateDialog() {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqTenants.readOnly");
return;
}
formData.value = {
name: "",
config: defaultTenantConfig(normalizedClusterOptions.value),
@ -79,7 +84,10 @@ function openCreateDialog() {
}
function openEditDialog(tenant: TenantInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqTenants.readOnly");
return;
}
editingTenant.value = tenant;
formData.value = {
name: tenant.name,
@ -95,7 +103,7 @@ function openEditDialog(tenant: TenantInfo) {
}
async function handleCreate() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqTenants.create")))) return;
const validationError = validateTenantForm(formData.value.name, formData.value.config);
if (validationError) {
error.value = t(validationError);
@ -116,7 +124,7 @@ async function handleCreate() {
}
async function handleUpdate() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqTenants.edit")))) return;
if (!editingTenant.value) return;
const validationError = validateTenantForm(formData.value.name, formData.value.config);
if (validationError) {
@ -138,7 +146,7 @@ async function handleUpdate() {
}
async function handleDelete(tenant: TenantInfo) {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqTenants.delete")))) return;
if (!confirm(t("mqTenants.confirmDelete", { name: tenant.name }))) return;
loading.value = true;
error.value = undefined;
@ -392,6 +400,8 @@ watch(normalizedClusterOptions, (clusters) => {
</template>
<style scoped>
@import "./shared/mqPanel.css";
.tenants-panel {
height: 100%;
display: flex;
@ -509,45 +519,6 @@ td {
gap: 8px;
}
.btn-primary,
.btn-secondary,
.btn-sm,
.btn-danger {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
@ -588,16 +559,6 @@ button:disabled {
font-size: 18px;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
max-height: 60vh;

View File

@ -14,6 +14,7 @@ import type { MqTab } from "@/lib/mq/mqConsoleDefaults";
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
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";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
const TOPIC_ROW_HEIGHT = 44;
@ -193,12 +194,14 @@ const canSubmitPartitionUpdate = computed(() => {
return !props.readOnly && current > 0 && Number.isFinite(newPartitions.value) && newPartitions.value > current;
});
function guardWritable() {
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return false;
}
return true;
return confirmMqWrite(operation);
}
async function loadTopics() {
@ -234,7 +237,10 @@ async function loadClusterInfo() {
}
function openCreateDialog() {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return;
}
dialogError.value = undefined;
const defaultBroker = rocketMqMasterBrokers.value[0]?.brokerName ?? rocketMqBrokerOptions.value[0]?.brokerName ?? "";
formData.value = {
@ -307,7 +313,10 @@ function isDlqTopic(topic: TopicInfo): boolean {
}
function openPartitionsDialog(topic: TopicInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return;
}
dialogError.value = undefined;
if (!topic.partitions || topic.partitions < 1) {
error.value = t("mqTopics.currentPartitionsUnknown");
@ -319,7 +328,7 @@ function openPartitionsDialog(topic: TopicInfo) {
}
async function handleCreate() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqTopics.create")))) return;
if (!formData.value.topicName.trim() || !props.tenant || !props.namespace) {
dialogError.value = t("mqTopics.topicNameRequired");
return;
@ -353,12 +362,16 @@ async function handleCreate() {
}
function handleDelete(topic: TopicInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return;
}
deleteTarget.value = topic;
showDeleteDialog.value = true;
}
async function confirmDelete() {
if (!(await guardWritable(t("mqTopics.delete")))) return;
const topic = deleteTarget.value;
if (!topic || !props.tenant || !props.namespace) return;
const namespace = resolveMqRowNamespace(topic, props.namespace);
@ -390,7 +403,7 @@ async function confirmDelete() {
}
async function handleUpdatePartitions() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqTopics.adjustPartitions")))) return;
if (!editingTopic.value || !props.tenant || !props.namespace) return;
const currentPartitions = editingTopic.value.partitions;
if (!currentPartitions || currentPartitions < 1) {
@ -719,6 +732,8 @@ watch(newPartitions, () => {
</template>
<style scoped>
@import "./shared/mqPanel.css";
.topics-panel {
--topics-surface: var(--card, var(--color-background, #ffffff));
--topics-header-bg: color-mix(in srgb, var(--secondary, #f5f5f5) 86%, var(--card, #ffffff));
@ -1010,45 +1025,6 @@ watch(newPartitions, () => {
color: var(--color-text);
}
.btn-primary,
.btn-secondary,
.btn-sm,
.btn-danger {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
@ -1089,16 +1065,6 @@ button:disabled {
font-size: 18px;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
max-height: 60vh;

View File

@ -25,6 +25,10 @@ vi.mock("@/lib/backend/api", () => ({
mqSendMessage: backend.mqSendMessage,
}));
vi.mock("@/composables/useMqMutationGuard", () => ({
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
}));
import MessageQueryPanel from "@/components/mq/MessageQueryPanel.vue";
const TOPIC = {

View File

@ -19,6 +19,10 @@ vi.mock("@/lib/backend/api", () => ({
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";

View File

@ -6,6 +6,7 @@ import type { PeekedMessage } from "@/types/mq";
const backend = vi.hoisted(() => ({
mqListSubscriptions: vi.fn(),
mqEnrichSubscriptions: vi.fn(),
mqCreateSubscription: vi.fn(),
mqDeleteSubscription: vi.fn(),
mqResetCursor: vi.fn(),
@ -21,6 +22,10 @@ vi.mock("vue-i18n", () => ({
vi.mock("@/lib/backend/api", () => backend);
vi.mock("@/composables/useMqMutationGuard", () => ({
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
}));
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({
default: { template: "<div />" },
}));
@ -61,7 +66,7 @@ function deferred<T>() {
return { promise, resolve };
}
async function mountPanel() {
async function mountPanel(overrides: Record<string, unknown> = {}) {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(SubscriptionsPanel, {
@ -71,15 +76,29 @@ async function mountPanel() {
namespace: "default",
mqSystemKind: "kafka",
supportsPeekMessages: true,
...overrides,
});
app.mount(root);
await flushUi();
return root;
}
function subscription(name: string, consumerGroupType: string) {
return {
name,
subType: consumerGroupType,
consumerGroupType,
messageModel: "CLUSTERING",
msgBacklog: 0,
msgRateOut: 0,
msgThroughputOut: 0,
consumers: [],
};
}
beforeEach(() => {
Object.values(backend).forEach((mock) => mock.mockReset());
backend.mqListSubscriptions.mockResolvedValue([
const rows = [
{
name: "orders-consumer",
subType: "shared",
@ -88,7 +107,9 @@ beforeEach(() => {
msgThroughputOut: 0,
consumers: [],
},
]);
];
backend.mqListSubscriptions.mockResolvedValue(rows);
backend.mqEnrichSubscriptions.mockResolvedValue(rows);
});
afterEach(() => {
@ -176,3 +197,63 @@ describe("SubscriptionsPanel message peek", () => {
expect(panel.textContent).not.toContain("orders message");
});
});
describe("SubscriptionsPanel RocketMQ online members", () => {
it("shows '-' when onlineMembers is unknown before enrich completes", async () => {
const enrich = deferred<ReturnType<typeof subscription>[]>();
const rows = [subscription("orders-group", "NORMAL")];
backend.mqListSubscriptions.mockResolvedValue(rows);
backend.mqEnrichSubscriptions.mockReturnValueOnce(enrich.promise);
const panel = await mountPanel({
topic: undefined,
tenant: "_rocketmq",
namespace: "default",
mqSystemKind: "rocketmq",
supportsPeekMessages: false,
});
// Fast list paints before enrich; unknown member count must not look like healthy zero.
const membersCell = () => panel.querySelector('[data-testid="online-members"]')?.textContent?.trim();
expect(membersCell()).toBe("-");
enrich.resolve([{ ...rows[0], onlineMembers: 2, topics: ["orders"] }]);
await flushUi();
expect(membersCell()).toBe("2");
});
});
describe("SubscriptionsPanel RocketMQ filter count", () => {
it("updates the visible/total count when type filters or search change", async () => {
const rows = [subscription("orders-group", "NORMAL"), subscription("payments-fifo", "FIFO"), subscription("CID_SYS_GROUP", "SYSTEM"), subscription("orders-retry", "NORMAL")];
backend.mqListSubscriptions.mockResolvedValue(rows);
backend.mqEnrichSubscriptions.mockResolvedValue(rows);
const panel = await mountPanel({
topic: undefined,
tenant: "_rocketmq",
namespace: "default",
mqSystemKind: "rocketmq",
supportsPeekMessages: false,
});
const count = () => panel.querySelector('[data-testid="subscription-count"]')?.textContent?.replace(/\s+/g, " ").trim();
// Default: SYSTEM off → 3 type-filtered groups.
expect(count()).toBe("3 / 3");
const systemCheckbox = [...panel.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find((input) => input.closest("label")?.textContent?.toLowerCase().includes("system"));
expect(systemCheckbox).toBeTruthy();
expect(systemCheckbox!.checked).toBe(false);
systemCheckbox!.click();
await flushUi();
expect(count()).toBe("4 / 4");
const search = panel.querySelector<HTMLInputElement>("input.topic-search");
expect(search).toBeTruthy();
search!.focus();
search!.value = "orders";
search!.dispatchEvent(new Event("input", { bubbles: true }));
await flushUi();
expect(count()).toBe("2 / 4");
});
});

View File

@ -5,6 +5,7 @@ import type { MqChannelInfo, MqClientConnectionInfo, NamespaceRef } from "@/type
import { mqCloseClientConnection, mqListClientChannels, mqListClientConnections } from "@/lib/backend/api";
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
import { formatError } from "@/lib/backend/errorUtils";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props {
@ -16,6 +17,7 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const connections = ref<MqClientConnectionInfo[]>([]);
const loading = ref(false);
@ -48,12 +50,12 @@ function nsRef(namespace?: string): NamespaceRef {
return { tenant: RABBITMQ_MQ_TENANT, namespace: namespace ?? "" };
}
function guardWritable() {
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = t("mqClientConnections.readOnly");
return false;
}
return true;
return confirmMqWrite(operation);
}
async function loadConnections() {
@ -112,12 +114,16 @@ function toggleExpanded(connection: MqClientConnectionInfo) {
}
function openCloseDialog(connection: MqClientConnectionInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqClientConnections.readOnly");
return;
}
closeTarget.value = connection;
showCloseDialog.value = true;
}
async function confirmClose() {
if (!(await guardWritable(t("mqClientConnections.closeConnection")))) return;
const target = closeTarget.value;
if (!target) return;
const namespace = resolveMqRowNamespace(target, props.namespace);
@ -274,6 +280,8 @@ watch(
</template>
<style scoped>
@import "../shared/mqPanel.css";
.clients-panel {
display: flex;
flex-direction: column;
@ -442,37 +450,6 @@ td {
align-items: center;
}
.btn-secondary,
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;

View File

@ -253,6 +253,8 @@ onUnmounted(() => {
</template>
<style scoped>
@import "../shared/mqPanel.css";
.rabbitmq-monitoring-panel {
display: flex;
flex-direction: column;
@ -415,21 +417,6 @@ td {
color: var(--color-error);
}
.btn-secondary {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;

View File

@ -5,6 +5,7 @@ import type { MqUserInfo, MqVhostPermission } from "@/types/mq";
import { mqCreateUser, mqDeleteUser, mqGrantUserPermission, mqListNamespaces, mqListUserPermissions, mqListUsers, mqRevokeUserPermission } from "@/lib/backend/api";
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults";
import { formatError } from "@/lib/backend/errorUtils";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props {
@ -16,6 +17,7 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const users = ref<MqUserInfo[]>([]);
const usersLoading = ref(false);
@ -58,12 +60,12 @@ const filteredPermissions = computed(() => {
return permissions.value.filter((permission) => permission.user === selectedUser.value);
});
function guardWritable(): boolean {
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = t("mqUserPermissions.readOnly");
return false;
}
return true;
return confirmMqWrite(operation);
}
async function loadUsers() {
@ -114,7 +116,10 @@ function toggleUserFilter(user: MqUserInfo) {
}
function openCreateDialog() {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqUserPermissions.readOnly");
return;
}
createForm.value = { name: "", password: "", tagsText: "" };
dialogError.value = undefined;
showCreateDialog.value = true;
@ -129,6 +134,7 @@ function parseTags(text: string): string[] | undefined {
}
async function handleCreateUser() {
if (!(await guardWritable(t("mqUserPermissions.createUser")))) return;
const name = createForm.value.name.trim();
const password = createForm.value.password;
if (!name) {
@ -153,12 +159,16 @@ async function handleCreateUser() {
}
function openDeleteDialog(user: MqUserInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqUserPermissions.readOnly");
return;
}
deleteTarget.value = user;
showDeleteDialog.value = true;
}
async function confirmDelete() {
if (!(await guardWritable(t("mqUserPermissions.delete")))) return;
const target = deleteTarget.value;
if (!target) return;
deleting.value = true;
@ -175,7 +185,10 @@ async function confirmDelete() {
}
function openGrantDialog() {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqUserPermissions.readOnly");
return;
}
const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : "";
grantForm.value = {
user: selectedUser.value ?? users.value[0]?.name ?? "",
@ -194,6 +207,7 @@ function patternOrDefault(value: string): string | undefined {
}
async function handleGrant() {
if (!(await guardWritable(t("mqUserPermissions.grant")))) return;
const user = grantForm.value.user.trim();
const virtualHost = grantForm.value.virtualHost.trim();
if (!user) {
@ -222,12 +236,16 @@ async function handleGrant() {
}
function openRevokeDialog(permission: MqVhostPermission) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqUserPermissions.readOnly");
return;
}
revokeTarget.value = permission;
showRevokeDialog.value = true;
}
async function confirmRevoke() {
if (!(await guardWritable(t("mqUserPermissions.revoke")))) return;
const target = revokeTarget.value;
if (!target) return;
revoking.value = true;
@ -457,6 +475,8 @@ watch(
</template>
<style scoped>
@import "../shared/mqPanel.css";
.user-permissions-panel {
display: flex;
flex-direction: column;
@ -639,48 +659,6 @@ td {
align-items: center;
}
.btn-primary,
.btn-secondary,
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
@ -721,16 +699,6 @@ button:disabled {
font-size: 18px;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
}

View File

@ -5,6 +5,7 @@ import type { MqPolicyInfo, MqPolicyUpsertRequest } from "@/types/mq";
import { mqDeletePolicy, mqListNamespaces, mqListPolicies, mqSetPolicy } from "@/lib/backend/api";
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults";
import { formatError } from "@/lib/backend/errorUtils";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props {
@ -21,6 +22,7 @@ interface DefinitionEntry {
const props = defineProps<Props>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
/** Well-known policy definition keys offered in the editor dropdown. */
const COMMON_DEFINITION_KEYS = ["message-ttl", "expires", "max-length", "max-length-bytes", "dead-letter-exchange", "dead-letter-routing-key", "max-age"];
@ -47,12 +49,12 @@ const showDeleteDialog = ref(false);
const deleteTarget = ref<MqPolicyInfo>();
const deleting = ref(false);
function guardWritable(): boolean {
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = t("mqRabbitMqPolicies.readOnly");
return false;
}
return true;
return confirmMqWrite(operation);
}
async function loadPolicies() {
@ -88,7 +90,10 @@ function definitionEntriesFrom(policy: MqPolicyInfo): DefinitionEntry[] {
}
function openCreateDialog() {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqRabbitMqPolicies.readOnly");
return;
}
const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : "";
editingPolicy.value = undefined;
editForm.value = { name: "", virtualHost: currentVhost || vhosts.value[0] || "", pattern: "", applyTo: "queues", priority: 0 };
@ -98,7 +103,10 @@ function openCreateDialog() {
}
function openEditDialog(policy: MqPolicyInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqRabbitMqPolicies.readOnly");
return;
}
editingPolicy.value = policy;
editForm.value = { name: policy.name, virtualHost: policy.vhost, pattern: policy.pattern, applyTo: policy.applyTo || "queues", priority: policy.priority };
definitionEntries.value = definitionEntriesFrom(policy);
@ -130,6 +138,7 @@ function buildDefinition(): Record<string, unknown> {
}
async function handleSave() {
if (!(await guardWritable(t("mqRabbitMqPolicies.save")))) return;
const name = editForm.value.name.trim();
const virtualHost = editForm.value.virtualHost.trim();
const pattern = editForm.value.pattern.trim();
@ -167,12 +176,16 @@ async function handleSave() {
}
function openDeleteDialog(policy: MqPolicyInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqRabbitMqPolicies.readOnly");
return;
}
deleteTarget.value = policy;
showDeleteDialog.value = true;
}
async function confirmDelete() {
if (!(await guardWritable(t("mqRabbitMqPolicies.delete")))) return;
const target = deleteTarget.value;
if (!target) return;
deleting.value = true;
@ -331,6 +344,8 @@ watch(
</template>
<style scoped>
@import "../shared/mqPanel.css";
.policies-panel {
display: flex;
flex-direction: column;
@ -467,48 +482,6 @@ td {
align-items: center;
}
.btn-primary,
.btn-secondary,
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
@ -549,16 +522,6 @@ button:disabled {
font-size: 18px;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
max-height: 70vh;

View File

@ -1,13 +1,23 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { ConsumerInfo, RocketMqConsumerGroupConfig, SubscriptionInfo, TopicRef } from "@/types/mq";
import type { ConsumerInfo, PartitionBacklog, RocketMqConsumerGroupConfig, SubscriptionInfo, TopicRef } from "@/types/mq";
import { mqAlterConsumerGroupConfig, mqGetBacklog, mqGetConsumerGroupConfig, mqListConsumers } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
import { resolveRocketMqConsumerGroupMessageModel, resolveRocketMqConsumerGroupType } from "@/lib/mq/rocketmqConsumerGroupTypes";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
export type RocketMqConsumerGroupDialogKind = "detail" | "config";
interface TopicConsumeDetail {
topic: string;
/** Undefined when backlog probe failed — do not render as healthy zero. */
delay?: number;
lastTimestamp?: number;
partitions: PartitionBacklog[];
error?: string;
}
interface Props {
connectionId: string;
tenant?: string;
@ -24,11 +34,15 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const loading = ref(false);
const dialogError = ref<string>();
const terminals = ref<ConsumerInfo[]>([]);
const topicBacklogs = ref<Array<{ topic: string; backlog: number }>>([]);
const topicConsumeDetails = ref<TopicConsumeDetail[]>([]);
/** Drops stale detail/config responses when enrich reloads the open dialog. */
let detailLoadSeq = 0;
let configLoadSeq = 0;
const configForm = ref<RocketMqConsumerGroupConfig>({
groupName: "",
consumeEnable: true,
@ -67,35 +81,75 @@ function closeDialog() {
emit("close");
}
/** Format consume timestamp; treat missing/zero as unavailable (avoid epoch display). */
function formatConsumeTimestamp(ms?: number | null): string {
if (ms == null || ms <= 0) return "-";
const date = new Date(ms);
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString();
}
function partitionRowKey(topic: string, partition: PartitionBacklog, index: number): string {
return `${topic}:${partition.brokerName ?? ""}:${partition.partition}:${index}`;
}
async function loadDetail() {
if (!props.group || !props.tenant || !props.namespace) return;
const seq = ++detailLoadSeq;
loading.value = true;
dialogError.value = undefined;
terminals.value = [];
topicBacklogs.value = [];
topicConsumeDetails.value = [];
try {
const topicRef = buildTopicRef("");
if (topicRef) {
terminals.value = await mqListConsumers(props.connectionId, topicRef, props.group.name);
const online = await mqListConsumers(props.connectionId, topicRef, props.group.name);
if (seq !== detailLoadSeq) return;
terminals.value = online;
}
const topics = subscribedTopics.value;
const backlogRows = await Promise.all(
const detailRows = await Promise.all(
topics.map(async (topic) => {
const ref = buildTopicRef(topic);
if (!ref) return { topic, backlog: 0 };
if (!ref) {
return { topic, partitions: [] as PartitionBacklog[], error: t("mqSubscriptions.invalidTopicScope") };
}
try {
const stats = await mqGetBacklog(props.connectionId, ref, props.group!.name);
return { topic, backlog: stats.msgBacklog };
} catch {
return { topic, backlog: 0 };
const partitions = stats.partitions ?? [];
// Topic-level last consume time = max of queue lastTimestamp values (> 0).
const lastTimestamp = partitions
.map((p) => p.lastTimestamp ?? 0)
.filter((ts) => ts > 0)
.reduce<number | undefined>((max, ts) => (max == null || ts > max ? ts : max), undefined);
return {
topic,
delay: stats.msgBacklog,
lastTimestamp,
partitions,
};
} catch (e: unknown) {
// Keep offsets empty; omit delay so the row does not look like lag=0.
return {
topic,
partitions: [] as PartitionBacklog[],
error: formatError(e) || String(e),
};
}
}),
);
topicBacklogs.value = backlogRows;
if (seq !== detailLoadSeq) return;
topicConsumeDetails.value = detailRows;
const backlogFailures = detailRows.filter((row) => row.error);
if (backlogFailures.length) {
dialogError.value = t("mqSubscriptions.backlogPartialFailed", {
count: backlogFailures.length,
error: backlogFailures[0]?.error ?? "",
});
}
} catch (e: unknown) {
dialogError.value = formatError(e);
if (seq === detailLoadSeq) dialogError.value = formatError(e);
} finally {
loading.value = false;
if (seq === detailLoadSeq) loading.value = false;
}
}
@ -115,11 +169,13 @@ function resetConfigForm(groupName: string) {
async function loadConfig() {
if (!props.group) return;
const seq = ++configLoadSeq;
resetConfigForm(props.group.name);
loading.value = true;
dialogError.value = undefined;
try {
const config = await mqGetConsumerGroupConfig(props.connectionId, props.group.name);
if (seq !== configLoadSeq) return;
configForm.value = {
groupName: config.groupName || props.group.name,
consumeEnable: config.consumeEnable ?? true,
@ -132,14 +188,15 @@ async function loadConfig() {
whichBrokerWhenConsumeSlowly: config.whichBrokerWhenConsumeSlowly ?? 0,
};
} catch (e: unknown) {
dialogError.value = formatError(e);
if (seq === configLoadSeq) dialogError.value = formatError(e);
} finally {
loading.value = false;
if (seq === configLoadSeq) loading.value = false;
}
}
async function saveConfig() {
if (!props.group || props.readOnly) return;
if (!(await confirmMqWrite(t("mqSubscriptions.editConfig")))) return;
loading.value = true;
dialogError.value = undefined;
try {
@ -163,11 +220,20 @@ async function saveConfig() {
}
watch(
() => [props.dialog, props.group?.name],
// Detail reloads when enrich fills topics; config must not reset the form on topics-only updates.
() => [props.dialog, props.group?.name, (props.group?.topics ?? []).join("\0")] as const,
() => {
if (props.dialog === "detail") {
void loadDetail();
} else if (props.dialog === "config") {
}
},
{ immediate: true },
);
watch(
() => [props.dialog, props.group?.name] as const,
() => {
if (props.dialog === "config") {
void loadConfig();
}
},
@ -193,7 +259,7 @@ watch(
<span>{{ t("mqSubscriptions.type") }}</span
><span>{{ groupTypeLabel }}</span> <span>{{ t("mqSubscriptions.mode") }}</span
><span>{{ groupModeLabel }}</span> <span>{{ t("mqSubscriptions.consumers") }}</span
><span>{{ group.onlineMembers ?? 0 }}</span>
><span>{{ group.onlineMembers == null ? "-" : group.onlineMembers }}</span>
<span>{{ t("mqSubscriptions.subscribedTopics") }}</span>
<span>{{ subscribedTopics.length ? subscribedTopics.join(", ") : "-" }}</span>
</div>
@ -226,21 +292,55 @@ watch(
<section class="detail-section">
<h4>{{ t("mqSubscriptions.consumerConsumeDetail") }}</h4>
<div v-if="!topicBacklogs.length" class="panel-placeholder">{{ t("mqSubscriptions.noConsumeDetail") }}</div>
<table v-else class="detail-table">
<thead>
<tr>
<th>{{ t("mqSubscriptions.operationTopic") }}</th>
<th>{{ t("mqSubscriptions.backlog") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in topicBacklogs" :key="row.topic">
<td>{{ row.topic }}</td>
<td>{{ row.backlog.toLocaleString() }}</td>
</tr>
</tbody>
</table>
<div v-if="loading && !topicConsumeDetails.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
<div v-else-if="!topicConsumeDetails.length" class="panel-placeholder">{{ t("mqSubscriptions.noConsumeDetail") }}</div>
<div v-else class="consume-detail-list">
<div v-for="detail in topicConsumeDetails" :key="detail.topic" class="consume-topic-block">
<div class="consume-topic-header">
<span
><strong>{{ t("mqSubscriptions.operationTopic") }}:</strong> {{ detail.topic }}</span
>
<span
><strong>{{ t("mqSubscriptions.consumeDelay") }}:</strong> {{ detail.delay == null ? "-" : detail.delay.toLocaleString() }}</span
>
<span
><strong>{{ t("mqSubscriptions.lastConsumeTime") }}:</strong> {{ formatConsumeTimestamp(detail.lastTimestamp) }}</span
>
</div>
<div v-if="detail.error" class="panel-placeholder panel-placeholder-compact form-error">
{{ detail.error }}
</div>
<div v-else-if="!detail.partitions.length" class="panel-placeholder panel-placeholder-compact">
{{ t("mqSubscriptions.noQueueConsumeProgress") }}
</div>
<div v-else class="detail-table-scroll">
<table class="detail-table detail-table-wide">
<thead>
<tr>
<th>{{ t("mqSubscriptions.broker") }}</th>
<th>{{ t("mqSubscriptions.queue") }}</th>
<th>{{ t("mqSubscriptions.consumerClient") }}</th>
<th>{{ t("mqSubscriptions.brokerOffset") }}</th>
<th>{{ t("mqSubscriptions.consumerOffset") }}</th>
<th>{{ t("mqSubscriptions.diffTotal") }}</th>
<th>{{ t("mqSubscriptions.lastTimestamp") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(partition, index) in detail.partitions" :key="partitionRowKey(detail.topic, partition, index)">
<td>{{ partition.brokerName || "-" }}</td>
<td>{{ partition.partition }}</td>
<td>{{ partition.consumerClient || "-" }}</td>
<td>{{ partition.endOffset.toLocaleString() }}</td>
<td>{{ partition.currentOffset.toLocaleString() }}</td>
<td>{{ partition.lag.toLocaleString() }}</td>
<td>{{ formatConsumeTimestamp(partition.lastTimestamp) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
@ -303,6 +403,8 @@ watch(
</template>
<style scoped>
@import "../shared/mqPanel.css";
.dialog-overlay {
position: fixed;
inset: 0;
@ -325,7 +427,7 @@ watch(
}
.dialog-wide {
max-width: 860px;
max-width: 1100px;
}
.dialog-header,
@ -354,14 +456,6 @@ watch(
overflow: auto;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
}
.detail-sections {
display: grid;
gap: 18px;
@ -388,17 +482,44 @@ watch(
font-size: 13px;
}
.consume-detail-list {
display: grid;
gap: 16px;
}
.consume-topic-block {
display: grid;
gap: 8px;
}
.consume-topic-header {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
font-size: 13px;
color: var(--color-text);
}
.detail-table-scroll {
overflow-x: auto;
}
.detail-table {
width: 100%;
border-collapse: collapse;
}
.detail-table-wide {
min-width: 720px;
}
.detail-table th,
.detail-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--color-border-light);
text-align: left;
font-size: 13px;
white-space: nowrap;
}
.detail-table th {
@ -454,6 +575,10 @@ watch(
font-size: 13px;
}
.panel-placeholder-compact {
padding: 10px;
}
.form-error {
margin-top: 12px;
padding: 8px 12px;
@ -463,26 +588,8 @@ watch(
font-size: 13px;
}
.btn-primary,
.btn-secondary,
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
button:disabled {
opacity: 0.5;
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@ -2,8 +2,9 @@
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { BrokerNode, ResetPosition, SubscriptionInfo, TopicInfo, TopicRef } from "@/types/mq";
import { mqAlterTopicConfig, mqGetTopicInternalStats, mqGetTopicRoute, mqGetTopicStats, mqListSubscriptions, mqResetCursor, mqSkipTopicAccumulation } from "@/lib/backend/api";
import { mqAlterTopicConfig, mqEnrichSubscriptions, mqGetTopicInternalStats, mqGetTopicRoute, mqGetTopicStats, mqListSubscriptions, mqResetCursor, mqSkipTopicAccumulation } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
export type RocketMqTopicDialogKind = "status" | "route" | "consumers" | "config" | "reset" | "skip";
@ -46,6 +47,7 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const loading = ref(false);
const dialogError = ref<string>();
@ -194,8 +196,13 @@ async function loadResetSubscriptions(topic: TopicRef): Promise<SubscriptionInfo
return sortResetSubscriptions(topicSpecific, topic.topic);
}
// Reset offset uses force=true; fall back to all cluster groups when the topic has no committed offsets yet.
// Prefer enrich so subscribed-topic sorting still works after the fast-list path dropped enrich.
const clusterWide: TopicRef = { ...topic, topic: "" };
return sortResetSubscriptions(await mqListSubscriptions(props.connectionId, clusterWide), topic.topic);
try {
return sortResetSubscriptions(await mqEnrichSubscriptions(props.connectionId, clusterWide), topic.topic);
} catch {
return sortResetSubscriptions(await mqListSubscriptions(props.connectionId, clusterWide), topic.topic);
}
}
function formatDateTimeLocal(date: Date): string {
@ -215,7 +222,12 @@ async function loadDialogData() {
} else if (props.dialog === "route") {
routeRaw.value = await mqGetTopicRoute(props.connectionId, topic);
} else if (props.dialog === "consumers") {
subscriptions.value = await mqListSubscriptions(props.connectionId, topic);
// Topic consumer dialog shows lag; includeLag is on the list path. Enrich fills members when cheap.
try {
subscriptions.value = await mqEnrichSubscriptions(props.connectionId, topic);
} catch {
subscriptions.value = await mqListSubscriptions(props.connectionId, topic);
}
selectedSubscription.value = subscriptions.value[0]?.name ?? "";
} else if (props.dialog === "reset") {
subscriptions.value = await loadResetSubscriptions(topic);
@ -237,6 +249,7 @@ async function loadDialogData() {
async function saveConfig() {
const topic = topicRef.value;
if (!topic || props.readOnly) return;
if (!(await confirmMqWrite(t("mqTopics.actionConfig")))) return;
loading.value = true;
dialogError.value = undefined;
try {
@ -258,6 +271,7 @@ async function saveConfig() {
async function confirmSkip() {
const topic = topicRef.value;
if (!topic || props.readOnly) return;
if (!(await confirmMqWrite(t("mqTopics.actionSkip")))) return;
if (!confirm(t("mqTopics.confirmSkip", { name: topic.topic }))) return;
loading.value = true;
dialogError.value = undefined;
@ -275,6 +289,7 @@ async function confirmSkip() {
async function confirmReset() {
const topic = topicRef.value;
if (!topic || props.readOnly || !selectedSubscription.value) return;
if (!(await confirmMqWrite(t("mqTopics.actionReset")))) return;
loading.value = true;
dialogError.value = undefined;
try {
@ -496,6 +511,8 @@ watch(
</template>
<style scoped>
@import "../shared/mqPanel.css";
.dialog-overlay {
position: fixed;
inset: 0;
@ -534,14 +551,6 @@ watch(
font-size: 18px;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
}
.dialog-body {
padding: 20px;
overflow-y: auto;
@ -674,28 +683,6 @@ watch(
border-top: 1px solid var(--color-border);
}
.btn-primary,
.btn-secondary,
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
cursor: pointer;
font-size: 13px;
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled,
input:disabled,
select:disabled {

View File

@ -233,13 +233,4 @@ const { t } = useI18n();
flex-direction: column;
gap: 16px;
}
.btn-close {
border: none;
background: none;
font-size: 22px;
line-height: 1;
cursor: pointer;
color: var(--color-text-secondary);
}
</style>

View File

@ -1,3 +1,6 @@
/* Shared MQ panel chrome + button system.
* Prefer .mq-btn-* in new code; .btn-* aliases keep existing panels consistent. */
.mq-panel-toolbar {
display: flex;
justify-content: space-between;
@ -51,9 +54,20 @@
font-size: 12px;
}
/* ---- Buttons (canonical + legacy aliases) ---- */
.mq-btn-primary,
.mq-btn-secondary,
.mq-btn-sm {
.mq-btn-sm,
.mq-btn-danger,
.btn-primary,
.btn-secondary,
.btn-sm,
.btn-danger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
@ -61,35 +75,115 @@
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.mq-btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-foreground);
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease,
filter 0.15s ease;
}
/* MQ chrome stays on the page surface in light theme avoid solid --color-primary
* fills (often near-black) that clash with toolbar/refresh and table action buttons. */
.mq-btn-primary:hover:not(:disabled),
.mq-btn-secondary:hover:not(:disabled),
.mq-btn-sm:hover:not(:disabled) {
.mq-btn-sm:hover:not(:disabled),
.btn-primary:hover:not(:disabled),
.btn-secondary:hover:not(:disabled),
.btn-sm:hover:not(:disabled) {
background: var(--color-hover);
}
.mq-btn-primary:hover:not(:disabled) {
filter: brightness(1.05);
.mq-btn-danger,
.mq-btn-sm.mq-btn-danger,
.mq-btn-sm.btn-danger,
.btn-danger,
.btn-sm.btn-danger,
.btn-sm.danger {
color: var(--color-error);
border-color: var(--color-error);
background: var(--color-background);
}
.mq-btn-danger:hover:not(:disabled),
.mq-btn-sm.mq-btn-danger:hover:not(:disabled),
.mq-btn-sm.btn-danger:hover:not(:disabled),
.btn-danger:hover:not(:disabled),
.btn-sm.btn-danger:hover:not(:disabled),
.btn-sm.danger:hover:not(:disabled) {
background: var(--color-error);
color: #fff;
filter: none;
}
.mq-btn-sm,
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.mq-btn-primary:disabled,
.mq-btn-secondary:disabled,
.mq-btn-sm:disabled {
.mq-btn-sm:disabled,
.mq-btn-danger:disabled,
.btn-primary:disabled,
.btn-secondary:disabled,
.btn-sm:disabled,
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: none;
}
/* Icon buttons only — do not style .btn-icon on nested SVG/icons */
.mq-btn-icon,
button.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 16px;
flex-shrink: 0;
transition:
background 0.15s ease,
color 0.15s ease;
}
.mq-btn-icon:hover:not(:disabled),
button.btn-icon:hover:not(:disabled) {
background: var(--color-background-secondary);
color: var(--color-text);
}
.mq-btn-icon:disabled,
button.btn-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mq-btn-sm.btn-danger,
.mq-btn-sm.mq-btn-danger {
color: var(--color-error);
border-color: var(--color-error);
.mq-btn-close,
.btn-close {
border: none;
background: none;
padding: 0;
font-size: 22px;
line-height: 1;
cursor: pointer;
color: var(--color-text-secondary);
}
.mq-btn-close:hover,
.btn-close:hover {
color: var(--color-text);
}
.mq-query-mode-tabs {
@ -154,26 +248,6 @@
background: var(--color-primary-alpha);
}
.mq-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 16px;
flex-shrink: 0;
}
.mq-btn-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mq-spin {
display: inline-block;
animation: mq-spin-anim 0.8s linear infinite;

View File

@ -0,0 +1,75 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
const requestConfirmation = vi.fn();
const toast = vi.fn();
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
getConfig: (id: string) => {
if (id === "missing") return undefined;
if (id === "readonly") return { name: "ro", read_only: true, is_production: false };
if (id === "prod") return { name: "prod-mq", read_only: false, is_production: true };
return { name: "dev-mq", read_only: false, is_production: false };
},
}),
}));
vi.mock("@/stores/productionSafetyStore", () => ({
useProductionSafetyStore: () => ({ requestConfirmation }),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast }),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
describe("useMqMutationGuard", () => {
beforeEach(() => {
setActivePinia(createPinia());
requestConfirmation.mockReset();
requestConfirmation.mockResolvedValue(true);
toast.mockReset();
});
it("denies missing and read-only connections without prompting", async () => {
const missing = useMqMutationGuard("missing");
const readonly = useMqMutationGuard("readonly");
await expect(missing.confirmMqWrite("send")).resolves.toBe(false);
await expect(readonly.confirmMqWrite("send")).resolves.toBe(false);
expect(requestConfirmation).not.toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith("mqAdmin.connectionMissing");
expect(toast).toHaveBeenCalledWith("mqAdmin.writeDeniedReadOnly");
});
it("allows non-production writes immediately", async () => {
const guard = useMqMutationGuard("dev");
await expect(guard.confirmMqWrite("send")).resolves.toBe(true);
expect(requestConfirmation).not.toHaveBeenCalled();
});
it("resolves getter connection ids used by MQ panels", async () => {
const guard = useMqMutationGuard(() => "dev");
await expect(guard.confirmMqWrite("send")).resolves.toBe(true);
expect(toast).not.toHaveBeenCalledWith("mqAdmin.connectionMissing");
});
it("prompts for production writes", async () => {
const guard = useMqMutationGuard("prod");
await expect(guard.confirmMqWrite("send")).resolves.toBe(true);
expect(requestConfirmation).toHaveBeenCalledWith({
sql: "send",
connectionName: "prod-mq",
source: "production.sourceMq",
});
});
});

View File

@ -0,0 +1,40 @@
import { toValue, type MaybeRefOrGetter } from "vue";
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { useToast } from "@/composables/useToast";
/**
* Shared write guard for MQ console mutations.
* Read-only is enforced by the backend; production connections require an
* explicit confirmation (same dialog as SQL production writes).
*/
export function useMqMutationGuard(connectionId: MaybeRefOrGetter<string>) {
const connectionStore = useConnectionStore();
const productionSafetyStore = useProductionSafetyStore();
const { toast } = useToast();
const { t } = useI18n();
async function confirmMqWrite(operation: string): Promise<boolean> {
// Call sites pass () => props.connectionId — must use toValue (not unref)
// so getters resolve to the real connection id.
const id = toValue(connectionId);
const config = connectionStore.getConfig(id);
if (!config) {
toast(t("mqAdmin.connectionMissing"));
return false;
}
if (config.read_only) {
toast(t("mqAdmin.writeDeniedReadOnly"));
return false;
}
if (!config.is_production) return true;
return productionSafetyStore.requestConfirmation({
sql: operation,
connectionName: config.name,
source: t("production.sourceMq"),
});
}
return { confirmMqWrite };
}

View File

@ -3991,6 +3991,7 @@ export default {
sourceDataCompare: "Data compare",
sourceExtension: "Extension manager",
sourceSidebar: "Object tree",
sourceMq: "Message queue",
sourceObjectSource: "Object source editor",
sourceDataGenerate: "Data generator",
sourceQueryHistory: "Query history rollback",
@ -6061,6 +6062,8 @@ export default {
viewTenant: "View tenant",
viewNamespace: "View namespace",
viewTopic: "View topic",
connectionMissing: "Connection not found. Reconnect and try again.",
writeDeniedReadOnly: "This connection is read-only and cannot perform write operations.",
readOnly: "Read-only",
allNamespaces: "All namespaces",
newNamespace: "New namespace",
@ -6352,8 +6355,13 @@ export default {
loading: "Loading...",
refresh: "Refresh",
refreshing: "Refreshing...",
enriching: "Loading online details...",
truncatedHint: "Showing the first {count} consumer groups (list capped). Narrow the search to find others.",
enrichFailedHint: "Online member/topic details could not be loaded ({error}). Showing the fast list only.",
noSubscriptions: "No subscriptions for this topic",
noConsumerGroups: "No consumer groups",
noMatches: "No matching consumer groups",
backlogPartialFailed: "Failed to load backlog for {count} topic(s): {error}",
viewDetail: "Details",
editConfig: "Config",
save: "Save",
@ -6364,7 +6372,18 @@ export default {
consumerConsumeDetail: "Consumption details",
noOnlineConsumers: "No online consumers",
noConsumeDetail: "No consumption details",
noQueueConsumeProgress: "No queue consumption progress",
operationTopic: "Topic",
consumeDelay: "Backlog",
invalidTopicScope: "Invalid topic scope",
lastConsumeTime: "Last consumption time",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "Consume enabled",
consumeBroadcastEnable: "Broadcast consume enabled",
consumeFromMinEnable: "Consume from min offset",
@ -6381,6 +6400,7 @@ export default {
normal: "Normal",
fifo: "Ordered",
system: "System",
unknown: "Unknown",
},
rocketmqGroupMode: {
clustering: "Cluster",
@ -6624,6 +6644,29 @@ export default {
searchHint: "Search for messages first, then click View trace",
viewTrace: "View trace",
detailTitle: "Message trace details",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "Unknown",
statusSuccess: "Success",
statusFail: "Failed",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store host",
fieldClientHost: "Client host",
fieldBodyLength: "Body length",
fieldCostTime: "Cost",
fieldMsgType: "Msg type",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "Retry",
fieldContextCode: "Context code",
rawPayload: "Raw trace",
traceTopicRouteMissing:
"The trace topic has no route on NameServer (CODE: 17).\n\nCommon causes:\n1. Broker trace is disabled: set traceTopicEnable=true in broker.conf and restart the broker\n2. Producer/Consumer trace is disabled: call setEnableTrace(true)\n3. A custom trace topic is used: select the correct trace topic above",
},

View File

@ -5917,6 +5917,8 @@ export default withEnglishFallback({
viewTenant: "Ver tenant",
viewNamespace: "Ver namespace",
viewTopic: "Ver tema",
connectionMissing: "No se encontró la conexión. Vuelve a conectar e inténtalo de nuevo.",
writeDeniedReadOnly: "Esta conexión es de solo lectura y no puede realizar operaciones de escritura.",
readOnly: "Solo lectura",
allNamespaces: "Todos los namespaces",
newNamespace: "Nuevo namespace",
@ -6258,7 +6260,12 @@ export default withEnglishFallback({
searchPlaceholder: "Buscar nombre de suscripción",
subscribedTopics: "Topics suscritos",
refreshing: "Actualizando...",
enriching: "Cargando detalles en línea...",
truncatedHint: "Mostrando los primeros {count} grupos de consumidores (límite). Restrinja la búsqueda para encontrar otros.",
enrichFailedHint: "No se pudieron cargar los detalles en línea ({error}). Solo se muestra la lista rápida.",
noConsumerGroups: "No hay grupos de consumidores",
noMatches: "No hay grupos de consumidores coincidentes",
backlogPartialFailed: "Error al cargar el backlog de {count} topic(s): {error}",
viewDetail: "Detalles",
editConfig: "Configurar",
save: "Guardar",
@ -6269,7 +6276,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "Detalles de consumo",
noOnlineConsumers: "No hay consumidores en línea actualmente",
noConsumeDetail: "Sin detalles de consumo",
noQueueConsumeProgress: "Sin progreso de consumo por cola",
operationTopic: "Topic",
consumeDelay: "Backlog",
invalidTopicScope: "Ámbito de tema no válido",
lastConsumeTime: "Último consumo",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "Permitir consumo",
consumeBroadcastEnable: "Permitir consumo en difusión",
consumeFromMinEnable: "Comenzar desde el offset mínimo",
@ -6284,6 +6302,7 @@ export default withEnglishFallback({
normal: "Normal",
fifo: "FIFO",
system: "Sistema",
unknown: "Desconocido",
},
rocketmqGroupMode: {
clustering: "Cluster",
@ -6522,6 +6541,7 @@ export default withEnglishFallback({
sourceDataCompare: "Comparación de datos",
sourceExtension: "Gestor de extensiones",
sourceSidebar: "Árbol de objetos",
sourceMq: "Cola de mensajes",
sourceObjectSource: "Editor de código fuente de objetos",
sourceDataGenerate: "Generador de datos",
sourceQueryHistory: "Reversión del historial de consultas",
@ -6916,5 +6936,28 @@ export default withEnglishFallback({
detailTitle: "Detalles de la traza de consumo",
traceTopicRouteMissing:
"El Topic de trazas no tiene información de ruta en NameServer (CODE: 17).\n\nCausas comunes:\n1. Broker no tiene habilitada la traza de mensajes: configure traceTopicEnable=true en broker.conf y reinicie el Broker\n2. Producer/Consumer no tiene habilitada la traza: setEnableTrace(true)\n3. Se utilizó un Topic de trazas personalizado, seleccione el Topic de trazas correcto arriba.",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "Desconocido",
statusSuccess: "Correcto",
statusFail: "Fallido",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store host",
fieldClientHost: "Client host",
fieldBodyLength: "Body length",
fieldCostTime: "Coste",
fieldMsgType: "Msg type",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "Reintentos",
fieldContextCode: "Context code",
rawPayload: "Traza en bruto",
},
});

View File

@ -5917,6 +5917,8 @@ export default withEnglishFallback({
viewTenant: "Visualizza tenant",
viewNamespace: "Visualizza namespace",
viewTopic: "Visualizza topic",
connectionMissing: "Connessione non trovata. Riconnetti e riprova.",
writeDeniedReadOnly: "La connessione corrente è in modalità sola lettura, impossibile eseguire operazioni di scrittura.",
readOnly: "Sola lettura",
allNamespaces: "Tutti i namespace",
newNamespace: "Nuovo namespace",
@ -6258,7 +6260,12 @@ export default withEnglishFallback({
searchPlaceholder: "Cerca nome sottoscrizione",
subscribedTopics: "Topic sottoscritti",
refreshing: "Aggiornamento in corso...",
enriching: "Caricamento dettagli online...",
truncatedHint: "Vengono mostrati i primi {count} consumer group (limite). Restringi la ricerca per trovarne altri.",
enrichFailedHint: "Impossibile caricare i dettagli online ({error}). Viene mostrata solo l'elenco rapido.",
noConsumerGroups: "Nessun Consumer Group",
noMatches: "Nessun consumer group corrispondente",
backlogPartialFailed: "Caricamento backlog non riuscito per {count} topic: {error}",
viewDetail: "Dettagli",
editConfig: "Configurazione",
save: "Salva",
@ -6269,7 +6276,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "Dettagli consumo",
noOnlineConsumers: "Nessun consumatore online",
noConsumeDetail: "Nessun dettaglio consumo",
noQueueConsumeProgress: "Nessun progresso di consumo per coda",
operationTopic: "Topic",
consumeDelay: "Backlog",
invalidTopicScope: "Ambito topic non valido",
lastConsumeTime: "Ultimo consumo",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "Abilita consumo",
consumeBroadcastEnable: "Abilita consumo broadcast",
consumeFromMinEnable: "Inizia dall'offset minimo",
@ -6284,6 +6302,7 @@ export default withEnglishFallback({
normal: "Normale",
fifo: "FIFO",
system: "Sistema",
unknown: "Sconosciuto",
},
rocketmqGroupMode: {
clustering: "Cluster",
@ -6522,6 +6541,7 @@ export default withEnglishFallback({
sourceDataCompare: "Confronto dati",
sourceExtension: "Gestione estensioni",
sourceSidebar: "Albero oggetti",
sourceMq: "Coda di messaggi",
sourceObjectSource: "Editor sorgente oggetto",
sourceDataGenerate: "Generatore dati",
sourceQueryHistory: "Rollback cronologia query",
@ -6916,5 +6936,28 @@ export default withEnglishFallback({
detailTitle: "Dettagli traccia consumo",
traceTopicRouteMissing:
"Il Topic di traccia non ha informazioni di instradamento sul NameServer (CODE: 17).\n\nCause comuni:\n1. Il Broker non ha abilitato la traccia messaggi: imposta traceTopicEnable=true in broker.conf e riavvia il Broker\n2. Producer/Consumer non hanno abilitato la traccia: setEnableTrace(true)\n3. È stato utilizzato un Topic di traccia personalizzato, seleziona il Topic di traccia corretto sopra.",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "Sconosciuto",
statusSuccess: "Riuscito",
statusFail: "Non riuscito",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store host",
fieldClientHost: "Client host",
fieldBodyLength: "Body length",
fieldCostTime: "Costo",
fieldMsgType: "Msg type",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "Tentativi",
fieldContextCode: "Context code",
rawPayload: "Traccia grezza",
},
});

View File

@ -5972,6 +5972,8 @@ export default withEnglishFallback({
viewTenant: "テナントを表示",
viewNamespace: "名前空間を表示",
viewTopic: "トピックを表示",
connectionMissing: "接続が見つかりません。再接続してから再試行してください。",
writeDeniedReadOnly: "現在の接続は読み取り専用モードです。書き込み操作は実行できません。",
readOnly: "読み取り専用",
allNamespaces: "すべての名前空間",
newNamespace: "新規名前空間",
@ -6313,7 +6315,12 @@ export default withEnglishFallback({
searchPlaceholder: "サブスクリプション名を検索",
subscribedTopics: "サブスクライブされた Topic",
refreshing: "更新中...",
enriching: "オンライン詳細を読み込み中...",
truncatedHint: "最初の {count} 件のコンシューマグループのみ表示しています(上限)。検索条件を絞ってください。",
enrichFailedHint: "オンラインメンバー/購読 Topic の読み込みに失敗しました({error})。高速一覧のみ表示しています。",
noConsumerGroups: "Consumer Group がありません",
noMatches: "一致するコンシューマグループがありません",
backlogPartialFailed: "{count} 件の Topic のバックログ読み込みに失敗しました: {error}",
viewDetail: "詳細",
editConfig: "設定",
save: "保存",
@ -6324,7 +6331,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "消費詳細",
noOnlineConsumers: "現在オンラインのコンシューマはいません",
noConsumeDetail: "消費詳細がありません",
noQueueConsumeProgress: "キューの消費進捗がありません",
operationTopic: "Topic",
consumeDelay: "バックログ",
invalidTopicScope: "トピックのスコープが無効です",
lastConsumeTime: "最終消費時間",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "消費を許可",
consumeBroadcastEnable: "ブロードキャスト消費を許可",
consumeFromMinEnable: "最小 offset から開始",
@ -6339,6 +6357,7 @@ export default withEnglishFallback({
normal: "通常",
fifo: "FIFO",
system: "システム",
unknown: "不明",
},
rocketmqGroupMode: {
clustering: "クラスタリング",
@ -6577,6 +6596,7 @@ export default withEnglishFallback({
sourceDataCompare: "データ比較",
sourceExtension: "拡張機能マネージャー",
sourceSidebar: "オブジェクトツリー",
sourceMq: "メッセージキュー",
sourceObjectSource: "オブジェクトソースエディター",
sourceDataGenerate: "データジェネレーター",
sourceQueryHistory: "クエリ履歴のロールバック",
@ -6971,5 +6991,28 @@ export default withEnglishFallback({
detailTitle: "消費トレース詳細",
traceTopicRouteMissing:
"トレース Topic が NameServer にルート情報がありません (CODE: 17)。\n\n一般的な原因:\n1. Broker でメッセージトレースが有効になっていない: broker.conf で traceTopicEnable=true を設定し Broker を再起動\n2. Producer/Consumer でトレースが有効になっていない: setEnableTrace(true)\n3. カスタムトレース Topic を使用している場合は、上で正しいトレース Topic を選択してください",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "不明",
statusSuccess: "成功",
statusFail: "失敗",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store Host",
fieldClientHost: "Client Host",
fieldBodyLength: "Body Length",
fieldCostTime: "所要時間",
fieldMsgType: "メッセージ種別",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "リトライ回数",
fieldContextCode: "Context Code",
rawPayload: "生トレース",
},
});

View File

@ -3538,6 +3538,7 @@ export default withEnglishFallback({
sourceDataCompare: "데이터 비교",
sourceExtension: "확장 관리자",
sourceSidebar: "객체 트리",
sourceMq: "메시지 큐",
sourceObjectSource: "객체 소스 편집기",
sourceDataGenerate: "데이터 생성기",
sourceQueryHistory: "쿼리 기록 롤백",
@ -5585,6 +5586,8 @@ export default withEnglishFallback({
viewTenant: "테넌트 보기",
viewNamespace: "네임스페이스 보기",
viewTopic: "토픽 보기",
connectionMissing: "연결을 찾을 수 없습니다. 다시 연결한 후 시도하세요.",
writeDeniedReadOnly: "이 연결은 읽기 전용이며 쓰기 작업을 수행할 수 없습니다.",
readOnly: "읽기 전용",
allNamespaces: "모든 네임스페이스",
newNamespace: "새 네임스페이스",
@ -5876,8 +5879,13 @@ export default withEnglishFallback({
loading: "불러오는 중...",
refresh: "새로고침",
refreshing: "새로고침 중...",
enriching: "온라인 상세 불러오는 중...",
truncatedHint: "처음 {count}개의 컨슈머 그룹만 표시합니다(상한). 검색 범위를 좁혀 주세요.",
enrichFailedHint: "온라인 멤버/구독 토픽 정보를 불러오지 못했습니다({error}). 빠른 목록만 표시합니다.",
noSubscriptions: "이 토픽에 구독이 없습니다",
noConsumerGroups: "컨슈머 그룹이 없습니다",
noMatches: "일치하는 컨슈머 그룹이 없습니다",
backlogPartialFailed: "{count}개 토픽의 백로그를 불러오지 못했습니다: {error}",
viewDetail: "상세",
editConfig: "구성",
save: "저장",
@ -5888,7 +5896,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "소비 상세",
noOnlineConsumers: "온라인 컨슈머가 없습니다",
noConsumeDetail: "소비 상세가 없습니다",
noQueueConsumeProgress: "큐 소비 진행 정보가 없습니다",
operationTopic: "토픽",
consumeDelay: "적체",
invalidTopicScope: "잘못된 토픽 범위",
lastConsumeTime: "마지막 소비 시간",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "소비 활성화",
consumeBroadcastEnable: "브로드캐스트 소비 활성화",
consumeFromMinEnable: "최소 오프셋부터 소비",
@ -5905,6 +5924,7 @@ export default withEnglishFallback({
normal: "일반",
fifo: "순서 보장",
system: "시스템",
unknown: "알 수 없음",
},
rocketmqGroupMode: {
clustering: "클러스터",
@ -6149,6 +6169,29 @@ export default withEnglishFallback({
detailTitle: "메시지 추적 상세",
traceTopicRouteMissing:
"추적 토픽이 NameServer에 라우트 정보가 없습니다 (CODE: 17).\n\n일반적인 원인:\n1. Broker 추적이 비활성화됨: broker.conf에서 traceTopicEnable=true를 설정하고 Broker를 다시 시작\n2. Producer/Consumer 추적이 비활성화됨: setEnableTrace(true) 호출\n3. 사용자 지정 추적 토픽 사용: 위에서 올바른 추적 토픽을 선택하세요",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "알 수 없음",
statusSuccess: "성공",
statusFail: "실패",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store Host",
fieldClientHost: "Client Host",
fieldBodyLength: "Body Length",
fieldCostTime: "소요 시간",
fieldMsgType: "메시지 유형",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "재시도",
fieldContextCode: "Context Code",
rawPayload: "원본 추적",
},
mqRaw: {
title: "원시 API",

View File

@ -5919,6 +5919,8 @@ export default withEnglishFallback({
viewTenant: "Ver locatário",
viewNamespace: "Ver namespace",
viewTopic: "Ver tópico",
connectionMissing: "Conexão não encontrada. Reconecte e tente novamente.",
writeDeniedReadOnly: "Esta conexão é somente leitura e não pode executar operações de gravação.",
readOnly: "Somente leitura",
allNamespaces: "Todos os namespaces",
newNamespace: "Novo namespace",
@ -6260,7 +6262,12 @@ export default withEnglishFallback({
searchPlaceholder: "Pesquisar nome da assinatura",
subscribedTopics: "Tópicos inscritos",
refreshing: "Atualizando...",
enriching: "Carregando detalhes online...",
truncatedHint: "Mostrando os primeiros {count} grupos de consumidores (limite). Restrinja a busca para encontrar outros.",
enrichFailedHint: "Não foi possível carregar os detalhes online ({error}). Mostrando apenas a lista rápida.",
noConsumerGroups: "Nenhum Consumer Group",
noMatches: "Nenhum grupo de consumidores correspondente",
backlogPartialFailed: "Falha ao carregar backlog de {count} tópico(s): {error}",
viewDetail: "Detalhes",
editConfig: "Configuração",
save: "Salvar",
@ -6271,7 +6278,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "Detalhes do consumo",
noOnlineConsumers: "Nenhum consumidor online no momento",
noConsumeDetail: "Nenhum detalhe de consumo",
noQueueConsumeProgress: "Sem progresso de consumo por fila",
operationTopic: "Topic",
consumeDelay: "Backlog",
invalidTopicScope: "Escopo de tópico inválido",
lastConsumeTime: "Último consumo",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "Permitir consumo",
consumeBroadcastEnable: "Permitir consumo em broadcast",
consumeFromMinEnable: "Iniciar do offset mínimo",
@ -6286,6 +6304,7 @@ export default withEnglishFallback({
normal: "Normal",
fifo: "Ordenado",
system: "Sistema",
unknown: "Desconhecido",
},
rocketmqGroupMode: {
clustering: "Cluster",
@ -6524,6 +6543,7 @@ export default withEnglishFallback({
sourceDataCompare: "Comparação de dados",
sourceExtension: "Gerenciador de extensões",
sourceSidebar: "Árvore de objetos",
sourceMq: "Fila de mensagens",
sourceObjectSource: "Editor de código-fonte de objetos",
sourceDataGenerate: "Gerador de dados",
sourceQueryHistory: "Reversão do histórico de consultas",
@ -6918,5 +6938,28 @@ export default withEnglishFallback({
detailTitle: "Detalhes do rastreamento de consumo",
traceTopicRouteMissing:
"O tópico de rastreamento não possui informações de rota no NameServer (CODE: 17).\n\nCausas comuns:\n1. Broker não habilitou rastreamento de mensagens: defina traceTopicEnable=true no broker.conf e reinicie o Broker\n2. Producer/Consumer não habilitou rastreamento: setEnableTrace(true)\n3. Usou um tópico de rastreamento personalizado, selecione o tópico correto acima.",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "Desconhecido",
statusSuccess: "Sucesso",
statusFail: "Falhou",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store host",
fieldClientHost: "Client host",
fieldBodyLength: "Body length",
fieldCostTime: "Custo",
fieldMsgType: "Msg type",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "Tentativas",
fieldContextCode: "Context code",
rawPayload: "Rastreamento bruto",
},
});

View File

@ -3990,6 +3990,7 @@ export default withEnglishFallback({
sourceDataCompare: "数据对比",
sourceExtension: "扩展管理",
sourceSidebar: "对象树",
sourceMq: "消息队列",
sourceObjectSource: "对象源码编辑器",
sourceDataGenerate: "数据生成器",
sourceQueryHistory: "查询历史回滚",
@ -6058,6 +6059,8 @@ export default withEnglishFallback({
viewTenant: "查看租户",
viewNamespace: "查看命名空间",
viewTopic: "查看主题",
connectionMissing: "未找到连接,请重新连接后再试。",
writeDeniedReadOnly: "当前连接为只读模式,不能执行写操作。",
readOnly: "只读",
allNamespaces: "全部命名空间",
newNamespace: "新建命名空间",
@ -6344,6 +6347,9 @@ export default withEnglishFallback({
createSubscription: "创建订阅",
selectTopicFirst: "请先选择一个主题",
searchPlaceholder: "搜索订阅名称",
enriching: "正在补全在线信息...",
truncatedHint: "当前仅显示前 {count} 个消费组(已达上限),请缩小搜索范围查找其他组",
enrichFailedHint: "在线成员/订阅 Topic 信息加载失败({error}),当前仅显示快速列表",
subscribedTopics: "订阅 Topic",
readOnly: "当前连接为只读模式,不能执行写操作",
loading: "加载中...",
@ -6351,6 +6357,8 @@ export default withEnglishFallback({
refreshing: "刷新中...",
noSubscriptions: "该主题暂无订阅",
noConsumerGroups: "暂无 Consumer Group",
noMatches: "没有匹配的 Consumer Group",
backlogPartialFailed: "{count} 个 Topic 的堆积信息加载失败:{error}",
viewDetail: "详情",
editConfig: "配置",
save: "保存",
@ -6361,7 +6369,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "消费详情",
noOnlineConsumers: "当前没有在线消费者",
noConsumeDetail: "暂无消费详情",
noQueueConsumeProgress: "暂无队列消费进度",
operationTopic: "Topic",
consumeDelay: "积压",
invalidTopicScope: "无效的 Topic 作用域",
lastConsumeTime: "最后消费时间",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "允许消费",
consumeBroadcastEnable: "允许广播消费",
consumeFromMinEnable: "从最小 offset 开始",
@ -6378,6 +6397,7 @@ export default withEnglishFallback({
normal: "普通",
fifo: "顺序",
system: "系统",
unknown: "未知",
},
rocketmqGroupMode: {
clustering: "集群",
@ -6620,6 +6640,29 @@ export default withEnglishFallback({
searchHint: "请先搜索消息,再点击「查看消费轨迹」",
viewTrace: "查看消费轨迹",
detailTitle: "消费轨迹详情",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "未知",
statusSuccess: "成功",
statusFail: "失败",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store Host",
fieldClientHost: "Client Host",
fieldBodyLength: "Body Length",
fieldCostTime: "耗时",
fieldMsgType: "消息类型",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "重试次数",
fieldContextCode: "Context Code",
rawPayload: "原始轨迹",
traceTopicRouteMissing: "轨迹 Topic 在 NameServer 上没有路由信息CODE: 17。\n\n常见原因\n1. Broker 未开启消息轨迹:在 broker.conf 中设置 traceTopicEnable=true 并重启 Broker\n2. Producer/Consumer 未开启轨迹setEnableTrace(true)\n3. 使用了自定义轨迹 Topic请在上方选择正确的轨迹 Topic",
},
mqRaw: {

View File

@ -5912,6 +5912,8 @@ export default withEnglishFallback({
viewTenant: "檢視租用戶",
viewNamespace: "檢視命名空間",
viewTopic: "檢視主題",
connectionMissing: "找不到連線,請重新連線後再試。",
writeDeniedReadOnly: "目前連線為唯讀模式,無法執行寫入操作。",
readOnly: "唯讀",
allNamespaces: "全部命名空間",
newNamespace: "新建命名空間",
@ -6253,7 +6255,12 @@ export default withEnglishFallback({
searchPlaceholder: "搜尋訂閱名稱",
subscribedTopics: "訂閱 Topic",
refreshing: "重新整理中...",
enriching: "正在載入線上詳情...",
truncatedHint: "目前僅顯示前 {count} 個消費組(已達上限),請縮小搜尋範圍查找其他組",
enrichFailedHint: "線上成員/訂閱 Topic 資訊載入失敗({error}),目前僅顯示快速列表",
noConsumerGroups: "暫無 Consumer Group",
noMatches: "沒有符合的消費組",
backlogPartialFailed: "{count} 個 Topic 的堆積資訊載入失敗:{error}",
viewDetail: "詳情",
editConfig: "配置",
save: "儲存",
@ -6264,7 +6271,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "消費詳情",
noOnlineConsumers: "當前沒有線上消費者",
noConsumeDetail: "暫無消費詳情",
noQueueConsumeProgress: "暫無佇列消費進度",
operationTopic: "Topic",
consumeDelay: "積壓",
invalidTopicScope: "無效的主題作用域",
lastConsumeTime: "最後消費時間",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "允許消費",
consumeBroadcastEnable: "允許廣播消費",
consumeFromMinEnable: "從最小 offset 開始",
@ -6279,6 +6297,7 @@ export default withEnglishFallback({
normal: "普通",
fifo: "順序",
system: "系統",
unknown: "未知",
},
rocketmqGroupMode: {
clustering: "集群",
@ -6517,6 +6536,7 @@ export default withEnglishFallback({
sourceDataCompare: "資料比對",
sourceExtension: "擴充功能管理",
sourceSidebar: "物件樹",
sourceMq: "訊息佇列",
sourceObjectSource: "物件原始碼編輯器",
sourceDataGenerate: "資料產生器",
sourceQueryHistory: "查詢歷史回復",
@ -6909,6 +6929,29 @@ export default withEnglishFallback({
searchHint: "請先搜尋消息,再點擊「檢視消費軌跡」",
viewTrace: "檢視消費軌跡",
detailTitle: "消費軌跡詳情",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "未知",
statusSuccess: "成功",
statusFail: "失敗",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store Host",
fieldClientHost: "Client Host",
fieldBodyLength: "Body Length",
fieldCostTime: "耗時",
fieldMsgType: "訊息類型",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "重試次數",
fieldContextCode: "Context Code",
rawPayload: "原始軌跡",
traceTopicRouteMissing: "軌跡 Topic 在 NameServer 上沒有路由資訊CODE: 17。\n\n常見原因\n1. Broker 未開啟消息軌跡:在 broker.conf 中設置 traceTopicEnable=true 並重新啟動 Broker\n2. Producer/Consumer 未開啟軌跡setEnableTrace(true)\n3. 使用了自定義軌跡 Topic請在上方選擇正確的軌跡 Topic",
},
});

View File

@ -489,6 +489,7 @@ export const mqListClientConnections = forward("mqListClientConnections");
export const mqListClientChannels = forward("mqListClientChannels");
export const mqCloseClientConnection = forward("mqCloseClientConnection");
export const mqListSubscriptions = forward("mqListSubscriptions");
export const mqEnrichSubscriptions = forward("mqEnrichSubscriptions");
export const mqCreateSubscription = forward("mqCreateSubscription");
export const mqDeleteSubscription = forward("mqDeleteSubscription");
export const mqSkipMessages = forward("mqSkipMessages");

View File

@ -217,6 +217,10 @@ export async function mqListSubscriptions(connectionId: string, topic: TopicRef)
return post("/api/mq/subscriptions/list", { connectionId, topic });
}
export async function mqEnrichSubscriptions(connectionId: string, topic: TopicRef): Promise<SubscriptionInfo[]> {
return post("/api/mq/subscriptions/enrich", { connectionId, topic });
}
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
return post("/api/mq/subscriptions/create", { connectionId, topic, sub, pos });
}

View File

@ -211,6 +211,10 @@ export async function mqListSubscriptions(connectionId: string, topic: TopicRef)
return invoke("mq_list_subscriptions", { connectionId, topic });
}
export async function mqEnrichSubscriptions(connectionId: string, topic: TopicRef): Promise<SubscriptionInfo[]> {
return invoke("mq_enrich_subscriptions", { connectionId, topic });
}
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
return invoke("mq_create_subscription", { connectionId, topic, sub, pos });
}

View File

@ -5,13 +5,15 @@ describe("rocketmqConsumerGroupTypes", () => {
it("resolves group type from consumerGroupType or subType", () => {
expect(resolveRocketMqConsumerGroupType({ consumerGroupType: "FIFO", subType: "NORMAL" })).toBe("FIFO");
expect(resolveRocketMqConsumerGroupType({ subType: "SYSTEM" })).toBe("SYSTEM");
expect(resolveRocketMqConsumerGroupType({ consumerGroupType: "UNKNOWN" })).toBe("UNKNOWN");
expect(resolveRocketMqConsumerGroupType({ subType: "CONSUME_PASSIVELY ? CLUSTERING" })).toBe("NORMAL");
});
it("filters by dashboard group types", () => {
const filters = { NORMAL: true, FIFO: true, SYSTEM: false };
const filters = { NORMAL: true, FIFO: true, SYSTEM: false, UNKNOWN: true };
expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "NORMAL", subType: "NORMAL" }, filters)).toBe(true);
expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "SYSTEM", subType: "SYSTEM" }, filters)).toBe(false);
expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "UNKNOWN", subType: "UNKNOWN" }, filters)).toBe(true);
});
it("resolves message model", () => {

View File

@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { formatRocketMqTraceError, isRocketMqTraceTopicRouteMissingError } from "@/lib/mq/rocketmqTraceUtils";
import { formatRocketMqTraceError, isParsedRocketMqTraceRecord, isRocketMqTraceTopicRouteMissingError, parseRocketMqTracePayload, splitRocketMqTraceKeys } from "@/lib/mq/rocketmqTraceUtils";
const SOH = "\u0001";
const STX = "\u0002";
function joinFields(...parts: string[]): string {
return parts.join(SOH);
}
describe("rocketmqTraceUtils", () => {
it("detects RocketMQ trace topic route missing errors", () => {
@ -11,4 +18,75 @@ describe("rocketmqTraceUtils", () => {
const hint = "Enable traceTopicEnable on broker";
expect(formatRocketMqTraceError(new Error("CODE: 17 DESC: No topic route info in name server for the topic: RMQ_SYS_TRACE_TOPIC"), hint)).toBe(hint);
});
it("parses Pub records including optional clientHost", () => {
const payload = joinFields(
"Pub",
"1785728122381",
"DefaultRegion",
"api-orch-server_producer",
"SIGNATURE_OPERATE_LOG_TOPIC_DEV",
"AC12021900073BCED0D20CC95A0D0197",
"ADD_SIGNATURE_OPERATE_LOG_TAG",
"AC12021900073BCED0D20CC95A0D0197 AC12021900073BCED0D20CC95BFF019F",
"192.168.3.25:10911",
"512",
"4",
"0",
"AC12021900073BCED0D20CC95A0D0197",
"true",
"192.168.2.25",
);
const [record] = parseRocketMqTracePayload(payload);
expect(record.type).toBe("Pub");
expect(record.timestamp).toBe(1785728122381);
expect(record.success).toBe(true);
expect(isParsedRocketMqTraceRecord(record)).toBe(true);
expect(record.fields.find((field) => field.key === "group")?.value).toBe("api-orch-server_producer");
expect(record.fields.find((field) => field.key === "topic")?.value).toBe("SIGNATURE_OPERATE_LOG_TOPIC_DEV");
expect(record.fields.find((field) => field.key === "costTime")?.value).toBe("4 ms");
expect(record.fields.find((field) => field.key === "clientHost")?.value).toBe("192.168.2.25");
});
it("parses SubBefore and SubAfter with version-tolerant lengths", () => {
const subBefore = joinFields("SubBefore", "1785728123000", "DefaultRegion", "LISTING_AGREEMENT_SYNC_TOPIC_LISTING_FULL_AGREEMENT_CONSUMER_DEV", "req-1", "AC12021900073BCED0D20CC95BFF019F", "0", "null", "192.168.2.30");
const subAfterOld = joinFields("SubAfter", "req-1", "AC12021900073BCED0D20CC95BFF019F", "12", "true", "k1", "0");
const subAfterNew = joinFields("SubAfter", "req-2", "AC12021900073BCED0D20CC95BFF019F", "20", "false", "k2", "1", "1785728123120", "LISTING_AGREEMENT_SYNC_TOPIC_LISTING_FULL_AGREEMENT_CONSUMER_DEV");
const records = parseRocketMqTracePayload(`${subBefore}${STX}${subAfterOld}${STX}${subAfterNew}`);
expect(records).toHaveLength(3);
expect(records[0].type).toBe("SubBefore");
expect(records[0].fields.find((field) => field.key === "retryTimes")?.value).toBe("0");
expect(records[0].fields.find((field) => field.key === "keys")?.value).toBe("-");
expect(records[0].fields.find((field) => field.key === "clientHost")?.value).toBe("192.168.2.30");
expect(records[1].type).toBe("SubAfter");
expect(records[1].success).toBe(true);
expect(records[1].timestamp).toBeUndefined();
expect(records[1].fields.find((field) => field.key === "costTime")?.value).toBe("12 ms");
expect(records[2].type).toBe("SubAfter");
expect(records[2].success).toBe(false);
expect(records[2].timestamp).toBe(1785728123120);
expect(records[2].fields.find((field) => field.key === "group")?.value).toBe("LISTING_AGREEMENT_SYNC_TOPIC_LISTING_FULL_AGREEMENT_CONSUMER_DEV");
});
it("normalizes Type@ prefix and falls back for unknown payloads", () => {
const withAt = `Pub@1785728122381${SOH}DefaultRegion${SOH}g${SOH}t${SOH}m${SOH}tag${SOH}k${SOH}host${SOH}1${SOH}2${SOH}0${SOH}off${SOH}true`;
const [parsed] = parseRocketMqTracePayload(withAt);
expect(parsed.type).toBe("Pub");
expect(parsed.fields.find((field) => field.key === "group")?.value).toBe("g");
const [unknown] = parseRocketMqTracePayload("not-a-trace-payload");
expect(unknown.type).toBe("Unknown");
expect(isParsedRocketMqTraceRecord(unknown)).toBe(false);
expect(unknown.raw).toBe("not-a-trace-payload");
});
it("splits KEYS into chips", () => {
expect(splitRocketMqTraceKeys("a b,c")).toEqual(["a", "b", "c"]);
expect(splitRocketMqTraceKeys("null")).toEqual([]);
expect(splitRocketMqTraceKeys("-")).toEqual([]);
});
});

View File

@ -1,19 +1,21 @@
import type { SubscriptionInfo } from "@/types/mq";
export type RocketMqConsumerGroupType = "NORMAL" | "FIFO" | "SYSTEM";
export type RocketMqConsumerGroupType = "NORMAL" | "FIFO" | "SYSTEM" | "UNKNOWN";
export const ROCKETMQ_CONSUMER_GROUP_TYPES: readonly RocketMqConsumerGroupType[] = ["NORMAL", "FIFO", "SYSTEM"];
export const ROCKETMQ_CONSUMER_GROUP_TYPES: readonly RocketMqConsumerGroupType[] = ["NORMAL", "FIFO", "SYSTEM", "UNKNOWN"];
export const DEFAULT_ROCKETMQ_CONSUMER_GROUP_TYPE_FILTERS: Record<RocketMqConsumerGroupType, boolean> = {
NORMAL: true,
FIFO: true,
SYSTEM: false,
UNKNOWN: true,
};
export function resolveRocketMqConsumerGroupType(sub: Pick<SubscriptionInfo, "consumerGroupType" | "subType">): RocketMqConsumerGroupType {
const raw = (sub.consumerGroupType ?? sub.subType)?.trim().toUpperCase();
if (raw === "FIFO" || raw === "ORDER" || raw === "ORDERLY") return "FIFO";
if (raw === "SYSTEM") return "SYSTEM";
if (raw === "UNKNOWN") return "UNKNOWN";
if (raw === "NORMAL") return "NORMAL";
return "NORMAL";
}

View File

@ -1,4 +1,5 @@
import { formatError } from "@/lib/backend/errorUtils";
import { formatRocketMqTimestamp } from "@/lib/mq/rocketmqMessageUtils";
/** RocketMQ CODE 17: trace topic has no route on NameServer (trace not enabled or wrong topic). */
export function isRocketMqTraceTopicRouteMissingError(error: unknown): boolean {
@ -12,3 +13,184 @@ export function formatRocketMqTraceError(error: unknown, traceTopicMissingHint:
}
return formatError(error);
}
/** Content / field splitters from Apache RocketMQ TraceConstants. */
const CONTENT_SPLITTER = "\u0001";
const FIELD_SPLITTER = "\u0002";
export type RocketMqTraceType = "Pub" | "SubBefore" | "SubAfter" | "EndTransaction" | "Unknown";
/** Stable field keys mapped to mqTrace.field* i18n labels in the dialog. */
export type RocketMqTraceFieldKey = "regionId" | "group" | "topic" | "msgId" | "tags" | "keys" | "storeHost" | "clientHost" | "bodyLength" | "costTime" | "msgType" | "offsetMsgId" | "requestId" | "retryTimes" | "contextCode";
export interface RocketMqTraceField {
key: RocketMqTraceFieldKey;
value: string;
}
export interface RocketMqTraceRecord {
type: RocketMqTraceType;
timestamp?: number;
/** Parsed success flag when present (Pub / SubAfter). */
success?: boolean;
fields: RocketMqTraceField[];
raw: string;
}
function emptyDisplay(value: string | undefined): string {
if (value == null) return "-";
const trimmed = value.trim();
if (!trimmed || trimmed === "null") return "-";
return trimmed;
}
function parseTimestamp(raw: string | undefined): number | undefined {
if (!raw?.trim()) return undefined;
const numeric = Number(raw);
return Number.isFinite(numeric) ? numeric : undefined;
}
function parseSuccess(raw: string | undefined): boolean | undefined {
if (raw == null || !raw.trim()) return undefined;
const normalized = raw.trim().toLowerCase();
if (normalized === "true") return true;
if (normalized === "false") return false;
return undefined;
}
function formatCostTime(raw: string | undefined): string {
const value = emptyDisplay(raw);
if (value === "-") return value;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return value;
return `${numeric} ms`;
}
function pushField(fields: RocketMqTraceField[], key: RocketMqTraceFieldKey, value: string | undefined): void {
const display = key === "costTime" ? formatCostTime(value) : emptyDisplay(value);
// Keep tags/keys/retry visible even when empty so Pub/Sub cards stay scannable.
if (display === "-" && key !== "tags" && key !== "keys" && key !== "retryTimes") return;
fields.push({ key, value: display });
}
/** Normalize rare "Type@fields" prefix into official Type\\u0001fields form. */
function normalizeTraceSegment(segment: string): string {
return segment.replace(/^(Pub|SubBefore|SubAfter|EndTransaction)@/, `$1${CONTENT_SPLITTER}`);
}
function parsePub(parts: string[], raw: string): RocketMqTraceRecord {
// Pub: type, ts, region, group, topic, msgId, tags, keys, storeHost, bodyLength, cost, msgType, offsetMsgId, success[, clientHost]
const timestamp = parseTimestamp(parts[1]);
const success = parseSuccess(parts[13]);
const fields: RocketMqTraceField[] = [];
pushField(fields, "regionId", parts[2]);
pushField(fields, "group", parts[3]);
pushField(fields, "topic", parts[4]);
pushField(fields, "msgId", parts[5]);
pushField(fields, "tags", parts[6]);
pushField(fields, "keys", parts[7]);
pushField(fields, "storeHost", parts[8]);
pushField(fields, "bodyLength", parts[9]);
pushField(fields, "costTime", parts[10]);
pushField(fields, "msgType", parts[11]);
pushField(fields, "offsetMsgId", parts[12]);
if (parts.length >= 15) pushField(fields, "clientHost", parts[14]);
return { type: "Pub", timestamp, success, fields, raw };
}
function parseSubBefore(parts: string[], raw: string): RocketMqTraceRecord {
// SubBefore: type, ts, region, group, requestId, msgId, retryTimes, keys[, clientHost]
// Some encoders also append storeHost before clientHost (length >= 9/10).
const timestamp = parseTimestamp(parts[1]);
const fields: RocketMqTraceField[] = [];
pushField(fields, "regionId", parts[2]);
pushField(fields, "group", parts[3]);
pushField(fields, "requestId", parts[4]);
pushField(fields, "msgId", parts[5]);
pushField(fields, "retryTimes", parts[6]);
pushField(fields, "keys", parts[7]);
if (parts.length >= 10) {
pushField(fields, "storeHost", parts[8]);
pushField(fields, "clientHost", parts[9]);
} else if (parts.length >= 9) {
pushField(fields, "clientHost", parts[8]);
}
return { type: "SubBefore", timestamp, fields, raw };
}
function parseSubAfter(parts: string[], raw: string): RocketMqTraceRecord {
// SubAfter: type, requestId, msgId, cost, success, keys, contextCode[, timestamp, groupName]
const success = parseSuccess(parts[4]);
const timestamp = parts.length >= 8 ? parseTimestamp(parts[7]) : undefined;
const fields: RocketMqTraceField[] = [];
pushField(fields, "requestId", parts[1]);
pushField(fields, "msgId", parts[2]);
pushField(fields, "costTime", parts[3]);
pushField(fields, "keys", parts[5]);
pushField(fields, "contextCode", parts[6]);
if (parts.length >= 9) pushField(fields, "group", parts[8]);
return { type: "SubAfter", timestamp, success, fields, raw };
}
function parseEndTransaction(parts: string[], raw: string): RocketMqTraceRecord {
// EndTransaction: type, ts, region, group, topic, msgId, tags, keys, storeHost, msgType, clientHost, ...
const timestamp = parseTimestamp(parts[1]);
const fields: RocketMqTraceField[] = [];
pushField(fields, "regionId", parts[2]);
pushField(fields, "group", parts[3]);
pushField(fields, "topic", parts[4]);
pushField(fields, "msgId", parts[5]);
pushField(fields, "tags", parts[6]);
pushField(fields, "keys", parts[7]);
pushField(fields, "storeHost", parts[8]);
pushField(fields, "msgType", parts[9]);
if (parts.length >= 11) pushField(fields, "clientHost", parts[10]);
return { type: "EndTransaction", timestamp, fields, raw };
}
function parseTraceSegment(segment: string): RocketMqTraceRecord {
const raw = segment;
const normalized = normalizeTraceSegment(segment.trim());
if (!normalized) {
return { type: "Unknown", fields: [], raw };
}
const parts = normalized.split(CONTENT_SPLITTER);
const type = parts[0]?.trim();
if (type === "Pub" && parts.length >= 14) return parsePub(parts, raw);
if (type === "SubBefore" && parts.length >= 8) return parseSubBefore(parts, raw);
if (type === "SubAfter" && parts.length >= 7) return parseSubAfter(parts, raw);
if (type === "EndTransaction" && parts.length >= 9) return parseEndTransaction(parts, raw);
return { type: "Unknown", fields: [], raw };
}
/**
* Parse RocketMQ trace message body into structured records.
* Official format: Type\\u0001field...\\u0002Type\\u0001...
*/
export function parseRocketMqTracePayload(payload: string): RocketMqTraceRecord[] {
if (!payload?.trim()) return [];
const segments = payload
.split(FIELD_SPLITTER)
.map((part) => part.trim())
.filter(Boolean);
// Body may be a single record without trailing STX.
const units = segments.length ? segments : [payload.trim()];
return units.map(parseTraceSegment);
}
export function isParsedRocketMqTraceRecord(record: RocketMqTraceRecord): boolean {
return record.type !== "Unknown" && record.fields.length > 0;
}
/** Split KEYS header / keys field into display chips. */
export function splitRocketMqTraceKeys(value: string | undefined): string[] {
if (!value?.trim() || value.trim() === "-" || value.trim() === "null") return [];
return value
.split(/[\s,]+/)
.map((part) => part.trim())
.filter(Boolean);
}
export function rocketMqTraceRecordTimeLabel(record: RocketMqTraceRecord, fallbackTimestamp?: number): string {
return formatRocketMqTimestamp(record.timestamp ?? fallbackTimestamp);
}

View File

@ -192,6 +192,8 @@ export interface SubscriptionInfo {
consumerGroupType?: string;
/** RocketMQ consumer group message model: CLUSTERING / BROADCASTING. */
messageModel?: string;
/** When true, backlog probe failed — do not render msgBacklog as healthy zero. */
backlogUnavailable?: boolean;
}
export interface RocketMqConsumerGroupConfig {
@ -228,9 +230,25 @@ export type ResetPosition = { kind: "earliest" } | { kind: "latest" } | { kind:
export type SkipCount = { kind: "all" } | { kind: "count"; count: number };
/** Per-queue consume progress (RocketMQ Dashboard consume-detail / Kafka lag rows). */
export interface PartitionBacklog {
partition: number;
/** Consumer committed offset (`consumerOffset`). */
currentOffset: number;
/** Broker max offset (`brokerOffset`). */
endOffset: number;
lag: number;
brokerName?: string;
/** Last consume message store timestamp (ms). `0` means unavailable. */
lastTimestamp?: number;
consumerClient?: string;
}
export interface BacklogStats {
msgBacklog: number;
backlogSize: number;
/** Optional queue-level progress; omitted or empty when adapter only exposes totals. */
partitions?: PartitionBacklog[];
}
export interface ClusterInfo {

View File

@ -1292,6 +1292,12 @@ impl AppState {
if wait_for_drain {
self.wait_for_pool_drain(&pool_key).await;
}
#[cfg(feature = "mq-admin")]
let mq_keepalive_adapter = if matches!(&pool, PoolKind::MessageQueue) {
self.mq_registry.get_cached_adapter(&config.id).await
} else {
None
};
let routing = self.pool_routing_control();
let previous = loop {
let mut connections = self.connections.write().await;
@ -1309,7 +1315,13 @@ impl AppState {
// candidate never reaches this point, so the existing route keeps its state.
routing.stop_keepalive(&pool_key);
activity.insert(pool_key.clone(), PoolActivity::now());
self.start_keepalive_task(&pool_key, &pool, config);
self.start_keepalive_task(
&pool_key,
&pool,
config,
#[cfg(feature = "mq-admin")]
mq_keepalive_adapter.clone(),
);
break Ok(connections.insert(pool_key.clone(), pool));
};
let previous = match previous {
@ -1430,9 +1442,21 @@ impl AppState {
self.pool_routing_control().close_pool_with_timeout(pool_key, pool).await;
}
fn start_keepalive_task(&self, pool_key: &str, pool: &PoolKind, config: &ConnectionConfig) {
fn start_keepalive_task(
&self,
pool_key: &str,
pool: &PoolKind,
config: &ConnectionConfig,
#[cfg(feature = "mq-admin")] mq_adapter: Option<std::sync::Arc<dyn crate::mq::port::MessageQueueAdmin>>,
) {
let interval_secs = config.keepalive_interval_secs;
let mut target = keepalive_target_from_pool(pool, config);
#[cfg(feature = "mq-admin")]
if target.is_none() {
if let Some(adapter) = mq_adapter {
target = Some(KeepaliveTarget::MessageQueue(adapter));
}
}
if interval_secs == 0 {
return;
}
@ -1445,10 +1469,28 @@ impl AppState {
let key = pool_key.to_string();
let interval = Duration::from_secs(interval_secs.max(1));
// MQ keepalive runs a full adapter test_connection (agent RPC); use query timeout
// so slow clusters are not spuriously dropped by the shorter connect timeout.
#[cfg(feature = "mq-admin")]
let timeout = if matches!(target, Some(KeepaliveTarget::MessageQueue(_))) {
match config.effective_query_timeout_secs() {
0 => Duration::from_secs(300), // UI "unlimited" still needs a keepalive bound
secs => Duration::from_secs(secs.max(1)),
}
} else {
Duration::from_secs(config.effective_connect_timeout_secs().max(1))
};
#[cfg(not(feature = "mq-admin"))]
let timeout = Duration::from_secs(config.effective_connect_timeout_secs().max(1));
let routing = self.pool_routing_control();
let connections = self.connections.clone();
let running_queries = self.running_queries.clone();
// MQ pool markers are empty; close_pool_kind is a no-op, so keepalive must
// drop the registry adapter or reconnect would reuse a dead agent.
#[cfg(feature = "mq-admin")]
let mq_registry = self.mq_registry.clone();
#[cfg(feature = "mq-admin")]
let mq_connection_id = config.id.clone();
self.task_supervisor.spawn_replace(format!("keepalive:{pool_key}"), move |shutdown| async move {
loop {
tokio::select! {
@ -1468,6 +1510,17 @@ impl AppState {
log::warn!("Connection keepalive failed for '{key}': {err}; invalidating pool");
let replace_runtime =
err.recovery_decision().is_some_and(RecoveryDecision::replaces_runtime);
// PoolKind::MessageQueue is a unit marker, so matches_pool cannot
// Arc::ptr_eq. Identity-check the registry adapter before teardown.
#[cfg(feature = "mq-admin")]
if let KeepaliveTarget::MessageQueue(adapter) = target {
if !mq_registry.is_current_adapter(&mq_connection_id, adapter).await {
log::debug!("Skipping stale MQ keepalive result for replaced pool '{key}'");
break;
}
}
#[cfg(feature = "mq-admin")]
let drop_mq = matches!(target, KeepaliveTarget::MessageQueue(_));
if !detach_keepalive_target_if_current(
&routing,
&connections,
@ -1478,6 +1531,11 @@ impl AppState {
.await
{
log::debug!("Skipping stale keepalive result for replaced pool '{key}'");
} else {
#[cfg(feature = "mq-admin")]
if drop_mq {
mq_registry.drop_connection(&mq_connection_id).await;
}
}
break;
}
@ -1486,8 +1544,22 @@ impl AppState {
"Connection keepalive timed out for '{key}' after {}s; invalidating pool",
timeout.as_secs()
);
#[cfg(feature = "mq-admin")]
if let KeepaliveTarget::MessageQueue(adapter) = target {
if !mq_registry.is_current_adapter(&mq_connection_id, adapter).await {
log::debug!("Skipping stale MQ keepalive timeout for replaced pool '{key}'");
break;
}
}
#[cfg(feature = "mq-admin")]
let drop_mq = matches!(target, KeepaliveTarget::MessageQueue(_));
if !detach_keepalive_target_if_current(&routing, &connections, &key, target, false).await {
log::debug!("Skipping stale keepalive timeout for replaced pool '{key}'");
} else {
#[cfg(feature = "mq-admin")]
if drop_mq {
mq_registry.drop_connection(&mq_connection_id).await;
}
}
break;
}
@ -3369,7 +3441,13 @@ impl AppState {
}
};
if let (Some(config), Some(client)) = (config, client) {
self.start_keepalive_task(pool_key, &PoolKind::Agent(client), &config);
self.start_keepalive_task(
pool_key,
&PoolKind::Agent(client),
&config,
#[cfg(feature = "mq-admin")]
None,
);
}
}
@ -4083,7 +4161,10 @@ enum KeepaliveTarget {
Postgres(deadpool_postgres::Pool),
Rqlite(db::rqlite_driver::RqliteClient),
Turso(db::turso_driver::TursoClient),
MongoDb { client: mongodb::Client, database: Option<String> },
MongoDb {
client: mongodb::Client,
database: Option<String>,
},
ClickHouse(db::clickhouse_driver::ChClient),
SqlServer(Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
Elasticsearch(db::elasticsearch_driver::EsClient),
@ -4093,6 +4174,8 @@ enum KeepaliveTarget {
InfluxDb(db::influxdb_driver::InfluxdbClient),
VictoriaMetrics(db::victoriametrics_driver::VictoriaMetricsClient),
Agent(Arc<db::agent_driver::PooledAgentClient>),
#[cfg(feature = "mq-admin")]
MessageQueue(Arc<dyn crate::mq::port::MessageQueueAdmin>),
}
#[derive(Debug)]
@ -4130,6 +4213,10 @@ impl KeepaliveTarget {
match (self, pool) {
(Self::Agent(expected), PoolKind::Agent(current)) => Arc::ptr_eq(expected, current),
(Self::SqlServer(expected), PoolKind::SqlServer(current)) => Arc::ptr_eq(expected, current),
#[cfg(feature = "mq-admin")]
(Self::MessageQueue(_), PoolKind::MessageQueue) => true,
#[cfg(feature = "mq-admin")]
(Self::MessageQueue(_), _) | (_, PoolKind::MessageQueue) => false,
(Self::Agent(_), _) | (_, PoolKind::Agent(_)) | (Self::SqlServer(_), _) | (_, PoolKind::SqlServer(_)) => {
false
}
@ -4245,6 +4332,10 @@ async fn ping_keepalive_target(target: &mut KeepaliveTarget, timeout: Duration)
Err(error) => Err(KeepaliveError::Agent(error)),
}
}
#[cfg(feature = "mq-admin")]
KeepaliveTarget::MessageQueue(adapter) => {
adapter.test_connection().await.map(|_| ()).map_err(KeepaliveError::from)
}
}
}
@ -6553,7 +6644,13 @@ for line in sys.stdin:
.await
.insert(pool_key.to_string(), super::PoolActivity::idle_for(std::time::Duration::from_secs(10)));
let pool = super::clone_pool_kind(state.connections.read().await.get(pool_key).unwrap());
state.start_keepalive_task(pool_key, &pool, &config);
state.start_keepalive_task(
pool_key,
&pool,
&config,
#[cfg(feature = "mq-admin")]
None,
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;

View File

@ -63,12 +63,13 @@ impl KafkaAdmin {
let mut client = AgentDriverClient::spawn(launch).await?;
// Handshake
let _: serde_json::Value = client.call("handshake", serde_json::json!({})).await?;
let _: serde_json::Value =
client.call_with_timeout("handshake", serde_json::json!({}), cfg.rpc_timeout()).await?;
// Build the connection params from MqAdminConfig
let conn_params = build_connection_params(&cfg);
let connect_params = serde_json::json!({ "connection": conn_params });
let _: serde_json::Value = client.call("connect", connect_params).await?;
let _: serde_json::Value = client.call_with_timeout("connect", connect_params, cfg.rpc_timeout()).await?;
log::info!("Kafka admin connected via agent (bootstrap servers: {})", bootstrap_servers(&cfg));
@ -82,7 +83,7 @@ impl KafkaAdmin {
params: serde_json::Value,
) -> Result<T, String> {
let mut client = self.client.lock().await;
client.call(method, params).await
client.call_with_timeout(method, params, self.config.rpc_timeout()).await
}
/// The Kafka agent bounds message browsing with its configured request timeout.
@ -538,8 +539,7 @@ impl MessageQueueAdmin for KafkaAdmin {
)
.await?;
let total_lag = result.get("totalLag").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(BacklogStats { msg_backlog: total_lag, backlog_size: 0 })
Ok(backlog_stats_from_consumer_lag(&result))
}
async fn get_cluster_info(&self) -> Result<ClusterInfo, String> {
@ -655,6 +655,7 @@ fn build_connection_params(cfg: &MqAdminConfig) -> serde_json::Value {
"skip_verify": cfg.tls_skip_verify,
},
"properties": properties,
"request_timeout_ms": cfg.request_timeout_ms(),
})
}
@ -802,6 +803,8 @@ mod tests {
token_signing: None,
connect_override: None,
management_connect_override: None,
query_timeout_secs: crate::mq::config::DEFAULT_MQ_QUERY_TIMEOUT_SECS,
connect_timeout_secs: crate::mq::config::DEFAULT_MQ_CONNECT_TIMEOUT_SECS,
extra,
}
}

View File

@ -16,8 +16,6 @@ use crate::mq::port::MessageQueueAdmin;
use crate::mq::types::*;
use crate::mq::util::truncate;
/// How long to wait for a single admin REST call.
const REQUEST_TIMEOUT_SECS: u64 = 30;
/// How long to wait for the initial version-probe during construction.
const PROBE_TIMEOUT_SECS: u64 = 8;
const DETAIL_REQUEST_CONCURRENCY: usize = 8;
@ -39,7 +37,12 @@ impl PulsarAdmin {
/// the API profile. Probe failure degrades gracefully to the 3.1.x baseline.
pub async fn new(cfg: MqAdminConfig) -> Result<Self, String> {
let base = normalize_base(&cfg.admin_url);
let http = build_http_client(cfg.tls_skip_verify, cfg.connect_override.as_ref(), &cfg.admin_url)?;
// Unlimited query timeout still needs a finite HTTP client timeout; mirror
// request_timeout_ms (1h) so Advanced "0 = unlimited" is not silently clamped to 30s.
let request_timeout =
cfg.rpc_timeout().unwrap_or_else(|| std::time::Duration::from_millis(cfg.request_timeout_ms()));
let http =
build_http_client(cfg.tls_skip_verify, cfg.connect_override.as_ref(), &cfg.admin_url, request_timeout)?;
let auth = cfg.auth.clone();
let token_cache = TokenCache::default();
@ -807,9 +810,9 @@ fn build_http_client(
tls_skip_verify: bool,
connect_override: Option<&MqConnectOverride>,
admin_url: &str,
request_timeout: std::time::Duration,
) -> Result<reqwest::Client, String> {
let mut builder = crate::db::http_client_builder(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
.timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS));
let mut builder = crate::db::http_client_builder(request_timeout).timeout(request_timeout);
if tls_skip_verify {
builder = builder.danger_accept_invalid_certs(true);
}
@ -1074,7 +1077,7 @@ fn backlog_stats_from_topic_stats(profile: &PulsarApiProfile, stats: &TopicStats
None => subs.into_iter().fold(0_i64, |total, sub| total.saturating_add(sub.msg_backlog)),
};
BacklogStats { msg_backlog, backlog_size: stats.backlog_size }
BacklogStats { msg_backlog, backlog_size: stats.backlog_size, partitions: Vec::new() }
}
#[cfg(test)]
@ -1151,6 +1154,8 @@ mod tests {
token_signing: None,
connect_override: None,
management_connect_override: None,
query_timeout_secs: crate::mq::config::DEFAULT_MQ_QUERY_TIMEOUT_SECS,
connect_timeout_secs: crate::mq::config::DEFAULT_MQ_CONNECT_TIMEOUT_SECS,
extra: serde_json::Value::Null,
})
.await

View File

@ -64,12 +64,13 @@ impl RabbitMqAdmin {
let mut client = AgentDriverClient::spawn(launch).await?;
// Handshake
let _: serde_json::Value = client.call("handshake", serde_json::json!({})).await?;
let _: serde_json::Value =
client.call_with_timeout("handshake", serde_json::json!({}), cfg.rpc_timeout()).await?;
// Build the connection params from MqAdminConfig
let conn_params = build_connection_params(&cfg)?;
let connect_params = serde_json::json!({ "connection": conn_params });
let _: serde_json::Value = client.call("connect", connect_params).await?;
let _: serde_json::Value = client.call_with_timeout("connect", connect_params, cfg.rpc_timeout()).await?;
log::info!("RabbitMQ admin connected via agent (addresses: {})", addresses(&cfg));
@ -83,7 +84,7 @@ impl RabbitMqAdmin {
params: serde_json::Value,
) -> Result<T, String> {
let mut client = self.client.lock().await;
client.call(method, params).await
client.call_with_timeout(method, params, self.config.rpc_timeout()).await
}
/// Send a JSON-RPC call that returns `{ok: true}` on success.
@ -623,7 +624,7 @@ impl MessageQueueAdmin for RabbitMqAdmin {
let result: serde_json::Value = self.call("mq_get_topic_stats", params).await?;
let total_messages = result.get("totalMessages").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(BacklogStats { msg_backlog: total_messages, backlog_size: total_messages })
Ok(BacklogStats { msg_backlog: total_messages, backlog_size: total_messages, partitions: Vec::new() })
}
async fn get_cluster_info(&self) -> Result<ClusterInfo, String> {
@ -952,6 +953,7 @@ fn build_connection_params(cfg: &MqAdminConfig) -> Result<serde_json::Value, Str
"virtual_host": virtual_host,
"tls_skip_verify": cfg.tls_skip_verify,
"properties": properties,
"request_timeout_ms": cfg.request_timeout_ms(),
});
if let Some(username) = username.filter(|value| !value.trim().is_empty()) {
params["username"] = serde_json::json!(username);
@ -1027,6 +1029,8 @@ mod tests {
token_signing: None,
connect_override: None,
management_connect_override: None,
query_timeout_secs: crate::mq::config::DEFAULT_MQ_QUERY_TIMEOUT_SECS,
connect_timeout_secs: crate::mq::config::DEFAULT_MQ_CONNECT_TIMEOUT_SECS,
extra,
}
}

View File

@ -9,11 +9,15 @@
//! 3. Delegate all `MessageQueueAdmin` trait methods to JSON-RPC calls
use std::collections::HashMap;
use std::net::ToSocketAddrs;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use serde::de::DeserializeOwned;
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tokio::time::timeout;
use crate::db::agent_driver::{AgentDriverClient, AgentLaunchSpec};
use crate::mq::auth::MqAuth;
@ -94,16 +98,20 @@ fn cluster_info_from_agent_result(result: &serde_json::Value) -> MqClusterInfo {
impl RocketMqAdmin {
/// Spawn the RocketMQ Java agent, perform handshake, and connect.
///
/// Callers must probe NameServer reachability before invoking this so the
/// connect-timeout wall covers only JVM spawn + handshake + connect.
pub async fn new(cfg: MqAdminConfig, launch: AgentLaunchSpec) -> Result<Self, String> {
let mut client = AgentDriverClient::spawn(launch).await?;
// Handshake
let _: serde_json::Value = client.call("handshake", serde_json::json!({})).await?;
let _: serde_json::Value =
client.call_with_timeout("handshake", serde_json::json!({}), cfg.rpc_timeout()).await?;
// Build the connection params from MqAdminConfig
let conn_params = build_connection_params(&cfg);
let connect_params = serde_json::json!({ "connection": conn_params });
let _: serde_json::Value = client.call("connect", connect_params).await?;
let _: serde_json::Value = client.call_with_timeout("connect", connect_params, cfg.rpc_timeout()).await?;
log::info!("RocketMQ admin connected via agent (namesrv: {})", namesrv_addr(&cfg));
@ -117,7 +125,7 @@ impl RocketMqAdmin {
params: serde_json::Value,
) -> Result<T, String> {
let mut client = self.client.lock().await;
client.call(method, params).await
client.call_with_timeout(method, params, self.config.rpc_timeout()).await
}
/// Send a JSON-RPC call that returns `{ok: true}` on success.
@ -355,13 +363,14 @@ impl MessageQueueAdmin for RocketMqAdmin {
async fn list_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String> {
if topic.topic.is_empty() {
// Fast path: names/types only. UI enrichs online members/topics in a second pass.
let result: serde_json::Value = self
.call(
"mq_list_consumer_groups",
serde_json::json!({
"limit": 500,
"offset": 0,
"enrich": true,
"enrich": false,
}),
)
.await?;
@ -369,8 +378,7 @@ impl MessageQueueAdmin for RocketMqAdmin {
return Ok(groups.iter().map(rocketmq_subscription_from_group).collect());
}
// Agent uses queryTopicConsumeByWho (Dashboard queryTopicConsumerInfo); include all
// returned groups even when consumers are offline and offsets are zero.
// Topic path: skip list enrich; batch lag in one agent RPC (no N+1).
let result: serde_json::Value = self
.call(
"mq_list_consumer_groups",
@ -378,34 +386,30 @@ impl MessageQueueAdmin for RocketMqAdmin {
"topic": topic.topic,
"limit": 200,
"offset": 0,
"enrich": true,
"enrich": false,
"includeLag": true,
}),
)
.await?;
let groups = result.get("groups").and_then(|v| v.as_array()).cloned().unwrap_or_default();
Ok(groups.iter().map(rocketmq_subscription_from_group).collect())
}
let mut subs = Vec::new();
for group in groups {
let group_id = group.get("groupId").and_then(|v| v.as_str()).unwrap_or_default();
if group_id.is_empty() {
continue;
}
let mut sub = rocketmq_subscription_from_group(&group);
if let Ok(lag) = self
.call::<serde_json::Value>(
"mq_get_consumer_lag",
serde_json::json!({
"groupId": group_id,
"topic": topic.topic,
}),
)
.await
{
sub.msg_backlog = lag.get("totalLag").and_then(|v| v.as_i64()).unwrap_or(0);
}
subs.push(sub);
async fn enrich_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String> {
// Cluster-wide second pass fills memberCount/topics after the fast list paint.
let mut params = serde_json::json!({
"limit": 500,
"offset": 0,
"enrich": true,
});
if !topic.topic.is_empty() {
params["topic"] = serde_json::json!(topic.topic);
params["limit"] = serde_json::json!(200);
params["includeLag"] = serde_json::json!(true);
}
Ok(subs)
let result: serde_json::Value = self.call("mq_list_consumer_groups", params).await?;
let groups = result.get("groups").and_then(|v| v.as_array()).cloned().unwrap_or_default();
Ok(groups.iter().map(rocketmq_subscription_from_group).collect())
}
async fn create_subscription(&self, _topic: &TopicRef, _sub: &str, _pos: ResetPosition) -> Result<(), String> {
@ -673,8 +677,12 @@ impl MessageQueueAdmin for RocketMqAdmin {
)
.await?;
let total_lag = result.get("totalLag").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(BacklogStats { msg_backlog: total_lag, backlog_size: 0 })
// Agent omits totalLag on probe failure; never coerce that to healthy zero backlog.
if result.get("totalLag").and_then(|v| v.as_i64()).is_none() {
return Err(format!("RocketMQ consumer lag unavailable for group '{group_id}' on topic '{}'", topic.topic));
}
Ok(backlog_stats_from_consumer_lag(&result))
}
async fn get_cluster_info(&self) -> Result<ClusterInfo, String> {
@ -831,6 +839,7 @@ fn build_connection_params(cfg: &MqAdminConfig) -> serde_json::Value {
"access_key": access_key,
"secret_key": secret_key,
"tls_skip_verify": cfg.tls_skip_verify,
"request_timeout_ms": cfg.request_timeout_ms(),
})
}
@ -901,12 +910,14 @@ fn rocketmq_subscription_for_topic(
online_members: None,
consumer_group_type: None,
message_model: None,
backlog_unavailable: None,
})
}
fn rocketmq_subscription_from_group(group: &serde_json::Value) -> SubscriptionInfo {
let group_id = group.get("groupId").and_then(|v| v.as_str()).unwrap_or_default();
let group_type = group.get("groupType").and_then(|v| v.as_str()).unwrap_or("NORMAL").to_string();
// Match agent classify: missing dump → UNKNOWN, not silent NORMAL (FIFO hide risk).
let group_type = group.get("groupType").and_then(|v| v.as_str()).unwrap_or("UNKNOWN").to_string();
let message_model = group.get("messageModel").and_then(|v| v.as_str()).map(String::from);
let online_members = group.get("memberCount").and_then(|v| v.as_u64()).map(|v| v as u32);
let topics = group
@ -914,10 +925,13 @@ fn rocketmq_subscription_from_group(group: &serde_json::Value) -> SubscriptionIn
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>())
.unwrap_or_default();
let lag_failed = group.get("totalLagFailed").and_then(|v| v.as_bool()).unwrap_or(false);
let total_lag = group.get("totalLag").and_then(|v| v.as_i64());
SubscriptionInfo {
name: group_id.to_string(),
sub_type: group_type.clone(),
msg_backlog: 0,
// Probe failure: keep 0 but set backlog_unavailable so topic-list UI shows "-".
msg_backlog: total_lag.unwrap_or(0),
msg_rate_out: 0.0,
msg_throughput_out: 0.0,
consumers: Vec::new(),
@ -925,9 +939,92 @@ fn rocketmq_subscription_from_group(group: &serde_json::Value) -> SubscriptionIn
online_members,
consumer_group_type: Some(group_type),
message_model,
backlog_unavailable: if lag_failed || (group.get("totalLag").is_some() && total_lag.is_none()) {
Some(true)
} else {
None
},
}
}
/// Fail fast on unreachable NameServer before paying for JVM agent startup.
/// Called outside the connect-timeout wall so the probe does not steal JVM budget.
pub(crate) async fn probe_namesrv_before_connect(cfg: &MqAdminConfig, budget: Duration) -> Result<(), String> {
probe_namesrv_tcp(&namesrv_addr(cfg), budget).await
}
/// Probe NameServer addresses so connect fails before spawning the JVM agent.
/// Tries each `;`-separated host (and each resolved IP) within the shared budget,
/// matching RocketMQ client HA behavior instead of failing on the first dead node.
async fn probe_namesrv_tcp(namesrv: &str, budget: Duration) -> Result<(), String> {
let hosts: Vec<String> =
namesrv.split(';').map(str::trim).filter(|part| !part.is_empty()).map(str::to_string).collect();
if hosts.is_empty() {
return Err("RocketMQ namesrv_addr is empty".to_string());
}
let deadline = tokio::time::Instant::now() + budget;
// Multi-NS HA: cap each attempt so a blackholed first node leaves budget for later hosts.
let per_attempt = if hosts.len() <= 1 {
budget
} else {
(budget / hosts.len() as u32).clamp(Duration::from_millis(500), Duration::from_secs(3))
};
let mut last_error = String::new();
for host in &hosts {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
// DNS resolution is blocking; keep it off the async runtime.
let host_for_resolve = host.clone();
let addrs = match tokio::task::spawn_blocking(move || {
host_for_resolve
.to_socket_addrs()
.map(|iter| iter.collect::<Vec<_>>())
.map_err(|e| format!("RocketMQ NameServer address '{host_for_resolve}' is invalid: {e}"))
})
.await
{
Ok(Ok(addrs)) => addrs,
Ok(Err(e)) => {
last_error = e;
continue;
}
Err(e) => {
last_error = format!("RocketMQ NameServer resolve task failed: {e}");
continue;
}
};
if addrs.is_empty() {
last_error = format!("RocketMQ NameServer address '{host}' did not resolve");
continue;
}
for addr in addrs {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
let attempt = remaining.min(per_attempt);
match timeout(attempt, TcpStream::connect(addr)).await {
Ok(Ok(_stream)) => return Ok(()),
Ok(Err(e)) => {
last_error = format!("Cannot reach RocketMQ NameServer {host}: {e}");
}
Err(_) => {
last_error =
format!("RocketMQ NameServer {host} connect timed out after {}ms", attempt.as_millis());
}
}
}
}
Err(if last_error.is_empty() {
format!("RocketMQ NameServer connect timed out after {}s", budget.as_secs())
} else {
last_error
})
}
fn peeked_message_from_agent_json(idx: usize, message: &serde_json::Value) -> PeekedMessage {
let mut properties = HashMap::new();
if let Some(partition) = message.get("partition").and_then(|v| v.as_i64()) {
@ -975,10 +1072,18 @@ mod tests {
token_signing: None,
connect_override: None,
management_connect_override: None,
query_timeout_secs: crate::mq::config::DEFAULT_MQ_QUERY_TIMEOUT_SECS,
connect_timeout_secs: crate::mq::config::DEFAULT_MQ_CONNECT_TIMEOUT_SECS,
extra,
}
}
#[tokio::test]
async fn probe_namesrv_rejects_empty_address_list() {
let err = probe_namesrv_tcp(" ; ; ", Duration::from_millis(200)).await.expect_err("empty");
assert!(err.contains("empty"), "{err}");
}
#[test]
fn connection_params_map_namesrv_and_acl_credentials() {
let cfg = rocketmq_config(
@ -995,6 +1100,7 @@ mod tests {
assert_eq!(params.get("cluster_name").and_then(|v| v.as_str()), Some("DefaultCluster"));
assert_eq!(params.get("access_key").and_then(|v| v.as_str()), Some("rocket"));
assert_eq!(params.get("secret_key").and_then(|v| v.as_str()), Some("secret"));
assert_eq!(params.get("request_timeout_ms").and_then(|v| v.as_u64()), Some(30_000));
}
#[test]
@ -1031,6 +1137,26 @@ mod tests {
assert_eq!(sub.msg_backlog, 7);
}
#[test]
fn rocketmq_subscription_from_group_marks_failed_lag_unavailable() {
let group = serde_json::json!({
"groupId": "lag-fail",
"groupType": "NORMAL",
"totalLagFailed": true
});
let sub = rocketmq_subscription_from_group(&group);
assert_eq!(sub.msg_backlog, 0);
assert_eq!(sub.backlog_unavailable, Some(true));
}
#[test]
fn rocketmq_subscription_from_group_defaults_missing_type_to_unknown() {
let group = serde_json::json!({ "groupId": "no-type" });
let sub = rocketmq_subscription_from_group(&group);
assert_eq!(sub.sub_type, "UNKNOWN");
assert_eq!(sub.consumer_group_type.as_deref(), Some("UNKNOWN"));
}
#[test]
fn rocketmq_subscription_from_group_maps_offline_topic_consumer() {
let group = serde_json::json!({

View File

@ -4,12 +4,19 @@
//! `ConnectionConfig` rather than adding top-level fields, keeping the 50+
//! database-type connection model untouched.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::models::connection::ConnectionConfig;
use crate::mq::auth::MqAuth;
use crate::mq::types::{MqSystemKind, MqTokenSigningConfig};
/// Default query timeout when constructing test configs without a ConnectionConfig.
pub const DEFAULT_MQ_QUERY_TIMEOUT_SECS: u64 = 30;
/// Default connect timeout when constructing test configs without a ConnectionConfig.
pub const DEFAULT_MQ_CONNECT_TIMEOUT_SECS: u64 = 10;
/// Runtime TCP endpoint override for an MQ transport.
///
/// The logical broker endpoint remains unchanged so TLS hostname verification,
@ -53,6 +60,12 @@ pub struct MqAdminConfig {
/// its Management HTTP API listens on an independently configured port.
#[serde(skip)]
pub management_connect_override: Option<MqConnectOverride>,
/// Runtime-only: from `ConnectionConfig.query_timeout_secs` (`0` = unlimited).
#[serde(skip)]
pub query_timeout_secs: u64,
/// Runtime-only: from `ConnectionConfig.effective_connect_timeout_secs()`.
#[serde(skip)]
pub connect_timeout_secs: u64,
/// System-specific extension fields (e.g. Kafka bootstrap servers).
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub extra: serde_json::Value,
@ -76,9 +89,35 @@ impl MqAdminConfig {
{
return Err("Message queue admin URL is empty".to_string());
}
// Advanced connection timeouts live on ConnectionConfig, not external_config.
parsed.query_timeout_secs = cfg.effective_query_timeout_secs();
parsed.connect_timeout_secs = cfg.effective_connect_timeout_secs();
Ok(parsed)
}
/// Agent / HTTP RPC wall-clock timeout. `None` disables the client-side timeout.
pub fn rpc_timeout(&self) -> Option<Duration> {
if self.query_timeout_secs == 0 {
None
} else {
Some(Duration::from_secs(self.query_timeout_secs.max(1)))
}
}
/// Milliseconds for agent `request_timeout_ms`. Unlimited query timeout maps to a
/// large but finite admin request budget so Java/Go clients still make progress.
pub fn request_timeout_ms(&self) -> u64 {
match self.query_timeout_secs {
0 => 3_600_000,
secs => secs.saturating_mul(1000).max(1_000),
}
}
/// Budget for establishing the MQ adapter (agent spawn + handshake/connect).
pub fn connect_timeout(&self) -> Duration {
Duration::from_secs(self.connect_timeout_secs.max(1))
}
pub fn token_signing_configured(&self) -> bool {
self.token_signing.as_ref().is_some_and(MqTokenSigningConfig::is_configured)
}
@ -228,6 +267,25 @@ mod tests {
assert_eq!(mqc.system_kind, MqSystemKind::RocketMq);
assert_eq!(mqc.admin_url, "");
assert_eq!(mqc.extra.get("namesrvAddr").and_then(|v| v.as_str()), Some("127.0.0.1:9876"));
assert_eq!(mqc.query_timeout_secs, 30);
assert_eq!(mqc.connect_timeout_secs, 5);
assert_eq!(mqc.request_timeout_ms(), 30_000);
}
#[test]
fn copies_advanced_timeouts_from_connection_config() {
let mut cfg = connection_with_external(serde_json::json!({
"systemKind": "rocketmq",
"adminUrl": "",
"extra": { "namesrvAddr": "127.0.0.1:9876" }
}));
cfg.query_timeout_secs = 120;
cfg.connect_timeout_secs = 15;
let mqc = MqAdminConfig::from_connection(&cfg).expect("parse");
assert_eq!(mqc.query_timeout_secs, 120);
assert_eq!(mqc.connect_timeout_secs, 15);
assert_eq!(mqc.request_timeout_ms(), 120_000);
assert_eq!(mqc.rpc_timeout(), Some(std::time::Duration::from_secs(120)));
}
#[test]

View File

@ -45,10 +45,11 @@ pub use crate::mq::types::*;
/// Each connection has its own build-lock so concurrent first-use requests for
/// the same connection block until the first builder finishes, rather than both
/// racing to construct an adapter.
#[derive(Default)]
/// Clone shares the same cache (needed for keepalive tasks that must drop adapters).
#[derive(Clone, Default)]
pub struct MqAdminRegistry {
instances: RwLock<HashMap<String, CachedMqAdmin>>,
build_locks: RwLock<HashMap<String, Arc<Mutex<()>>>>,
instances: Arc<RwLock<HashMap<String, CachedMqAdmin>>>,
build_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
}
struct CachedMqAdmin {
@ -65,7 +66,7 @@ pub struct MqBuildResult {
impl MqAdminRegistry {
pub fn new() -> Self {
Self { instances: RwLock::new(HashMap::new()), build_locks: RwLock::new(HashMap::new()) }
Self { instances: Arc::new(RwLock::new(HashMap::new())), build_locks: Arc::new(RwLock::new(HashMap::new())) }
}
/// Return the cached adapter for this connection, building it from the
@ -108,7 +109,7 @@ impl MqAdminRegistry {
// Config changed — drop the stale adapter so its agent process is released.
self.instances.write().await.remove(connection_id);
let adapter = build_adapter(mqc, agent_launch).await?;
let adapter = build_adapter_with_connect_timeout(mqc, agent_launch).await?;
self.instances
.write()
.await
@ -116,6 +117,17 @@ impl MqAdminRegistry {
Ok(MqBuildResult { adapter, was_cached: false })
}
/// Cached adapter for keepalive / diagnostics. Returns `None` when not built yet.
pub async fn get_cached_adapter(&self, connection_id: &str) -> Option<Arc<dyn MessageQueueAdmin>> {
self.instances.read().await.get(connection_id).map(|entry| entry.adapter.clone())
}
/// Whether `adapter` is still the live registry entry for this connection (Arc identity).
/// Used so a stale keepalive cannot drop a replacement built after reconnect.
pub async fn is_current_adapter(&self, connection_id: &str, adapter: &Arc<dyn MessageQueueAdmin>) -> bool {
self.instances.read().await.get(connection_id).is_some_and(|entry| Arc::ptr_eq(&entry.adapter, adapter))
}
/// Drop the cached adapter for a connection (called on disconnect).
pub async fn drop_connection(&self, connection_id: &str) {
self.instances.write().await.remove(connection_id);
@ -144,7 +156,26 @@ impl MqAdminRegistry {
mqc: MqAdminConfig,
agent_launch: Option<AgentLaunchSpec>,
) -> Result<Arc<dyn MessageQueueAdmin>, String> {
build_adapter(mqc, agent_launch).await
build_adapter_with_connect_timeout(mqc, agent_launch).await
}
}
async fn build_adapter_with_connect_timeout(
mqc: MqAdminConfig,
agent_launch: Option<AgentLaunchSpec>,
) -> Result<Arc<dyn MessageQueueAdmin>, String> {
let budget = mqc.connect_timeout();
// RocketMQ: TCP-probe NameServer outside the connect wall so cold JVM spawn
// retains the full connect_timeout (probe used to steal up to half of it).
if mqc.system_kind == MqSystemKindInternal::RocketMq {
// Probe runs outside the connect wall; scale with connect_timeout so HA NameServer
// lists are not starved by a hard 2s cap, but keep an upper bound for snappy UX.
let probe_budget = (budget / 2).clamp(std::time::Duration::from_millis(500), std::time::Duration::from_secs(5));
crate::mq::adapters::rocketmq::probe_namesrv_before_connect(&mqc, probe_budget).await?;
}
match tokio::time::timeout(budget, build_adapter(mqc, agent_launch)).await {
Ok(result) => result,
Err(_) => Err(format!("Message queue connect timed out after {}s", budget.as_secs())),
}
}

View File

@ -149,6 +149,13 @@ pub trait MessageQueueAdmin: Send + Sync {
// ---- Subscriptions ----
async fn list_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String>;
/// Second-pass enrichment for subscription rows (e.g. RocketMQ online members/topics).
/// Default reuses [`list_subscriptions`]; RocketMQ overrides with `enrich: true`.
async fn enrich_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String> {
self.list_subscriptions(topic).await
}
async fn create_subscription(&self, topic: &TopicRef, sub: &str, pos: ResetPosition) -> Result<(), String>;
async fn delete_subscription(&self, topic: &TopicRef, sub: &str, force: bool) -> Result<(), String>;
async fn skip_messages(&self, topic: &TopicRef, sub: &str, count: SkipCount) -> Result<(), String>;

View File

@ -390,6 +390,15 @@ pub async fn mq_list_subscriptions_core(
adapter.list_subscriptions(&topic).await
}
pub async fn mq_enrich_subscriptions_core(
state: &AppState,
conn_id: &str,
topic: TopicRef,
) -> Result<Vec<SubscriptionInfo>, String> {
let adapter = get_adapter(state, conn_id).await?;
adapter.enrich_subscriptions(&topic).await
}
pub async fn mq_create_subscription_core(
state: &AppState,
conn_id: &str,
@ -842,6 +851,8 @@ async fn ensure_connection_writable(state: &AppState, conn_id: &str, operation:
config.name, operation
));
}
// Production protection for desktop MQ uses UI confirmation; MCP enforces
// is_production separately. Do not hard-block confirmed desktop writes here.
}
Ok(())
}
@ -939,6 +950,29 @@ mod tests {
(state, dir)
}
#[tokio::test]
async fn mutating_rocketmq_send_blocks_read_only_connections() {
let (state, _dir) = test_state_with(mq_connection(true)).await;
let err = mq_send_message_core(
&state,
"readonly-mq",
SendMessageRequest {
topic: "t".into(),
key: None,
payload_base64: "eA==".into(),
payload_text: Some("x".into()),
headers: Default::default(),
partition: None,
exchange: None,
routing_key: None,
namespace: None,
},
)
.await
.expect_err("read-only must block send");
assert!(err.contains("Read-only mode"), "{err}");
}
#[tokio::test]
async fn mutating_service_calls_block_read_only_connections_before_adapter_build() {
let (state, dir) = test_state_with(mq_connection(true)).await;

View File

@ -415,6 +415,9 @@ pub struct SubscriptionInfo {
/// RocketMQ: CLUSTERING / BROADCASTING.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_model: Option<String>,
/// When true, backlog probe failed — UI must not treat `msg_backlog` as healthy zero.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backlog_unavailable: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@ -465,11 +468,76 @@ pub enum SkipCount {
Count { count: u32 },
}
/// Per-queue/partition consume progress (RocketMQ Dashboard consume-detail / Kafka lag rows).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PartitionBacklog {
pub partition: i32,
/// Consumer committed offset (`consumerOffset` / `currentOffset`).
pub current_offset: i64,
/// Broker max offset (`brokerOffset` / `endOffset`).
pub end_offset: i64,
pub lag: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub broker_name: Option<String>,
/// Last consume message store timestamp (ms). `0` means unavailable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_timestamp: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub consumer_client: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BacklogStats {
pub msg_backlog: i64,
pub backlog_size: i64,
/// Optional queue-level progress; empty for adapters that only expose totals.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub partitions: Vec<PartitionBacklog>,
}
/// Parse agent `mq_get_consumer_lag` JSON (`totalLag` + `partitions[]`) into [`BacklogStats`].
pub fn backlog_stats_from_consumer_lag(value: &serde_json::Value) -> BacklogStats {
let msg_backlog = value.get("totalLag").and_then(|v| v.as_i64()).unwrap_or(0);
let partitions = value
.get("partitions")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|row| {
let partition = row.get("partition").and_then(|v| v.as_i64()).map(|v| v as i32)?;
let current_offset = row.get("currentOffset").and_then(|v| v.as_i64()).unwrap_or(0);
let end_offset = row.get("endOffset").and_then(|v| v.as_i64()).unwrap_or(0);
let lag =
row.get("lag").and_then(|v| v.as_i64()).unwrap_or_else(|| (end_offset - current_offset).max(0));
let broker_name = row
.get("brokerName")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let last_timestamp = row.get("lastTimestamp").and_then(|v| v.as_i64());
let consumer_client = row
.get("consumerClient")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
Some(PartitionBacklog {
partition,
current_offset,
end_offset,
lag,
broker_name,
last_timestamp,
consumer_client,
})
})
.collect()
})
.unwrap_or_default();
BacklogStats { msg_backlog, backlog_size: 0, partitions }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@ -1250,4 +1318,52 @@ mod tests {
assert!(!legacy.incomplete);
assert!(legacy.messages.is_empty());
}
#[test]
fn backlog_stats_from_consumer_lag_maps_dashboard_fields() {
let lag = serde_json::json!({
"totalLag": 10,
"partitions": [
{
"partition": 0,
"currentOffset": 90,
"endOffset": 100,
"lag": 10,
"brokerName": "broker-a",
"lastTimestamp": 1725000000000_i64,
"consumerClient": "172.18.2.212@7#1"
},
{
"partition": 1,
"currentOffset": 50,
"endOffset": 50,
"lag": 0,
"brokerName": "",
"lastTimestamp": 0,
"consumerClient": ""
}
]
});
let stats = super::backlog_stats_from_consumer_lag(&lag);
assert_eq!(stats.msg_backlog, 10);
assert_eq!(stats.partitions.len(), 2);
assert_eq!(stats.partitions[0].broker_name.as_deref(), Some("broker-a"));
assert_eq!(stats.partitions[0].consumer_client.as_deref(), Some("172.18.2.212@7#1"));
assert_eq!(stats.partitions[0].last_timestamp, Some(1725000000000));
// Empty strings are normalized to None so UI can show "-".
assert!(stats.partitions[1].broker_name.is_none());
assert!(stats.partitions[1].consumer_client.is_none());
assert_eq!(stats.partitions[1].last_timestamp, Some(0));
let json = serde_json::to_value(&stats).expect("serialize backlog stats");
assert_eq!(json.get("msgBacklog").and_then(|v| v.as_i64()), Some(10));
assert!(json.get("partitions").and_then(|v| v.as_array()).is_some());
// Legacy callers that only had totals still deserialize with empty partitions.
let legacy: super::BacklogStats =
serde_json::from_value(serde_json::json!({ "msgBacklog": 3, "backlogSize": 0 }))
.expect("legacy backlog stats");
assert_eq!(legacy.msg_backlog, 3);
assert!(legacy.partitions.is_empty());
}
}

View File

@ -134,6 +134,7 @@ fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
.route("/mq/messages/query-by-topic", post(routes::mq::query_messages_by_topic))
.route("/mq/messages/trace", post(routes::mq::query_message_trace))
.route("/mq/subscriptions/list", post(routes::mq::list_subscriptions))
.route("/mq/subscriptions/enrich", post(routes::mq::enrich_subscriptions))
.route("/mq/subscriptions/create", post(routes::mq::create_subscription))
.route("/mq/subscriptions/delete", post(routes::mq::delete_subscription))
.route("/mq/subscriptions/skip-messages", post(routes::mq::skip_messages))

View File

@ -751,6 +751,18 @@ pub async fn list_subscriptions(
Ok(Json(result))
}
pub async fn enrich_subscriptions(
State(state): State<Arc<WebState>>,
headers: HeaderMap,
Json(req): Json<TopicReq>,
) -> Result<Json<Vec<dbx_core::mq::SubscriptionInfo>>, AppError> {
super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?;
let result = dbx_core::mq::service::mq_enrich_subscriptions_core(&state.app, &req.connection_id, req.topic)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn create_subscription(
State(state): State<Arc<WebState>>,
headers: HeaderMap,

View File

@ -267,6 +267,15 @@ pub async fn mq_list_subscriptions(
dbx_core::mq::service::mq_list_subscriptions_core(&state, &connection_id, topic).await
}
#[tauri::command]
pub async fn mq_enrich_subscriptions(
state: State<'_, Arc<AppState>>,
connection_id: String,
topic: dbx_core::mq::TopicRef,
) -> Result<Vec<dbx_core::mq::SubscriptionInfo>, String> {
dbx_core::mq::service::mq_enrich_subscriptions_core(&state, &connection_id, topic).await
}
#[tauri::command]
pub async fn mq_create_subscription(
state: State<'_, Arc<AppState>>,

View File

@ -1873,6 +1873,8 @@ pub fn run() {
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_list_subscriptions,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_enrich_subscriptions,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_create_subscription,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_delete_subscription,