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:
authored and GitHub committed 2026-08-06 01:12:31 +08:00
1 parent 5c281b1653
commit 2f9ca313cb
61 files changed
+3400 -1099

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -2,14 +2,18 @@ package com.dbx.agent.rocketmq;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; 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.client.exception.MQClientException;
import org.apache.rocketmq.common.TopicConfig; import org.apache.rocketmq.common.TopicConfig;
import org.apache.rocketmq.common.MixAll; import org.apache.rocketmq.common.MixAll;
import org.apache.rocketmq.common.message.Message; import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig; 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.TopicStatsTable;
import org.apache.rocketmq.remoting.protocol.admin.TopicOffset; import org.apache.rocketmq.remoting.protocol.admin.TopicOffset;
import org.apache.rocketmq.remoting.protocol.body.ProducerInfo; import org.apache.rocketmq.remoting.protocol.body.ProducerInfo;
@@ -23,6 +27,7 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; 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("172.18.0.3:10911", conn));
assertEquals("127.0.0.1:10911", RocketMqAgent.remapBrokerAddrForClient("10.0.0.5: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)); 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 @Test
@@ -255,6 +275,8 @@ class RocketMqAgentTest {
SubscriptionGroupConfig normal = new SubscriptionGroupConfig(); SubscriptionGroupConfig normal = new SubscriptionGroupConfig();
normal.setConsumeMessageOrderly(false); normal.setConsumeMessageOrderly(false);
assertEquals("NORMAL", RocketMqAgent.classifyConsumerGroupType("MyGroup", normal)); 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 @Test
@@ -291,13 +313,94 @@ class RocketMqAgentTest {
assertTrue(maxActive.get() > 1); assertTrue(maxActive.get() > 1);
for (Map<String, Object> row : rows) { for (Map<String, Object> row : rows) {
// Successful empty connection probe → genuine offline 0.
assertEquals(0, row.get("memberCount")); assertEquals(0, row.get("memberCount"));
assertEquals(List.of(), row.get("topics")); assertEquals(List.of(), row.get("topics"));
} }
} }
@Test @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) List<Map<String, Object>> rows = IntStream.range(0, 4)
.mapToObj(index -> { .mapToObj(index -> {
Map<String, Object> row = new LinkedHashMap<>(); Map<String, Object> row = new LinkedHashMap<>();
@@ -316,7 +419,8 @@ class RocketMqAgentTest {
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
assertTrue(elapsedMs < 1_000, "enrichment exceeded its response budget: " + elapsedMs + "ms"); assertTrue(elapsedMs < 1_000, "enrichment exceeded its response budget: " + elapsedMs + "ms");
for (Map<String, Object> row : rows) { 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")); assertEquals(List.of(), row.get("topics"));
} }
} }
@@ -575,4 +679,230 @@ class RocketMqAgentTest {
assertTrue(RocketMqAgent.connectionMatches(base, same)); assertTrue(RocketMqAgent.connectionMatches(base, same));
assertFalse(RocketMqAgent.connectionMatches(base, differentNamesrv)); 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;
}
} }
@@ -2896,12 +2896,13 @@ const productionDatabaseSummary = computed(() => {
return t("production.databasesSelectedCount", { selected, total: productionDatabaseNames.value.length }); return t("production.databasesSelectedCount", { selected, total: productionDatabaseNames.value.length });
}); });
const productionScope = computed<ProductionScope>({ 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) => { 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) { function setProductionProtectionEnabled(enabled: boolean) {
productionProtectionEnabled.value = enabled; productionProtectionEnabled.value = enabled;
@@ -3415,8 +3416,8 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
} }
if (!config.one_time) config.one_time = undefined; if (!config.one_time) config.one_time = undefined;
if (!config.read_only) config.read_only = undefined; if (!config.read_only) config.read_only = undefined;
if (isSingleDatabase(config.db_type) && config.production_databases?.length) { if ((isSingleDatabase(config.db_type) || config.db_type === "mq" || config.db_type === "mqtt") && config.production_databases?.length) {
// Single-database drivers expose schemas or internal names, not independently selectable databases. // Single-database / MQ drivers expose no independently selectable database list for PROD scope.
config.is_production = true; config.is_production = true;
config.production_databases = []; config.production_databases = [];
} }
+2 -34
View File
@@ -205,6 +205,8 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.broker-panel { .broker-panel {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -253,26 +255,6 @@ onUnmounted(() => {
cursor: not-allowed; 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-error,
.panel-loading, .panel-loading,
.empty-state { .empty-state {
@@ -408,20 +390,6 @@ onUnmounted(() => {
color: var(--color-text-secondary); 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 { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -6,6 +6,7 @@ import { mqBind, mqCreateExchange, mqDeleteExchange, mqListBindings, mqListExcha
import { formatError } from "@/lib/backend/errorUtils"; import { formatError } from "@/lib/backend/errorUtils";
import { isBuiltinRabbitMqExchange, rabbitMqExchangeDisplayName, RABBITMQ_EXCHANGE_TYPES } from "@/lib/mq/rabbitmqExchanges"; import { isBuiltinRabbitMqExchange, rabbitMqExchangeDisplayName, RABBITMQ_EXCHANGE_TYPES } from "@/lib/mq/rabbitmqExchanges";
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults"; import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props { interface Props {
@@ -17,6 +18,7 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const exchanges = ref<MqExchangeInfo[]>([]); const exchanges = ref<MqExchangeInfo[]>([]);
const loading = ref(false); const loading = ref(false);
@@ -79,12 +81,12 @@ function nsRefFor(row: { namespace?: string } | undefined): NamespaceRef | null
return { tenant: props.tenant, namespace }; return { tenant: props.tenant, namespace };
} }
function guardWritable() { async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) { if (props.readOnly) {
error.value = t("mqExchanges.readOnly"); error.value = t("mqExchanges.readOnly");
return false; return false;
} }
return true; return confirmMqWrite(operation);
} }
async function loadExchanges() { async function loadExchanges() {
@@ -143,14 +145,17 @@ function selectExchange(exchange: MqExchangeInfo) {
} }
function openCreateDialog() { function openCreateDialog() {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqExchanges.readOnly");
return;
}
dialogError.value = undefined; dialogError.value = undefined;
createForm.value = { name: "", type: "direct", durable: true, autoDelete: false }; createForm.value = { name: "", type: "direct", durable: true, autoDelete: false };
showCreateDialog.value = true; showCreateDialog.value = true;
} }
async function handleCreate() { async function handleCreate() {
if (!guardWritable()) return; if (!(await guardWritable(t("mqExchanges.create")))) return;
const ns = nsRef(); const ns = nsRef();
if (!createForm.value.name.trim() || !ns) { if (!createForm.value.name.trim() || !ns) {
dialogError.value = t("mqExchanges.nameRequired"); dialogError.value = t("mqExchanges.nameRequired");
@@ -176,13 +181,17 @@ async function handleCreate() {
} }
function openDeleteDialog(exchange: MqExchangeInfo) { function openDeleteDialog(exchange: MqExchangeInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqExchanges.readOnly");
return;
}
if (isBuiltinRabbitMqExchange(exchange)) return; if (isBuiltinRabbitMqExchange(exchange)) return;
deleteTarget.value = exchange; deleteTarget.value = exchange;
showDeleteDialog.value = true; showDeleteDialog.value = true;
} }
async function confirmDelete() { async function confirmDelete() {
if (!(await guardWritable(t("mqExchanges.delete")))) return;
const target = deleteTarget.value; const target = deleteTarget.value;
if (!target) return; if (!target) return;
const ns = nsRefFor(target); const ns = nsRefFor(target);
@@ -208,7 +217,10 @@ async function confirmDelete() {
} }
function openBindDialog() { function openBindDialog() {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqExchanges.readOnly");
return;
}
if (!selectedExchange.value) return; if (!selectedExchange.value) return;
dialogError.value = undefined; dialogError.value = undefined;
bindForm.value = { destinationType: "queue", destination: "", routingKey: "", argumentsText: "" }; bindForm.value = { destinationType: "queue", destination: "", routingKey: "", argumentsText: "" };
@@ -227,7 +239,7 @@ function parseBindingArguments(): Record<string, unknown> | undefined {
} }
async function handleBind() { async function handleBind() {
if (!guardWritable()) return; if (!(await guardWritable(t("mqExchanges.bind")))) return;
const exchange = selectedExchange.value; const exchange = selectedExchange.value;
if (!exchange) return; if (!exchange) return;
const ns = nsRefFor(exchange); const ns = nsRefFor(exchange);
@@ -260,12 +272,16 @@ async function handleBind() {
} }
function openUnbindDialog(bindingRow: MqBindingInfo) { function openUnbindDialog(bindingRow: MqBindingInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqExchanges.readOnly");
return;
}
unbindTarget.value = bindingRow; unbindTarget.value = bindingRow;
showUnbindDialog.value = true; showUnbindDialog.value = true;
} }
async function confirmUnbind() { async function confirmUnbind() {
if (!(await guardWritable(t("mqExchanges.unbind")))) return;
const target = unbindTarget.value; const target = unbindTarget.value;
if (!target) return; if (!target) return;
const ns = nsRefFor(target ?? selectedExchange.value); const ns = nsRefFor(target ?? selectedExchange.value);
@@ -522,6 +538,8 @@ watch(
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.exchanges-panel { .exchanges-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -700,45 +718,6 @@ td {
font-weight: 600; 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 { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -778,16 +757,6 @@ button:disabled {
font-size: 18px; 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 { .dialog-body {
padding: 20px; padding: 20px;
max-height: 60vh; max-height: 60vh;
@@ -225,6 +225,8 @@ watch(kafkaStartPosition, () => {
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.message-browser { .message-browser {
margin-top: 4px; margin-top: 4px;
padding: 14px; padding: 14px;
@@ -257,20 +259,6 @@ watch(kafkaStartPosition, () => {
font-weight: 600; 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, .btn-sm:disabled,
.peek-controls input:disabled, .peek-controls input:disabled,
.peek-controls :deep(.message-browser-start-position[data-disabled]) { .peek-controls :deep(.message-browser-start-position[data-disabled]) {
@@ -7,6 +7,7 @@ import { formatRocketMqMessagePayload, formatRocketMqTimestamp, parseRocketMqMes
import { formatError } from "@/lib/backend/errorUtils"; import { formatError } from "@/lib/backend/errorUtils";
import { copyToClipboard } from "@/lib/common/clipboard"; import { copyToClipboard } from "@/lib/common/clipboard";
import { buildRocketMqTraceTopicOptions, DEFAULT_ROCKETMQ_TRACE_TOPIC, resolveRocketMqMessageType } from "@/lib/mq/rocketmqTopicTypes"; import { buildRocketMqTraceTopicOptions, DEFAULT_ROCKETMQ_TRACE_TOPIC, resolveRocketMqMessageType } from "@/lib/mq/rocketmqTopicTypes";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import RocketMqTraceDetailDialog from "./RocketMqTraceDetailDialog.vue"; import RocketMqTraceDetailDialog from "./RocketMqTraceDetailDialog.vue";
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue"; import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
import RocketMqMessageDetailDialog from "./shared/RocketMqMessageDetailDialog.vue"; import RocketMqMessageDetailDialog from "./shared/RocketMqMessageDetailDialog.vue";
@@ -25,6 +26,7 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const topicSelectRef = ref<InstanceType<typeof RocketMqTopicSelect>>(); const topicSelectRef = ref<InstanceType<typeof RocketMqTopicSelect>>();
const availableTopics = ref<TopicInfo[]>([]); const availableTopics = ref<TopicInfo[]>([]);
@@ -258,6 +260,7 @@ async function resendMessage() {
resendError.value = t("mqMessages.readOnlyCannotSend"); resendError.value = t("mqMessages.readOnlyCannotSend");
return; return;
} }
if (!(await confirmMqWrite(t("mqMessages.resendMessage")))) return;
const topic = resendTopic.value; const topic = resendTopic.value;
const payloadText = detailPayload.value.trim(); const payloadText = detailPayload.value.trim();
if (!topic) { if (!topic) {
@@ -493,6 +496,8 @@ watch(topicName, () => {
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.message-query-panel { .message-query-panel {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -587,32 +592,6 @@ watch(topicName, () => {
min-width: 0; 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 { .spin {
display: inline-block; display: inline-block;
animation: spin-anim 0.8s linear infinite; animation: spin-anim 0.8s linear infinite;
@@ -711,39 +690,12 @@ watch(topicName, () => {
word-break: break-all; 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 { .operation-cell {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; 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 { .dialog-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -791,15 +743,6 @@ watch(topicName, () => {
border-top: 1px solid var(--color-border); 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 { .dialog-body {
padding: 16px 20px; padding: 16px 20px;
display: flex; display: flex;
@@ -843,16 +786,6 @@ watch(topicName, () => {
font-size: 13px; 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 { .detail-payload {
margin: 0; margin: 0;
padding: 12px; padding: 12px;
@@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
import type { MqAdminConfig, MqClusterInfo, MqSystemKind, NamespaceRef, TopicInfo } from "@/types/mq"; import type { MqAdminConfig, MqClusterInfo, MqSystemKind, NamespaceRef, TopicInfo } from "@/types/mq";
import { mqCreateNamespace, mqListNamespaces, mqTestConnection } from "@/lib/backend/api"; import { mqCreateNamespace, mqListNamespaces, mqTestConnection } from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore"; import { useConnectionStore } from "@/stores/connectionStore";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import { mqClusterOptionsFromExtra } from "@/lib/mq/mqTenantForm"; import { mqClusterOptionsFromExtra } from "@/lib/mq/mqTenantForm";
import { import {
defaultMqCapabilitiesForSystemKind, defaultMqCapabilitiesForSystemKind,
@@ -50,6 +51,8 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const { t } = useI18n(); const { t } = useI18n();
const connectionStore = useConnectionStore(); 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 configuredSystemKind = computed(() => resolveMqSystemKindFromConnection(connectionStore.getConfig(props.connectionId)));
const FLAT_MQ_CONTEXT = "_flat_mq"; const FLAT_MQ_CONTEXT = "_flat_mq";
@@ -225,6 +228,7 @@ function handleNamespaceSelect(value: AcceptableValue) {
async function handleCreateNamespace() { async function handleCreateNamespace() {
if (props.readOnly) return; if (props.readOnly) return;
if (!(await confirmMqWrite(t("mqNamespaces.create")))) return;
const name = createNamespaceName.value.trim(); const name = createNamespaceName.value.trim();
if (!name) { if (!name) {
createNamespaceError.value = t("mqNamespaces.namespaceNameRequired"); 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> <button v-if="selectedTopic" class="breadcrumb-button" @click="goToTopicLevel" :title="t('mqAdmin.viewTopic')">{{ selectedTopic.shortName }}</button>
</div> </div>
<div class="toolbar-status"> <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="readOnly" class="readonly-badge">{{ t("mqAdmin.readOnly") }}</span>
<span v-if="error" class="toolbar-error">{{ error }}</span> <span v-if="error" class="toolbar-error">{{ error }}</span>
</div> </div>
@@ -560,6 +565,8 @@ onMounted(async () => {
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.mq-admin-console { .mq-admin-console {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -635,6 +642,15 @@ onMounted(async () => {
font-weight: 500; 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 { .mq-tabs {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -735,16 +751,6 @@ onMounted(async () => {
font-size: 18px; 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 { .dialog-body {
padding: 20px; padding: 20px;
} }
@@ -784,28 +790,6 @@ onMounted(async () => {
font-size: 13px; 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 { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -4,6 +4,7 @@ import { ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import type { NamespaceRef, NamespaceInfo, NamespaceConfig } from "@/types/mq"; import type { NamespaceRef, NamespaceInfo, NamespaceConfig } from "@/types/mq";
import { mqListNamespaces, mqCreateNamespace, mqDeleteNamespace } from "@/lib/backend/api"; import { mqListNamespaces, mqCreateNamespace, mqDeleteNamespace } from "@/lib/backend/api";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props { interface Props {
@@ -23,6 +24,7 @@ const emit = defineEmits<{
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const namespaces = ref<NamespaceInfo[]>([]); const namespaces = ref<NamespaceInfo[]>([]);
const loading = ref(false); const loading = ref(false);
@@ -37,12 +39,12 @@ const formData = ref({
namespace: "", namespace: "",
}); });
function guardWritable() { async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) { if (props.readOnly) {
error.value = t("mqNamespaces.readOnly"); error.value = t("mqNamespaces.readOnly");
return false; return false;
} }
return true; return confirmMqWrite(operation);
} }
async function loadNamespaces() { async function loadNamespaces() {
@@ -62,7 +64,10 @@ async function loadNamespaces() {
} }
function openCreateDialog() { function openCreateDialog() {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqNamespaces.readOnly");
return;
}
formData.value = { formData.value = {
namespace: "", namespace: "",
}; };
@@ -70,7 +75,7 @@ function openCreateDialog() {
} }
async function handleCreate() { async function handleCreate() {
if (!guardWritable()) return; if (!(await guardWritable(t("mqNamespaces.create")))) return;
if (!formData.value.namespace.trim() || !props.tenant) { if (!formData.value.namespace.trim() || !props.tenant) {
error.value = t("mqNamespaces.namespaceNameRequired"); error.value = t("mqNamespaces.namespaceNameRequired");
return; return;
@@ -94,12 +99,16 @@ async function handleCreate() {
} }
function handleDelete(ns: NamespaceInfo) { function handleDelete(ns: NamespaceInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqNamespaces.readOnly");
return;
}
deleteTarget.value = ns; deleteTarget.value = ns;
showDeleteDialog.value = true; showDeleteDialog.value = true;
} }
async function confirmDelete() { async function confirmDelete() {
if (!(await guardWritable(t("mqNamespaces.delete")))) return;
const ns = deleteTarget.value; const ns = deleteTarget.value;
if (!ns || !props.tenant) return; if (!ns || !props.tenant) return;
deleting.value = true; deleting.value = true;
@@ -217,6 +226,8 @@ watch(
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.namespaces-panel { .namespaces-panel {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -307,45 +318,6 @@ td {
gap: 8px; 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 { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -414,16 +386,6 @@ button:disabled {
font-size: 18px; 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 { .dialog-body {
padding: 20px; padding: 20px;
max-height: 60vh; max-height: 60vh;
@@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
import type { AuthAction, MqIssuedToken, MqSystemKind, MqTokenRecord, PermissionMap, PolicyScope, TopicInfo } from "@/types/mq"; import type { AuthAction, MqIssuedToken, MqSystemKind, MqTokenRecord, PermissionMap, PolicyScope, TopicInfo } from "@/types/mq";
import { mqGrantPermission, mqIssueToken, mqListPermissions, mqListTokenRecords, mqRevokePermission } from "@/lib/backend/api"; import { mqGrantPermission, mqIssueToken, mqListPermissions, mqListTokenRecords, mqRevokePermission } from "@/lib/backend/api";
import { formatMqTokenIssueError, type MqTokenIssueErrorView } from "@/lib/mq/mqTokenErrors"; import { formatMqTokenIssueError, type MqTokenIssueErrorView } from "@/lib/mq/mqTokenErrors";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
interface Props { interface Props {
connectionId: string; connectionId: string;
@@ -17,6 +18,7 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const actionOptions: AuthAction[] = ["produce", "consume", "functions", "sources", "sinks", "packages"]; 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 })); return Object.entries(permissions.value).map(([role, actions]) => ({ role, actions }));
}); });
function guardWritable() { async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) { if (props.readOnly) {
error.value = readOnlyMessage.value; error.value = readOnlyMessage.value;
notice.value = undefined; notice.value = undefined;
return false; return false;
} }
return true; return confirmMqWrite(operation);
} }
function formatActionLabel(action: AuthAction): string { function formatActionLabel(action: AuthAction): string {
@@ -120,7 +122,7 @@ async function loadPermissions() {
} }
async function grantPermission() { async function grantPermission() {
if (!guardWritable()) return; if (!(await guardWritable(t("mqPermissions.grant")))) return;
const current = scope.value; const current = scope.value;
const role = roleName.value.trim(); const role = roleName.value.trim();
roleNameError.value = ""; roleNameError.value = "";
@@ -163,7 +165,7 @@ async function grantPermission() {
} }
async function revokePermission(role: string) { async function revokePermission(role: string) {
if (!guardWritable()) return; if (!(await guardWritable(t("mqPermissions.revoke")))) return;
const current = scope.value; const current = scope.value;
if (!current) { if (!current) {
error.value = t("mqPermissions.selectNamespaceOrTopic"); error.value = t("mqPermissions.selectNamespaceOrTopic");
@@ -228,6 +230,10 @@ async function issueToken() {
tokenIssueError.value = undefined; tokenIssueError.value = undefined;
return; return;
} }
if (!(await confirmMqWrite(t("mqPermissions.generateToken")))) {
tokenIssueError.value = undefined;
return;
}
const current = scope.value; const current = scope.value;
if (!current) { if (!current) {
tokenError.value = t("mqPermissions.selectNamespaceOrTopic"); tokenError.value = t("mqPermissions.selectNamespaceOrTopic");
@@ -475,6 +481,8 @@ watch(
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.permissions-panel { .permissions-panel {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -677,30 +685,6 @@ th {
color: var(--color-warning); 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 { .dialog-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -736,15 +720,6 @@ th {
font-weight: 600; font-weight: 600;
} }
.btn-close {
border: none;
background: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 22px;
line-height: 1;
}
.dialog-body { .dialog-body {
padding: 18px 20px; padding: 18px 20px;
overflow: auto; overflow: auto;
@@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
import type { BacklogQuota, DispatchRate, PolicyScope, PublishRate, RetentionPolicy, SubscribeRate, TopicInfo } from "@/types/mq"; import type { BacklogQuota, DispatchRate, PolicyScope, PublishRate, RetentionPolicy, SubscribeRate, TopicInfo } from "@/types/mq";
import { mqGetEffectivePolicies, mqSetBacklogQuota, mqSetDispatchRate, mqSetPublishRate, mqSetRetention, mqSetSubscribeRate } from "@/lib/backend/api"; import { mqGetEffectivePolicies, mqSetBacklogQuota, mqSetDispatchRate, mqSetPublishRate, mqSetRetention, mqSetSubscribeRate } from "@/lib/backend/api";
import { defaultMqPolicyForms, policyFormsFromEffectivePolicies } from "@/lib/mq/mqPolicyForms"; import { defaultMqPolicyForms, policyFormsFromEffectivePolicies } from "@/lib/mq/mqPolicyForms";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
interface Props { interface Props {
connectionId: string; connectionId: string;
@@ -20,6 +21,7 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const policies = ref<unknown>(); const policies = ref<unknown>();
const loading = ref(false); const loading = ref(false);
@@ -70,13 +72,13 @@ const scopeLabel = computed(() => {
const formattedPolicies = computed(() => JSON.stringify(policies.value ?? {}, null, 2)); const formattedPolicies = computed(() => JSON.stringify(policies.value ?? {}, null, 2));
function guardWritable() { async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) { if (props.readOnly) {
error.value = readOnlyMessage.value; error.value = readOnlyMessage.value;
notice.value = undefined; notice.value = undefined;
return false; return false;
} }
return true; return confirmMqWrite(operation);
} }
async function loadPolicies() { async function loadPolicies() {
@@ -100,7 +102,7 @@ async function loadPolicies() {
} }
async function applyPolicy(kind: string, action: (current: PolicyScope) => Promise<void>) { async function applyPolicy(kind: string, action: (current: PolicyScope) => Promise<void>) {
if (!guardWritable()) return; if (!(await guardWritable(kind))) return;
const current = scope.value; const current = scope.value;
if (!current) { if (!current) {
error.value = scopePlaceholderMessage.value; error.value = scopePlaceholderMessage.value;
@@ -279,6 +281,8 @@ watch(
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.policies-panel { .policies-panel {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -404,23 +408,6 @@ pre {
color: var(--color-warning); 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 { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -5,6 +5,7 @@ import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import type { ConsumerInfo, ProducerInfo, SubscriptionInfo, TopicInfo, TopicRef, TopicStats, MqSystemKind } from "@/types/mq"; import type { ConsumerInfo, ProducerInfo, SubscriptionInfo, TopicInfo, TopicRef, TopicStats, MqSystemKind } from "@/types/mq";
import { mqGetTopicStats, mqListConsumers, mqListProducers, mqListSubscriptions, mqUnloadTopic } from "@/lib/backend/api"; import { mqGetTopicStats, mqListConsumers, mqListProducers, mqListSubscriptions, mqUnloadTopic } from "@/lib/backend/api";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue"; import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
interface Props { interface Props {
@@ -26,6 +27,7 @@ const emit = defineEmits<{
topicSelected: [topic: TopicInfo | undefined]; topicSelected: [topic: TopicInfo | undefined];
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
interface PartitionClientRow { interface PartitionClientRow {
name: string; name: string;
@@ -273,6 +275,7 @@ async function loadRuntimeClients() {
async function unloadTopic() { async function unloadTopic() {
const current = topicRef.value; const current = topicRef.value;
if (!current || props.readOnly || unloading.value) return; if (!current || props.readOnly || unloading.value) return;
if (!(await confirmMqWrite(t("mqClients.unloadTopic")))) return;
if (!confirm(t("mqClients.confirmUnload"))) return; if (!confirm(t("mqClients.confirmUnload"))) return;
unloading.value = true; unloading.value = true;
@@ -762,6 +765,8 @@ watch(
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.producer-consumer-panel { .producer-consumer-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -828,44 +833,6 @@ watch(
gap: 8px; 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 { .runtime-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
+8 -25
View File
@@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
import type { MqRawResponse, TopicInfo } from "@/types/mq"; import type { MqRawResponse, TopicInfo } from "@/types/mq";
import { mqRawRequest } from "@/lib/backend/api"; import { mqRawRequest } from "@/lib/backend/api";
import { safeJsonFormat } from "@/lib/common/safeJsonFormat"; import { safeJsonFormat } from "@/lib/common/safeJsonFormat";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
interface Props { interface Props {
connectionId: string; connectionId: string;
@@ -16,6 +17,7 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const methods = ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"]; const methods = ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"];
type RawApiPreset = { type RawApiPreset = {
@@ -184,6 +186,10 @@ async function executeRequest() {
error.value = readOnlyMessage.value; error.value = readOnlyMessage.value;
return; 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()) { if (!path.value.trim()) {
error.value = t("mqRaw.pathRequired"); error.value = t("mqRaw.pathRequired");
return; return;
@@ -277,6 +283,8 @@ async function executeRequest() {
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.raw-api-panel { .raw-api-panel {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -514,31 +522,6 @@ pre {
color: var(--color-warning); 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 { .btn-secondary.compact {
padding: 3px 8px; padding: 3px 8px;
} }
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { mqQueryMessageTrace } from "@/lib/backend/api"; import { mqQueryMessageTrace } from "@/lib/backend/api";
import { formatRocketMqTraceError } from "@/lib/mq/rocketmqTraceUtils"; import { formatRocketMqTraceError, isParsedRocketMqTraceRecord, parseRocketMqTracePayload, rocketMqTraceRecordTimeLabel, splitRocketMqTraceKeys, type RocketMqTraceFieldKey, type RocketMqTraceRecord, type RocketMqTraceType } from "@/lib/mq/rocketmqTraceUtils";
import { formatRocketMqTimestamp, parseRocketMqMessagesFromResult, rocketMqMessagePayload, type RocketMqDisplayMessage } from "@/lib/mq/rocketmqMessageUtils"; import { parseRocketMqMessagesFromResult, rocketMqMessagePayload, type RocketMqDisplayMessage } from "@/lib/mq/rocketmqMessageUtils";
import { DEFAULT_ROCKETMQ_TRACE_TOPIC } from "@/lib/mq/rocketmqTopicTypes"; import { DEFAULT_ROCKETMQ_TRACE_TOPIC } from "@/lib/mq/rocketmqTopicTypes";
interface Props { interface Props {
@@ -13,6 +13,15 @@ interface Props {
traceTopic?: string; traceTopic?: string;
} }
interface TraceCardModel {
index: number;
message: RocketMqDisplayMessage;
record: RocketMqTraceRecord;
parsed: boolean;
rawPayload: string;
headerEntries: Array<[string, string]>;
}
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
traceTopic: DEFAULT_ROCKETMQ_TRACE_TOPIC, traceTopic: DEFAULT_ROCKETMQ_TRACE_TOPIC,
}); });
@@ -26,6 +35,71 @@ const { t } = useI18n();
const loading = ref(false); const loading = ref(false);
const error = ref<string>(); const error = ref<string>();
const messages = ref<RocketMqDisplayMessage[]>([]); 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() { async function loadTrace() {
const msgId = props.msgId.trim(); const msgId = props.msgId.trim();
@@ -35,17 +109,20 @@ async function loadTrace() {
return; return;
} }
const seq = ++loadSeq;
loading.value = true; loading.value = true;
error.value = undefined; error.value = undefined;
messages.value = []; messages.value = [];
try { try {
const traceTopic = props.traceTopic.trim() || DEFAULT_ROCKETMQ_TRACE_TOPIC; const traceTopic = props.traceTopic.trim() || DEFAULT_ROCKETMQ_TRACE_TOPIC;
const result = await mqQueryMessageTrace(props.connectionId, msgId, traceTopic); const result = await mqQueryMessageTrace(props.connectionId, msgId, traceTopic);
if (seq !== loadSeq) return;
messages.value = parseRocketMqMessagesFromResult(result); messages.value = parseRocketMqMessagesFromResult(result);
} catch (e: unknown) { } catch (e: unknown) {
if (seq !== loadSeq) return;
error.value = formatRocketMqTraceError(e, t("mqTrace.traceTopicRouteMissing")); error.value = formatRocketMqTraceError(e, t("mqTrace.traceTopicRouteMissing"));
} finally { } finally {
loading.value = false; if (seq === loadSeq) loading.value = false;
} }
} }
@@ -55,6 +132,7 @@ watch(
if (open) { if (open) {
void loadTrace(); void loadTrace();
} else { } else {
loadSeq += 1;
loading.value = false; loading.value = false;
error.value = undefined; error.value = undefined;
messages.value = []; messages.value = [];
@@ -80,19 +158,49 @@ watch(
<div v-if="loading" class="panel-placeholder">{{ t("mqTrace.querying") }}</div> <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="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"> <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"> <div class="message-meta">
<span>#{{ index + 1 }}</span> <span>#{{ card.index }}</span>
<span v-if="message.topic">{{ message.topic }}</span> <span class="type-badge" :data-type="card.record.type">{{ typeLabel(card.record.type) }}</span>
<span v-if="message.partition != null">{{ t("mqMessages.metaPartition", { partition: message.partition }) }}</span> <span>{{ rocketMqTraceRecordTimeLabel(card.record, card.message.timestamp) }}</span>
<span>{{ formatRocketMqTimestamp(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> </div>
<pre class="message-payload">{{ rocketMqMessagePayload(message) }}</pre>
<div v-if="message.headers && Object.keys(message.headers).length" class="message-headers"> <div v-if="card.parsed" class="detail-grid trace-fields">
<span v-for="(value, key) in message.headers" :key="key">{{ key }}: {{ value }}</span> <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> </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> </article>
</div> </div>
</div> </div>
@@ -176,6 +284,10 @@ watch(
font-weight: 500; font-weight: 500;
} }
.trace-fields {
margin-top: 10px;
}
.mono { .mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all; word-break: break-all;
@@ -213,11 +325,54 @@ watch(
.message-meta { .message-meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; align-items: center;
gap: 8px 10px;
font-size: 12px; font-size: 12px;
color: var(--color-text-tertiary); 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 { .message-payload {
margin: 8px 0 0; margin: 8px 0 0;
padding: 10px; padding: 10px;
@@ -238,12 +393,62 @@ watch(
margin-top: 8px; margin-top: 8px;
} }
.message-headers span { .message-headers > span {
padding: 2px 6px; padding: 2px 6px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4); border-radius: var(--dbx-radius-fixed-4);
font-size: 12px; font-size: 12px;
color: var(--color-text-secondary); 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 { .btn-secondary {
@@ -5,6 +5,7 @@ import type { MqSystemKind, TopicInfo, TopicRef, SendMessageRequest, SendMessage
import { mqSendMessage, mqListTopics } from "@/lib/backend/api"; import { mqSendMessage, mqListTopics } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils"; import { formatError } from "@/lib/backend/errorUtils";
import { resolveRabbitMqSendNamespace } from "@/lib/mq/mqConsoleDefaults"; import { resolveRabbitMqSendNamespace } from "@/lib/mq/mqConsoleDefaults";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import MessageBrowser from "./MessageBrowser.vue"; import MessageBrowser from "./MessageBrowser.vue";
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue"; import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
@@ -23,6 +24,7 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const topicName = ref(""); const topicName = ref("");
const messageKey = ref(""); const messageKey = ref("");
@@ -82,12 +84,12 @@ onUnmounted(() => {
if (successTimer) clearTimeout(successTimer); if (successTimer) clearTimeout(successTimer);
}); });
function guardWritable() { async function guardWritable() {
if (props.readOnly) { if (props.readOnly) {
error.value = readOnlyMessage.value; error.value = readOnlyMessage.value;
return false; return false;
} }
return true; return confirmMqWrite(t("mqMessages.sendMessage"));
} }
async function loadTopics() { async function loadTopics() {
@@ -138,7 +140,7 @@ function parseHeaders(): Record<string, string> {
} }
async function sendMessage() { async function sendMessage() {
if (!guardWritable()) return; if (!(await guardWritable())) return;
error.value = undefined; error.value = undefined;
success.value = undefined; success.value = undefined;
@@ -327,6 +329,8 @@ watch(
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.send-message-panel { .send-message-panel {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -463,27 +467,6 @@ watch(
box-shadow: 0 0 0 2px var(--color-primary-alpha); 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 { .spin {
display: inline-block; display: inline-block;
animation: spin-anim 0.8s linear infinite; animation: spin-anim 0.8s linear infinite;
@@ -587,35 +570,11 @@ input[type="number"]:focus {
padding-top: 4px; padding-top: 4px;
} }
.btn-primary, /* Keep send CTA visually balanced next to optional secondary actions */
.btn-sm { .form-actions .btn-primary {
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;
min-width: 100px; min-width: 100px;
} }
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
button:disabled, button:disabled,
input:disabled, input:disabled,
textarea:disabled, textarea:disabled,
@@ -3,11 +3,12 @@ import { formatError } from "@/lib/backend/errorUtils";
import { ref, watch, computed } from "vue"; import { ref, watch, computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import type { TopicRef, TopicInfo, SubscriptionInfo, ResetPosition, SkipCount, PeekedMessage, MqSystemKind } from "@/types/mq"; 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 RocketMqConsumerGroupDialogs, { type RocketMqConsumerGroupDialogKind } from "./rocketmq/RocketMqConsumerGroupDialogs.vue";
import MqTypeFilterBar from "./shared/MqTypeFilterBar.vue"; import MqTypeFilterBar from "./shared/MqTypeFilterBar.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.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 { 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 { interface Props {
connectionId: string; connectionId: string;
@@ -31,10 +32,15 @@ const emit = defineEmits<{
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const subscriptions = ref<SubscriptionInfo[]>([]); const subscriptions = ref<SubscriptionInfo[]>([]);
const loading = ref(false); const loading = ref(false);
const enriching = ref(false);
const truncatedHint = ref<string>();
const enrichFailedHint = ref<string>();
const error = ref<string>(); const error = ref<string>();
let loadSeq = 0;
const showCreateDialog = ref(false); const showCreateDialog = ref(false);
const showResetDialog = ref(false); const showResetDialog = ref(false);
const showSkipDialog = ref(false); const showSkipDialog = ref(false);
@@ -82,14 +88,23 @@ const canShowPanel = computed(() => isClusterWideMode.value || !!props.topic);
const rocketMqConsumerGroupTypeOptions = ROCKETMQ_CONSUMER_GROUP_TYPES; const rocketMqConsumerGroupTypeOptions = ROCKETMQ_CONSUMER_GROUP_TYPES;
const panelTitle = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.consumerGroupTitle") : t("mqSubscriptions.title"))); const panelTitle = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.consumerGroupTitle") : t("mqSubscriptions.title")));
const searchPlaceholder = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.searchConsumerGroup") : t("mqSubscriptions.searchPlaceholder"))); 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; let rows = subscriptions.value;
if (isRocketMqCluster.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(); const keyword = searchKeyword.value.trim().toLowerCase();
if (!keyword) return rows; if (!keyword) return typeFilteredSubscriptions.value;
return rows.filter((sub) => sub.name.toLowerCase().includes(keyword)); return typeFilteredSubscriptions.value.filter((sub) => sub.name.toLowerCase().includes(keyword));
}); });
function consumerGroupTypeLabel(sub: SubscriptionInfo): string { function consumerGroupTypeLabel(sub: SubscriptionInfo): string {
@@ -100,7 +115,7 @@ function consumerGroupTypeLabel(sub: SubscriptionInfo): string {
function consumerGroupTypeBadgeClass(sub: SubscriptionInfo): string { function consumerGroupTypeBadgeClass(sub: SubscriptionInfo): string {
const type = resolveRocketMqConsumerGroupType(sub); const type = resolveRocketMqConsumerGroupType(sub);
if (type === "FIFO") return "badge badge-info"; 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"; return "badge";
} }
@@ -109,12 +124,12 @@ function consumerGroupModeLabel(sub: SubscriptionInfo): string {
return t(`mqSubscriptions.rocketmqGroupMode.${mode.toLowerCase()}`); return t(`mqSubscriptions.rocketmqGroupMode.${mode.toLowerCase()}`);
} }
function guardWritable() { async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) { if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly"); error.value = t("mqSubscriptions.readOnly");
return false; return false;
} }
return true; return confirmMqWrite(operation);
} }
function getListTopicRef(): TopicRef | null { function getListTopicRef(): TopicRef | null {
@@ -153,21 +168,57 @@ async function loadSubscriptions() {
const topicRef = getListTopicRef(); const topicRef = getListTopicRef();
if (!topicRef) { if (!topicRef) {
subscriptions.value = []; subscriptions.value = [];
truncatedHint.value = undefined;
enrichFailedHint.value = undefined;
return; return;
} }
const seq = ++loadSeq;
loading.value = true; loading.value = true;
enriching.value = false;
error.value = undefined; error.value = undefined;
truncatedHint.value = undefined;
enrichFailedHint.value = undefined;
try { 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) { } catch (e: unknown) {
error.value = formatError(e); if (seq === loadSeq) error.value = formatError(e);
} finally { } finally {
loading.value = false; if (seq === loadSeq) loading.value = false;
} }
} }
function openCreateDialog() { function openCreateDialog() {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
formData.value = { formData.value = {
subName: "", subName: "",
startFrom: "latest", startFrom: "latest",
@@ -175,13 +226,24 @@ function openCreateDialog() {
showCreateDialog.value = true; 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) { function openRocketMqDetail(sub: SubscriptionInfo) {
selectedSub.value = sub; selectedSub.value = sub;
activeRocketMqDialog.value = "detail"; activeRocketMqDialog.value = "detail";
} }
function openRocketMqConfig(sub: SubscriptionInfo) { function openRocketMqConfig(sub: SubscriptionInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
selectedSub.value = sub; selectedSub.value = sub;
activeRocketMqDialog.value = "config"; activeRocketMqDialog.value = "config";
} }
@@ -191,7 +253,10 @@ function closeRocketMqDialog() {
} }
function openResetDialog(sub: SubscriptionInfo) { function openResetDialog(sub: SubscriptionInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
selectedSub.value = sub; selectedSub.value = sub;
resetFormData.value = { resetFormData.value = {
position: "latest", position: "latest",
@@ -201,7 +266,10 @@ function openResetDialog(sub: SubscriptionInfo) {
} }
function openSkipDialog(sub: SubscriptionInfo) { function openSkipDialog(sub: SubscriptionInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
selectedSub.value = sub; selectedSub.value = sub;
skipFormData.value = { skipFormData.value = {
mode: "count", mode: "count",
@@ -220,7 +288,10 @@ function openPeekDialog(sub: SubscriptionInfo) {
} }
function openExpireDialog(sub: SubscriptionInfo) { function openExpireDialog(sub: SubscriptionInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
selectedSub.value = sub; selectedSub.value = sub;
expireSeconds.value = 3600; expireSeconds.value = 3600;
showExpireDialog.value = true; showExpireDialog.value = true;
@@ -236,7 +307,7 @@ function selectSubscription(sub: SubscriptionInfo) {
} }
async function handleCreate() { async function handleCreate() {
if (!guardWritable()) return; if (!(await guardWritable(t("mqSubscriptions.create")))) return;
const topicRef = getPulsarTopicRef(); const topicRef = getPulsarTopicRef();
if (!formData.value.subName.trim() || !topicRef) { if (!formData.value.subName.trim() || !topicRef) {
error.value = t("mqSubscriptions.subscriptionNameRequired"); error.value = t("mqSubscriptions.subscriptionNameRequired");
@@ -257,12 +328,16 @@ async function handleCreate() {
} }
function handleDelete(sub: SubscriptionInfo) { function handleDelete(sub: SubscriptionInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
deleteTarget.value = sub; deleteTarget.value = sub;
showDeleteDialog.value = true; showDeleteDialog.value = true;
} }
async function confirmDelete() { async function confirmDelete() {
if (!(await guardWritable(t("mqSubscriptions.delete")))) return;
const sub = deleteTarget.value; const sub = deleteTarget.value;
if (!sub) return; if (!sub) return;
const topicRef = getListTopicRef(); const topicRef = getListTopicRef();
@@ -281,7 +356,7 @@ async function confirmDelete() {
} }
async function handleResetCursor() { async function handleResetCursor() {
if (!guardWritable()) return; if (!(await guardWritable(t("mqSubscriptions.reset")))) return;
const topicRef = getPulsarTopicRef(); const topicRef = getPulsarTopicRef();
if (!selectedSub.value || !topicRef) return; if (!selectedSub.value || !topicRef) return;
loading.value = true; loading.value = true;
@@ -304,7 +379,7 @@ async function handleResetCursor() {
} }
async function handleSkipMessages() { async function handleSkipMessages() {
if (!guardWritable()) return; if (!(await guardWritable(t("mqSubscriptions.skip")))) return;
const topicRef = getPulsarTopicRef(); const topicRef = getPulsarTopicRef();
if (!selectedSub.value || !topicRef) return; if (!selectedSub.value || !topicRef) return;
loading.value = true; loading.value = true;
@@ -322,12 +397,16 @@ async function handleSkipMessages() {
} }
function handleClearBacklog(sub: SubscriptionInfo) { function handleClearBacklog(sub: SubscriptionInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqSubscriptions.readOnly");
return;
}
clearBacklogTarget.value = sub; clearBacklogTarget.value = sub;
showClearBacklogDialog.value = true; showClearBacklogDialog.value = true;
} }
async function confirmClearBacklog() { async function confirmClearBacklog() {
if (!(await guardWritable(t("mqSubscriptions.clearBacklog")))) return;
const sub = clearBacklogTarget.value; const sub = clearBacklogTarget.value;
if (!sub) return; if (!sub) return;
const topicRef = getPulsarTopicRef(); const topicRef = getPulsarTopicRef();
@@ -381,7 +460,7 @@ function invalidatePeekRequest() {
} }
async function handleExpireMessages() { async function handleExpireMessages() {
if (!guardWritable()) return; if (!(await guardWritable(t("mqSubscriptions.expire")))) return;
const topicRef = getPulsarTopicRef(); const topicRef = getPulsarTopicRef();
if (!selectedSub.value || !topicRef) return; if (!selectedSub.value || !topicRef) return;
loading.value = true; loading.value = true;
@@ -414,10 +493,11 @@ watch(
<div class="toolbar-left"> <div class="toolbar-left">
<h3>{{ panelTitle }}</h3> <h3>{{ panelTitle }}</h3>
<input v-if="isClusterWideMode" v-model="searchKeyword" type="search" class="topic-search" :placeholder="searchPlaceholder" /> <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>
<div class="toolbar-actions"> <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") }} {{ loading ? t("mqSubscriptions.refreshing") : t("mqSubscriptions.refresh") }}
</button> </button>
<button v-if="supportsCreateSubscription !== false && !isRocketMqCluster" @click="openCreateDialog" :disabled="loading || readOnly || !topic" class="btn-primary">+ {{ t("mqSubscriptions.createSubscription") }}</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> <template v-else>
<div v-if="error" class="panel-error">{{ error }}</div> <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"> <div v-else-if="!error && !filteredSubscriptions.length" class="panel-placeholder">
{{ isClusterWideMode ? t("mqSubscriptions.noConsumerGroups") : t("mqSubscriptions.noSubscriptions") }} {{ isClusterWideMode ? (subscriptions.length ? t("mqSubscriptions.noMatches") : t("mqSubscriptions.noConsumerGroups")) : t("mqSubscriptions.noSubscriptions") }}
</div> </div>
<div v-else class="subscriptions-table"> <div v-else-if="!error && filteredSubscriptions.length" class="subscriptions-table">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -471,12 +553,19 @@ watch(
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</td> </td>
<td v-if="!isClusterWideMode"> <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() }} {{ sub.msgBacklog.toLocaleString() }}
</span> </span>
</td> </td>
<td v-if="!isClusterWideMode">{{ t("mqSubscriptions.msgRate", { rate: sub.msgRateOut.toFixed(2) }) }}</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"> <td class="actions">
<template v-if="isRocketMqCluster"> <template v-if="isRocketMqCluster">
<button @click.stop="openRocketMqDetail(sub)" class="btn-sm">{{ t("mqSubscriptions.viewDetail") }}</button> <button @click.stop="openRocketMqDetail(sub)" class="btn-sm">{{ t("mqSubscriptions.viewDetail") }}</button>
@@ -692,6 +781,8 @@ watch(
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.subscriptions-panel { .subscriptions-panel {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -744,20 +835,6 @@ watch(
font-size: 12px; 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 { .subscription-type-filters {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -829,6 +906,7 @@ watch(
.panel-placeholder, .panel-placeholder,
.panel-error, .panel-error,
.panel-hint,
.panel-loading { .panel-loading {
padding: 24px; padding: 24px;
text-align: center; text-align: center;
@@ -839,6 +917,12 @@ watch(
color: var(--color-error); color: var(--color-error);
} }
.panel-hint {
padding: 8px 16px;
text-align: left;
font-size: 12px;
}
.subscriptions-table { .subscriptions-table {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
@@ -902,52 +986,16 @@ td {
font-weight: 500; font-weight: 500;
} }
.text-muted {
color: var(--color-text-secondary);
}
.actions { .actions {
display: flex; display: flex;
gap: 4px; gap: 4px;
flex-wrap: wrap; 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 { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -992,16 +1040,6 @@ button:disabled {
font-size: 18px; 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 { .dialog-body {
padding: 20px; padding: 20px;
max-height: 60vh; max-height: 60vh;
+17 -56
View File
@@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
import type { TenantInfo, TenantConfig } from "@/types/mq"; import type { TenantInfo, TenantConfig } from "@/types/mq";
import { mqListTenants, mqCreateTenant, mqUpdateTenant, mqDeleteTenant } from "@/lib/backend/api"; import { mqListTenants, mqCreateTenant, mqUpdateTenant, mqDeleteTenant } from "@/lib/backend/api";
import { defaultTenantConfig, normalizeClusterOptions, validateTenantForm } from "@/lib/mq/mqTenantForm"; import { defaultTenantConfig, normalizeClusterOptions, validateTenantForm } from "@/lib/mq/mqTenantForm";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
interface Props { interface Props {
connectionId: string; connectionId: string;
@@ -19,6 +20,7 @@ const emit = defineEmits<{
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const tenants = ref<TenantInfo[]>([]); const tenants = ref<TenantInfo[]>([]);
const loading = ref(false); 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 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); const canSubmitTenant = computed(() => Boolean(formData.value.name.trim()) && selectedAllowedClusters.value.length > 0);
function guardWritable() { async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) { if (props.readOnly) {
error.value = t("mqTenants.readOnly"); error.value = t("mqTenants.readOnly");
return false; return false;
} }
return true; return confirmMqWrite(operation);
} }
async function loadTenants() { async function loadTenants() {
@@ -67,7 +69,10 @@ async function loadTenants() {
} }
function openCreateDialog() { function openCreateDialog() {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqTenants.readOnly");
return;
}
formData.value = { formData.value = {
name: "", name: "",
config: defaultTenantConfig(normalizedClusterOptions.value), config: defaultTenantConfig(normalizedClusterOptions.value),
@@ -79,7 +84,10 @@ function openCreateDialog() {
} }
function openEditDialog(tenant: TenantInfo) { function openEditDialog(tenant: TenantInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqTenants.readOnly");
return;
}
editingTenant.value = tenant; editingTenant.value = tenant;
formData.value = { formData.value = {
name: tenant.name, name: tenant.name,
@@ -95,7 +103,7 @@ function openEditDialog(tenant: TenantInfo) {
} }
async function handleCreate() { async function handleCreate() {
if (!guardWritable()) return; if (!(await guardWritable(t("mqTenants.create")))) return;
const validationError = validateTenantForm(formData.value.name, formData.value.config); const validationError = validateTenantForm(formData.value.name, formData.value.config);
if (validationError) { if (validationError) {
error.value = t(validationError); error.value = t(validationError);
@@ -116,7 +124,7 @@ async function handleCreate() {
} }
async function handleUpdate() { async function handleUpdate() {
if (!guardWritable()) return; if (!(await guardWritable(t("mqTenants.edit")))) return;
if (!editingTenant.value) return; if (!editingTenant.value) return;
const validationError = validateTenantForm(formData.value.name, formData.value.config); const validationError = validateTenantForm(formData.value.name, formData.value.config);
if (validationError) { if (validationError) {
@@ -138,7 +146,7 @@ async function handleUpdate() {
} }
async function handleDelete(tenant: TenantInfo) { async function handleDelete(tenant: TenantInfo) {
if (!guardWritable()) return; if (!(await guardWritable(t("mqTenants.delete")))) return;
if (!confirm(t("mqTenants.confirmDelete", { name: tenant.name }))) return; if (!confirm(t("mqTenants.confirmDelete", { name: tenant.name }))) return;
loading.value = true; loading.value = true;
error.value = undefined; error.value = undefined;
@@ -392,6 +400,8 @@ watch(normalizedClusterOptions, (clusters) => {
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.tenants-panel { .tenants-panel {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -509,45 +519,6 @@ td {
gap: 8px; 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 { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -588,16 +559,6 @@ button:disabled {
font-size: 18px; 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 { .dialog-body {
padding: 20px; padding: 20px;
max-height: 60vh; max-height: 60vh;
+22 -56
View File
@@ -14,6 +14,7 @@ import type { MqTab } from "@/lib/mq/mqConsoleDefaults";
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults"; import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
import { formatError } from "@/lib/backend/errorUtils"; 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 { 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"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
const TOPIC_ROW_HEIGHT = 44; const TOPIC_ROW_HEIGHT = 44;
@@ -193,12 +194,14 @@ const canSubmitPartitionUpdate = computed(() => {
return !props.readOnly && current > 0 && Number.isFinite(newPartitions.value) && newPartitions.value > current; 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) { if (props.readOnly) {
error.value = t("mqTopics.readOnly"); error.value = t("mqTopics.readOnly");
return false; return false;
} }
return true; return confirmMqWrite(operation);
} }
async function loadTopics() { async function loadTopics() {
@@ -234,7 +237,10 @@ async function loadClusterInfo() {
} }
function openCreateDialog() { function openCreateDialog() {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return;
}
dialogError.value = undefined; dialogError.value = undefined;
const defaultBroker = rocketMqMasterBrokers.value[0]?.brokerName ?? rocketMqBrokerOptions.value[0]?.brokerName ?? ""; const defaultBroker = rocketMqMasterBrokers.value[0]?.brokerName ?? rocketMqBrokerOptions.value[0]?.brokerName ?? "";
formData.value = { formData.value = {
@@ -307,7 +313,10 @@ function isDlqTopic(topic: TopicInfo): boolean {
} }
function openPartitionsDialog(topic: TopicInfo) { function openPartitionsDialog(topic: TopicInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return;
}
dialogError.value = undefined; dialogError.value = undefined;
if (!topic.partitions || topic.partitions < 1) { if (!topic.partitions || topic.partitions < 1) {
error.value = t("mqTopics.currentPartitionsUnknown"); error.value = t("mqTopics.currentPartitionsUnknown");
@@ -319,7 +328,7 @@ function openPartitionsDialog(topic: TopicInfo) {
} }
async function handleCreate() { async function handleCreate() {
if (!guardWritable()) return; if (!(await guardWritable(t("mqTopics.create")))) return;
if (!formData.value.topicName.trim() || !props.tenant || !props.namespace) { if (!formData.value.topicName.trim() || !props.tenant || !props.namespace) {
dialogError.value = t("mqTopics.topicNameRequired"); dialogError.value = t("mqTopics.topicNameRequired");
return; return;
@@ -353,12 +362,16 @@ async function handleCreate() {
} }
function handleDelete(topic: TopicInfo) { function handleDelete(topic: TopicInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return;
}
deleteTarget.value = topic; deleteTarget.value = topic;
showDeleteDialog.value = true; showDeleteDialog.value = true;
} }
async function confirmDelete() { async function confirmDelete() {
if (!(await guardWritable(t("mqTopics.delete")))) return;
const topic = deleteTarget.value; const topic = deleteTarget.value;
if (!topic || !props.tenant || !props.namespace) return; if (!topic || !props.tenant || !props.namespace) return;
const namespace = resolveMqRowNamespace(topic, props.namespace); const namespace = resolveMqRowNamespace(topic, props.namespace);
@@ -390,7 +403,7 @@ async function confirmDelete() {
} }
async function handleUpdatePartitions() { async function handleUpdatePartitions() {
if (!guardWritable()) return; if (!(await guardWritable(t("mqTopics.adjustPartitions")))) return;
if (!editingTopic.value || !props.tenant || !props.namespace) return; if (!editingTopic.value || !props.tenant || !props.namespace) return;
const currentPartitions = editingTopic.value.partitions; const currentPartitions = editingTopic.value.partitions;
if (!currentPartitions || currentPartitions < 1) { if (!currentPartitions || currentPartitions < 1) {
@@ -719,6 +732,8 @@ watch(newPartitions, () => {
</template> </template>
<style scoped> <style scoped>
@import "./shared/mqPanel.css";
.topics-panel { .topics-panel {
--topics-surface: var(--card, var(--color-background, #ffffff)); --topics-surface: var(--card, var(--color-background, #ffffff));
--topics-header-bg: color-mix(in srgb, var(--secondary, #f5f5f5) 86%, var(--card, #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); 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 { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -1089,16 +1065,6 @@ button:disabled {
font-size: 18px; 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 { .dialog-body {
padding: 20px; padding: 20px;
max-height: 60vh; max-height: 60vh;
@@ -25,6 +25,10 @@ vi.mock("@/lib/backend/api", () => ({
mqSendMessage: backend.mqSendMessage, mqSendMessage: backend.mqSendMessage,
})); }));
vi.mock("@/composables/useMqMutationGuard", () => ({
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
}));
import MessageQueryPanel from "@/components/mq/MessageQueryPanel.vue"; import MessageQueryPanel from "@/components/mq/MessageQueryPanel.vue";
const TOPIC = { const TOPIC = {
@@ -19,6 +19,10 @@ vi.mock("@/lib/backend/api", () => ({
mqSendMessage: backend.mqSendMessage, mqSendMessage: backend.mqSendMessage,
})); }));
vi.mock("@/composables/useMqMutationGuard", () => ({
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
}));
vi.mock("@/components/ui/select", async () => (await import("./selectStub")).createSelectStub()); vi.mock("@/components/ui/select", async () => (await import("./selectStub")).createSelectStub());
import SendMessagePanel from "@/components/mq/SendMessagePanel.vue"; import SendMessagePanel from "@/components/mq/SendMessagePanel.vue";
@@ -6,6 +6,7 @@ import type { PeekedMessage } from "@/types/mq";
const backend = vi.hoisted(() => ({ const backend = vi.hoisted(() => ({
mqListSubscriptions: vi.fn(), mqListSubscriptions: vi.fn(),
mqEnrichSubscriptions: vi.fn(),
mqCreateSubscription: vi.fn(), mqCreateSubscription: vi.fn(),
mqDeleteSubscription: vi.fn(), mqDeleteSubscription: vi.fn(),
mqResetCursor: vi.fn(), mqResetCursor: vi.fn(),
@@ -21,6 +22,10 @@ vi.mock("vue-i18n", () => ({
vi.mock("@/lib/backend/api", () => backend); vi.mock("@/lib/backend/api", () => backend);
vi.mock("@/composables/useMqMutationGuard", () => ({
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
}));
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({
default: { template: "<div />" }, default: { template: "<div />" },
})); }));
@@ -61,7 +66,7 @@ function deferred<T>() {
return { promise, resolve }; return { promise, resolve };
} }
async function mountPanel() { async function mountPanel(overrides: Record<string, unknown> = {}) {
root = document.createElement("div"); root = document.createElement("div");
document.body.appendChild(root); document.body.appendChild(root);
app = createApp(SubscriptionsPanel, { app = createApp(SubscriptionsPanel, {
@@ -71,15 +76,29 @@ async function mountPanel() {
namespace: "default", namespace: "default",
mqSystemKind: "kafka", mqSystemKind: "kafka",
supportsPeekMessages: true, supportsPeekMessages: true,
...overrides,
}); });
app.mount(root); app.mount(root);
await flushUi(); await flushUi();
return root; return root;
} }
function subscription(name: string, consumerGroupType: string) {
return {
name,
subType: consumerGroupType,
consumerGroupType,
messageModel: "CLUSTERING",
msgBacklog: 0,
msgRateOut: 0,
msgThroughputOut: 0,
consumers: [],
};
}
beforeEach(() => { beforeEach(() => {
Object.values(backend).forEach((mock) => mock.mockReset()); Object.values(backend).forEach((mock) => mock.mockReset());
backend.mqListSubscriptions.mockResolvedValue([ const rows = [
{ {
name: "orders-consumer", name: "orders-consumer",
subType: "shared", subType: "shared",
@@ -88,7 +107,9 @@ beforeEach(() => {
msgThroughputOut: 0, msgThroughputOut: 0,
consumers: [], consumers: [],
}, },
]); ];
backend.mqListSubscriptions.mockResolvedValue(rows);
backend.mqEnrichSubscriptions.mockResolvedValue(rows);
}); });
afterEach(() => { afterEach(() => {
@@ -176,3 +197,63 @@ describe("SubscriptionsPanel message peek", () => {
expect(panel.textContent).not.toContain("orders message"); 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");
});
});
@@ -5,6 +5,7 @@ import type { MqChannelInfo, MqClientConnectionInfo, NamespaceRef } from "@/type
import { mqCloseClientConnection, mqListClientChannels, mqListClientConnections } from "@/lib/backend/api"; import { mqCloseClientConnection, mqListClientChannels, mqListClientConnections } from "@/lib/backend/api";
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults"; import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
import { formatError } from "@/lib/backend/errorUtils"; import { formatError } from "@/lib/backend/errorUtils";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props { interface Props {
@@ -16,6 +17,7 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const connections = ref<MqClientConnectionInfo[]>([]); const connections = ref<MqClientConnectionInfo[]>([]);
const loading = ref(false); const loading = ref(false);
@@ -48,12 +50,12 @@ function nsRef(namespace?: string): NamespaceRef {
return { tenant: RABBITMQ_MQ_TENANT, namespace: namespace ?? "" }; return { tenant: RABBITMQ_MQ_TENANT, namespace: namespace ?? "" };
} }
function guardWritable() { async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) { if (props.readOnly) {
error.value = t("mqClientConnections.readOnly"); error.value = t("mqClientConnections.readOnly");
return false; return false;
} }
return true; return confirmMqWrite(operation);
} }
async function loadConnections() { async function loadConnections() {
@@ -112,12 +114,16 @@ function toggleExpanded(connection: MqClientConnectionInfo) {
} }
function openCloseDialog(connection: MqClientConnectionInfo) { function openCloseDialog(connection: MqClientConnectionInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqClientConnections.readOnly");
return;
}
closeTarget.value = connection; closeTarget.value = connection;
showCloseDialog.value = true; showCloseDialog.value = true;
} }
async function confirmClose() { async function confirmClose() {
if (!(await guardWritable(t("mqClientConnections.closeConnection")))) return;
const target = closeTarget.value; const target = closeTarget.value;
if (!target) return; if (!target) return;
const namespace = resolveMqRowNamespace(target, props.namespace); const namespace = resolveMqRowNamespace(target, props.namespace);
@@ -274,6 +280,8 @@ watch(
</template> </template>
<style scoped> <style scoped>
@import "../shared/mqPanel.css";
.clients-panel { .clients-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -442,37 +450,6 @@ td {
align-items: center; 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 { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -253,6 +253,8 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
@import "../shared/mqPanel.css";
.rabbitmq-monitoring-panel { .rabbitmq-monitoring-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -415,21 +417,6 @@ td {
color: var(--color-error); 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 { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -5,6 +5,7 @@ import type { MqUserInfo, MqVhostPermission } from "@/types/mq";
import { mqCreateUser, mqDeleteUser, mqGrantUserPermission, mqListNamespaces, mqListUserPermissions, mqListUsers, mqRevokeUserPermission } from "@/lib/backend/api"; import { mqCreateUser, mqDeleteUser, mqGrantUserPermission, mqListNamespaces, mqListUserPermissions, mqListUsers, mqRevokeUserPermission } from "@/lib/backend/api";
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults"; import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults";
import { formatError } from "@/lib/backend/errorUtils"; import { formatError } from "@/lib/backend/errorUtils";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props { interface Props {
@@ -16,6 +17,7 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const users = ref<MqUserInfo[]>([]); const users = ref<MqUserInfo[]>([]);
const usersLoading = ref(false); const usersLoading = ref(false);
@@ -58,12 +60,12 @@ const filteredPermissions = computed(() => {
return permissions.value.filter((permission) => permission.user === selectedUser.value); return permissions.value.filter((permission) => permission.user === selectedUser.value);
}); });
function guardWritable(): boolean { async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) { if (props.readOnly) {
error.value = t("mqUserPermissions.readOnly"); error.value = t("mqUserPermissions.readOnly");
return false; return false;
} }
return true; return confirmMqWrite(operation);
} }
async function loadUsers() { async function loadUsers() {
@@ -114,7 +116,10 @@ function toggleUserFilter(user: MqUserInfo) {
} }
function openCreateDialog() { function openCreateDialog() {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqUserPermissions.readOnly");
return;
}
createForm.value = { name: "", password: "", tagsText: "" }; createForm.value = { name: "", password: "", tagsText: "" };
dialogError.value = undefined; dialogError.value = undefined;
showCreateDialog.value = true; showCreateDialog.value = true;
@@ -129,6 +134,7 @@ function parseTags(text: string): string[] | undefined {
} }
async function handleCreateUser() { async function handleCreateUser() {
if (!(await guardWritable(t("mqUserPermissions.createUser")))) return;
const name = createForm.value.name.trim(); const name = createForm.value.name.trim();
const password = createForm.value.password; const password = createForm.value.password;
if (!name) { if (!name) {
@@ -153,12 +159,16 @@ async function handleCreateUser() {
} }
function openDeleteDialog(user: MqUserInfo) { function openDeleteDialog(user: MqUserInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqUserPermissions.readOnly");
return;
}
deleteTarget.value = user; deleteTarget.value = user;
showDeleteDialog.value = true; showDeleteDialog.value = true;
} }
async function confirmDelete() { async function confirmDelete() {
if (!(await guardWritable(t("mqUserPermissions.delete")))) return;
const target = deleteTarget.value; const target = deleteTarget.value;
if (!target) return; if (!target) return;
deleting.value = true; deleting.value = true;
@@ -175,7 +185,10 @@ async function confirmDelete() {
} }
function openGrantDialog() { function openGrantDialog() {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqUserPermissions.readOnly");
return;
}
const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : ""; const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : "";
grantForm.value = { grantForm.value = {
user: selectedUser.value ?? users.value[0]?.name ?? "", user: selectedUser.value ?? users.value[0]?.name ?? "",
@@ -194,6 +207,7 @@ function patternOrDefault(value: string): string | undefined {
} }
async function handleGrant() { async function handleGrant() {
if (!(await guardWritable(t("mqUserPermissions.grant")))) return;
const user = grantForm.value.user.trim(); const user = grantForm.value.user.trim();
const virtualHost = grantForm.value.virtualHost.trim(); const virtualHost = grantForm.value.virtualHost.trim();
if (!user) { if (!user) {
@@ -222,12 +236,16 @@ async function handleGrant() {
} }
function openRevokeDialog(permission: MqVhostPermission) { function openRevokeDialog(permission: MqVhostPermission) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqUserPermissions.readOnly");
return;
}
revokeTarget.value = permission; revokeTarget.value = permission;
showRevokeDialog.value = true; showRevokeDialog.value = true;
} }
async function confirmRevoke() { async function confirmRevoke() {
if (!(await guardWritable(t("mqUserPermissions.revoke")))) return;
const target = revokeTarget.value; const target = revokeTarget.value;
if (!target) return; if (!target) return;
revoking.value = true; revoking.value = true;
@@ -457,6 +475,8 @@ watch(
</template> </template>
<style scoped> <style scoped>
@import "../shared/mqPanel.css";
.user-permissions-panel { .user-permissions-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -639,48 +659,6 @@ td {
align-items: center; 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 { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -721,16 +699,6 @@ button:disabled {
font-size: 18px; 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 { .dialog-body {
padding: 20px; padding: 20px;
} }
@@ -5,6 +5,7 @@ import type { MqPolicyInfo, MqPolicyUpsertRequest } from "@/types/mq";
import { mqDeletePolicy, mqListNamespaces, mqListPolicies, mqSetPolicy } from "@/lib/backend/api"; import { mqDeletePolicy, mqListNamespaces, mqListPolicies, mqSetPolicy } from "@/lib/backend/api";
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults"; import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults";
import { formatError } from "@/lib/backend/errorUtils"; import { formatError } from "@/lib/backend/errorUtils";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
interface Props { interface Props {
@@ -21,6 +22,7 @@ interface DefinitionEntry {
const props = defineProps<Props>(); const props = defineProps<Props>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
/** Well-known policy definition keys offered in the editor dropdown. */ /** 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"]; 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 deleteTarget = ref<MqPolicyInfo>();
const deleting = ref(false); const deleting = ref(false);
function guardWritable(): boolean { async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) { if (props.readOnly) {
error.value = t("mqRabbitMqPolicies.readOnly"); error.value = t("mqRabbitMqPolicies.readOnly");
return false; return false;
} }
return true; return confirmMqWrite(operation);
} }
async function loadPolicies() { async function loadPolicies() {
@@ -88,7 +90,10 @@ function definitionEntriesFrom(policy: MqPolicyInfo): DefinitionEntry[] {
} }
function openCreateDialog() { function openCreateDialog() {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqRabbitMqPolicies.readOnly");
return;
}
const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : ""; const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : "";
editingPolicy.value = undefined; editingPolicy.value = undefined;
editForm.value = { name: "", virtualHost: currentVhost || vhosts.value[0] || "", pattern: "", applyTo: "queues", priority: 0 }; editForm.value = { name: "", virtualHost: currentVhost || vhosts.value[0] || "", pattern: "", applyTo: "queues", priority: 0 };
@@ -98,7 +103,10 @@ function openCreateDialog() {
} }
function openEditDialog(policy: MqPolicyInfo) { function openEditDialog(policy: MqPolicyInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqRabbitMqPolicies.readOnly");
return;
}
editingPolicy.value = policy; editingPolicy.value = policy;
editForm.value = { name: policy.name, virtualHost: policy.vhost, pattern: policy.pattern, applyTo: policy.applyTo || "queues", priority: policy.priority }; editForm.value = { name: policy.name, virtualHost: policy.vhost, pattern: policy.pattern, applyTo: policy.applyTo || "queues", priority: policy.priority };
definitionEntries.value = definitionEntriesFrom(policy); definitionEntries.value = definitionEntriesFrom(policy);
@@ -130,6 +138,7 @@ function buildDefinition(): Record<string, unknown> {
} }
async function handleSave() { async function handleSave() {
if (!(await guardWritable(t("mqRabbitMqPolicies.save")))) return;
const name = editForm.value.name.trim(); const name = editForm.value.name.trim();
const virtualHost = editForm.value.virtualHost.trim(); const virtualHost = editForm.value.virtualHost.trim();
const pattern = editForm.value.pattern.trim(); const pattern = editForm.value.pattern.trim();
@@ -167,12 +176,16 @@ async function handleSave() {
} }
function openDeleteDialog(policy: MqPolicyInfo) { function openDeleteDialog(policy: MqPolicyInfo) {
if (!guardWritable()) return; if (props.readOnly) {
error.value = t("mqRabbitMqPolicies.readOnly");
return;
}
deleteTarget.value = policy; deleteTarget.value = policy;
showDeleteDialog.value = true; showDeleteDialog.value = true;
} }
async function confirmDelete() { async function confirmDelete() {
if (!(await guardWritable(t("mqRabbitMqPolicies.delete")))) return;
const target = deleteTarget.value; const target = deleteTarget.value;
if (!target) return; if (!target) return;
deleting.value = true; deleting.value = true;
@@ -331,6 +344,8 @@ watch(
</template> </template>
<style scoped> <style scoped>
@import "../shared/mqPanel.css";
.policies-panel { .policies-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -467,48 +482,6 @@ td {
align-items: center; 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 { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -549,16 +522,6 @@ button:disabled {
font-size: 18px; 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 { .dialog-body {
padding: 20px; padding: 20px;
max-height: 70vh; max-height: 70vh;
@@ -1,13 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; 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 { mqAlterConsumerGroupConfig, mqGetBacklog, mqGetConsumerGroupConfig, mqListConsumers } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils"; import { formatError } from "@/lib/backend/errorUtils";
import { resolveRocketMqConsumerGroupMessageModel, resolveRocketMqConsumerGroupType } from "@/lib/mq/rocketmqConsumerGroupTypes"; import { resolveRocketMqConsumerGroupMessageModel, resolveRocketMqConsumerGroupType } from "@/lib/mq/rocketmqConsumerGroupTypes";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
export type RocketMqConsumerGroupDialogKind = "detail" | "config"; 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 { interface Props {
connectionId: string; connectionId: string;
tenant?: string; tenant?: string;
@@ -24,11 +34,15 @@ const emit = defineEmits<{
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const loading = ref(false); const loading = ref(false);
const dialogError = ref<string>(); const dialogError = ref<string>();
const terminals = ref<ConsumerInfo[]>([]); 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>({ const configForm = ref<RocketMqConsumerGroupConfig>({
groupName: "", groupName: "",
consumeEnable: true, consumeEnable: true,
@@ -67,35 +81,75 @@ function closeDialog() {
emit("close"); 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() { async function loadDetail() {
if (!props.group || !props.tenant || !props.namespace) return; if (!props.group || !props.tenant || !props.namespace) return;
const seq = ++detailLoadSeq;
loading.value = true; loading.value = true;
dialogError.value = undefined; dialogError.value = undefined;
terminals.value = []; terminals.value = [];
topicBacklogs.value = []; topicConsumeDetails.value = [];
try { try {
const topicRef = buildTopicRef(""); const topicRef = buildTopicRef("");
if (topicRef) { 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 topics = subscribedTopics.value;
const backlogRows = await Promise.all( const detailRows = await Promise.all(
topics.map(async (topic) => { topics.map(async (topic) => {
const ref = buildTopicRef(topic); const ref = buildTopicRef(topic);
if (!ref) return { topic, backlog: 0 }; if (!ref) {
return { topic, partitions: [] as PartitionBacklog[], error: t("mqSubscriptions.invalidTopicScope") };
}
try { try {
const stats = await mqGetBacklog(props.connectionId, ref, props.group!.name); const stats = await mqGetBacklog(props.connectionId, ref, props.group!.name);
return { topic, backlog: stats.msgBacklog }; const partitions = stats.partitions ?? [];
} catch { // Topic-level last consume time = max of queue lastTimestamp values (> 0).
return { topic, backlog: 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) { } catch (e: unknown) {
dialogError.value = formatError(e); if (seq === detailLoadSeq) dialogError.value = formatError(e);
} finally { } finally {
loading.value = false; if (seq === detailLoadSeq) loading.value = false;
} }
} }
@@ -115,11 +169,13 @@ function resetConfigForm(groupName: string) {
async function loadConfig() { async function loadConfig() {
if (!props.group) return; if (!props.group) return;
const seq = ++configLoadSeq;
resetConfigForm(props.group.name); resetConfigForm(props.group.name);
loading.value = true; loading.value = true;
dialogError.value = undefined; dialogError.value = undefined;
try { try {
const config = await mqGetConsumerGroupConfig(props.connectionId, props.group.name); const config = await mqGetConsumerGroupConfig(props.connectionId, props.group.name);
if (seq !== configLoadSeq) return;
configForm.value = { configForm.value = {
groupName: config.groupName || props.group.name, groupName: config.groupName || props.group.name,
consumeEnable: config.consumeEnable ?? true, consumeEnable: config.consumeEnable ?? true,
@@ -132,14 +188,15 @@ async function loadConfig() {
whichBrokerWhenConsumeSlowly: config.whichBrokerWhenConsumeSlowly ?? 0, whichBrokerWhenConsumeSlowly: config.whichBrokerWhenConsumeSlowly ?? 0,
}; };
} catch (e: unknown) { } catch (e: unknown) {
dialogError.value = formatError(e); if (seq === configLoadSeq) dialogError.value = formatError(e);
} finally { } finally {
loading.value = false; if (seq === configLoadSeq) loading.value = false;
} }
} }
async function saveConfig() { async function saveConfig() {
if (!props.group || props.readOnly) return; if (!props.group || props.readOnly) return;
if (!(await confirmMqWrite(t("mqSubscriptions.editConfig")))) return;
loading.value = true; loading.value = true;
dialogError.value = undefined; dialogError.value = undefined;
try { try {
@@ -163,11 +220,20 @@ async function saveConfig() {
} }
watch( 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") { if (props.dialog === "detail") {
void loadDetail(); void loadDetail();
} else if (props.dialog === "config") { }
},
{ immediate: true },
);
watch(
() => [props.dialog, props.group?.name] as const,
() => {
if (props.dialog === "config") {
void loadConfig(); void loadConfig();
} }
}, },
@@ -193,7 +259,7 @@ watch(
<span>{{ t("mqSubscriptions.type") }}</span <span>{{ t("mqSubscriptions.type") }}</span
><span>{{ groupTypeLabel }}</span> <span>{{ t("mqSubscriptions.mode") }}</span ><span>{{ groupTypeLabel }}</span> <span>{{ t("mqSubscriptions.mode") }}</span
><span>{{ groupModeLabel }}</span> <span>{{ t("mqSubscriptions.consumers") }}</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>{{ t("mqSubscriptions.subscribedTopics") }}</span>
<span>{{ subscribedTopics.length ? subscribedTopics.join(", ") : "-" }}</span> <span>{{ subscribedTopics.length ? subscribedTopics.join(", ") : "-" }}</span>
</div> </div>
@@ -226,21 +292,55 @@ watch(
<section class="detail-section"> <section class="detail-section">
<h4>{{ t("mqSubscriptions.consumerConsumeDetail") }}</h4> <h4>{{ t("mqSubscriptions.consumerConsumeDetail") }}</h4>
<div v-if="!topicBacklogs.length" class="panel-placeholder">{{ t("mqSubscriptions.noConsumeDetail") }}</div> <div v-if="loading && !topicConsumeDetails.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
<table v-else class="detail-table"> <div v-else-if="!topicConsumeDetails.length" class="panel-placeholder">{{ t("mqSubscriptions.noConsumeDetail") }}</div>
<thead> <div v-else class="consume-detail-list">
<tr> <div v-for="detail in topicConsumeDetails" :key="detail.topic" class="consume-topic-block">
<th>{{ t("mqSubscriptions.operationTopic") }}</th> <div class="consume-topic-header">
<th>{{ t("mqSubscriptions.backlog") }}</th> <span
</tr> ><strong>{{ t("mqSubscriptions.operationTopic") }}:</strong> {{ detail.topic }}</span
</thead> >
<tbody> <span
<tr v-for="row in topicBacklogs" :key="row.topic"> ><strong>{{ t("mqSubscriptions.consumeDelay") }}:</strong> {{ detail.delay == null ? "-" : detail.delay.toLocaleString() }}</span
<td>{{ row.topic }}</td> >
<td>{{ row.backlog.toLocaleString() }}</td> <span
</tr> ><strong>{{ t("mqSubscriptions.lastConsumeTime") }}:</strong> {{ formatConsumeTimestamp(detail.lastTimestamp) }}</span
</tbody> >
</table> </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> </section>
</div> </div>
@@ -303,6 +403,8 @@ watch(
</template> </template>
<style scoped> <style scoped>
@import "../shared/mqPanel.css";
.dialog-overlay { .dialog-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -325,7 +427,7 @@ watch(
} }
.dialog-wide { .dialog-wide {
max-width: 860px; max-width: 1100px;
} }
.dialog-header, .dialog-header,
@@ -354,14 +456,6 @@ watch(
overflow: auto; overflow: auto;
} }
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
}
.detail-sections { .detail-sections {
display: grid; display: grid;
gap: 18px; gap: 18px;
@@ -388,17 +482,44 @@ watch(
font-size: 13px; 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 { .detail-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
} }
.detail-table-wide {
min-width: 720px;
}
.detail-table th, .detail-table th,
.detail-table td { .detail-table td {
padding: 8px 10px; padding: 8px 10px;
border-bottom: 1px solid var(--color-border-light); border-bottom: 1px solid var(--color-border-light);
text-align: left; text-align: left;
font-size: 13px; font-size: 13px;
white-space: nowrap;
} }
.detail-table th { .detail-table th {
@@ -454,6 +575,10 @@ watch(
font-size: 13px; font-size: 13px;
} }
.panel-placeholder-compact {
padding: 10px;
}
.form-error { .form-error {
margin-top: 12px; margin-top: 12px;
padding: 8px 12px; padding: 8px 12px;
@@ -463,26 +588,8 @@ watch(
font-size: 13px; 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 { button:disabled {
opacity: 0.5; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
</style> </style>
@@ -2,8 +2,9 @@
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import type { BrokerNode, ResetPosition, SubscriptionInfo, TopicInfo, TopicRef } from "@/types/mq"; 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 { formatError } from "@/lib/backend/errorUtils";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
export type RocketMqTopicDialogKind = "status" | "route" | "consumers" | "config" | "reset" | "skip"; export type RocketMqTopicDialogKind = "status" | "route" | "consumers" | "config" | "reset" | "skip";
@@ -46,6 +47,7 @@ const emit = defineEmits<{
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const loading = ref(false); const loading = ref(false);
const dialogError = ref<string>(); const dialogError = ref<string>();
@@ -194,8 +196,13 @@ async function loadResetSubscriptions(topic: TopicRef): Promise<SubscriptionInfo
return sortResetSubscriptions(topicSpecific, topic.topic); return sortResetSubscriptions(topicSpecific, topic.topic);
} }
// Reset offset uses force=true; fall back to all cluster groups when the topic has no committed offsets yet. // 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: "" }; 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 { function formatDateTimeLocal(date: Date): string {
@@ -215,7 +222,12 @@ async function loadDialogData() {
} else if (props.dialog === "route") { } else if (props.dialog === "route") {
routeRaw.value = await mqGetTopicRoute(props.connectionId, topic); routeRaw.value = await mqGetTopicRoute(props.connectionId, topic);
} else if (props.dialog === "consumers") { } 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 ?? ""; selectedSubscription.value = subscriptions.value[0]?.name ?? "";
} else if (props.dialog === "reset") { } else if (props.dialog === "reset") {
subscriptions.value = await loadResetSubscriptions(topic); subscriptions.value = await loadResetSubscriptions(topic);
@@ -237,6 +249,7 @@ async function loadDialogData() {
async function saveConfig() { async function saveConfig() {
const topic = topicRef.value; const topic = topicRef.value;
if (!topic || props.readOnly) return; if (!topic || props.readOnly) return;
if (!(await confirmMqWrite(t("mqTopics.actionConfig")))) return;
loading.value = true; loading.value = true;
dialogError.value = undefined; dialogError.value = undefined;
try { try {
@@ -258,6 +271,7 @@ async function saveConfig() {
async function confirmSkip() { async function confirmSkip() {
const topic = topicRef.value; const topic = topicRef.value;
if (!topic || props.readOnly) return; if (!topic || props.readOnly) return;
if (!(await confirmMqWrite(t("mqTopics.actionSkip")))) return;
if (!confirm(t("mqTopics.confirmSkip", { name: topic.topic }))) return; if (!confirm(t("mqTopics.confirmSkip", { name: topic.topic }))) return;
loading.value = true; loading.value = true;
dialogError.value = undefined; dialogError.value = undefined;
@@ -275,6 +289,7 @@ async function confirmSkip() {
async function confirmReset() { async function confirmReset() {
const topic = topicRef.value; const topic = topicRef.value;
if (!topic || props.readOnly || !selectedSubscription.value) return; if (!topic || props.readOnly || !selectedSubscription.value) return;
if (!(await confirmMqWrite(t("mqTopics.actionReset")))) return;
loading.value = true; loading.value = true;
dialogError.value = undefined; dialogError.value = undefined;
try { try {
@@ -496,6 +511,8 @@ watch(
</template> </template>
<style scoped> <style scoped>
@import "../shared/mqPanel.css";
.dialog-overlay { .dialog-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -534,14 +551,6 @@ watch(
font-size: 18px; font-size: 18px;
} }
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
}
.dialog-body { .dialog-body {
padding: 20px; padding: 20px;
overflow-y: auto; overflow-y: auto;
@@ -674,28 +683,6 @@ watch(
border-top: 1px solid var(--color-border); 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, button:disabled,
input:disabled, input:disabled,
select:disabled { select:disabled {
@@ -233,13 +233,4 @@ const { t } = useI18n();
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
.btn-close {
border: none;
background: none;
font-size: 22px;
line-height: 1;
cursor: pointer;
color: var(--color-text-secondary);
}
</style> </style>
+110 -36
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 { .mq-panel-toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -51,9 +54,20 @@
font-size: 12px; font-size: 12px;
} }
/* ---- Buttons (canonical + legacy aliases) ---- */
.mq-btn-primary, .mq-btn-primary,
.mq-btn-secondary, .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; padding: 6px 12px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6); border-radius: var(--dbx-radius-fixed-6);
@@ -61,35 +75,115 @@
color: var(--color-text); color: var(--color-text);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
transition: all 0.2s; font-weight: 500;
} line-height: 1.2;
white-space: nowrap;
.mq-btn-primary { transition:
background: var(--color-primary); background 0.15s ease,
border-color: var(--color-primary); border-color 0.15s ease,
color: var(--color-primary-foreground); 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-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); background: var(--color-hover);
} }
.mq-btn-primary:hover:not(:disabled) { .mq-btn-danger,
filter: brightness(1.05); .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-primary:disabled,
.mq-btn-secondary: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; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.mq-btn-sm.btn-danger, .mq-btn-close,
.mq-btn-sm.mq-btn-danger { .btn-close {
color: var(--color-error); border: none;
border-color: var(--color-error); 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 { .mq-query-mode-tabs {
@@ -154,26 +248,6 @@
background: var(--color-primary-alpha); 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 { .mq-spin {
display: inline-block; display: inline-block;
animation: mq-spin-anim 0.8s linear infinite; animation: mq-spin-anim 0.8s linear infinite;
@@ -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",
});
});
});
@@ -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 };
}
+43
View File
@@ -3991,6 +3991,7 @@ export default {
sourceDataCompare: "Data compare", sourceDataCompare: "Data compare",
sourceExtension: "Extension manager", sourceExtension: "Extension manager",
sourceSidebar: "Object tree", sourceSidebar: "Object tree",
sourceMq: "Message queue",
sourceObjectSource: "Object source editor", sourceObjectSource: "Object source editor",
sourceDataGenerate: "Data generator", sourceDataGenerate: "Data generator",
sourceQueryHistory: "Query history rollback", sourceQueryHistory: "Query history rollback",
@@ -6061,6 +6062,8 @@ export default {
viewTenant: "View tenant", viewTenant: "View tenant",
viewNamespace: "View namespace", viewNamespace: "View namespace",
viewTopic: "View topic", 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", readOnly: "Read-only",
allNamespaces: "All namespaces", allNamespaces: "All namespaces",
newNamespace: "New namespace", newNamespace: "New namespace",
@@ -6352,8 +6355,13 @@ export default {
loading: "Loading...", loading: "Loading...",
refresh: "Refresh", refresh: "Refresh",
refreshing: "Refreshing...", 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", noSubscriptions: "No subscriptions for this topic",
noConsumerGroups: "No consumer groups", noConsumerGroups: "No consumer groups",
noMatches: "No matching consumer groups",
backlogPartialFailed: "Failed to load backlog for {count} topic(s): {error}",
viewDetail: "Details", viewDetail: "Details",
editConfig: "Config", editConfig: "Config",
save: "Save", save: "Save",
@@ -6364,7 +6372,18 @@ export default {
consumerConsumeDetail: "Consumption details", consumerConsumeDetail: "Consumption details",
noOnlineConsumers: "No online consumers", noOnlineConsumers: "No online consumers",
noConsumeDetail: "No consumption details", noConsumeDetail: "No consumption details",
noQueueConsumeProgress: "No queue consumption progress",
operationTopic: "Topic", 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", consumeEnable: "Consume enabled",
consumeBroadcastEnable: "Broadcast consume enabled", consumeBroadcastEnable: "Broadcast consume enabled",
consumeFromMinEnable: "Consume from min offset", consumeFromMinEnable: "Consume from min offset",
@@ -6381,6 +6400,7 @@ export default {
normal: "Normal", normal: "Normal",
fifo: "Ordered", fifo: "Ordered",
system: "System", system: "System",
unknown: "Unknown",
}, },
rocketmqGroupMode: { rocketmqGroupMode: {
clustering: "Cluster", clustering: "Cluster",
@@ -6624,6 +6644,29 @@ export default {
searchHint: "Search for messages first, then click View trace", searchHint: "Search for messages first, then click View trace",
viewTrace: "View trace", viewTrace: "View trace",
detailTitle: "Message trace details", 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: 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", "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",
}, },
+43
View File
@@ -5917,6 +5917,8 @@ export default withEnglishFallback({
viewTenant: "Ver tenant", viewTenant: "Ver tenant",
viewNamespace: "Ver namespace", viewNamespace: "Ver namespace",
viewTopic: "Ver tema", 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", readOnly: "Solo lectura",
allNamespaces: "Todos los namespaces", allNamespaces: "Todos los namespaces",
newNamespace: "Nuevo namespace", newNamespace: "Nuevo namespace",
@@ -6258,7 +6260,12 @@ export default withEnglishFallback({
searchPlaceholder: "Buscar nombre de suscripción", searchPlaceholder: "Buscar nombre de suscripción",
subscribedTopics: "Topics suscritos", subscribedTopics: "Topics suscritos",
refreshing: "Actualizando...", 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", 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", viewDetail: "Detalles",
editConfig: "Configurar", editConfig: "Configurar",
save: "Guardar", save: "Guardar",
@@ -6269,7 +6276,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "Detalles de consumo", consumerConsumeDetail: "Detalles de consumo",
noOnlineConsumers: "No hay consumidores en línea actualmente", noOnlineConsumers: "No hay consumidores en línea actualmente",
noConsumeDetail: "Sin detalles de consumo", noConsumeDetail: "Sin detalles de consumo",
noQueueConsumeProgress: "Sin progreso de consumo por cola",
operationTopic: "Topic", 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", consumeEnable: "Permitir consumo",
consumeBroadcastEnable: "Permitir consumo en difusión", consumeBroadcastEnable: "Permitir consumo en difusión",
consumeFromMinEnable: "Comenzar desde el offset mínimo", consumeFromMinEnable: "Comenzar desde el offset mínimo",
@@ -6284,6 +6302,7 @@ export default withEnglishFallback({
normal: "Normal", normal: "Normal",
fifo: "FIFO", fifo: "FIFO",
system: "Sistema", system: "Sistema",
unknown: "Desconocido",
}, },
rocketmqGroupMode: { rocketmqGroupMode: {
clustering: "Cluster", clustering: "Cluster",
@@ -6522,6 +6541,7 @@ export default withEnglishFallback({
sourceDataCompare: "Comparación de datos", sourceDataCompare: "Comparación de datos",
sourceExtension: "Gestor de extensiones", sourceExtension: "Gestor de extensiones",
sourceSidebar: "Árbol de objetos", sourceSidebar: "Árbol de objetos",
sourceMq: "Cola de mensajes",
sourceObjectSource: "Editor de código fuente de objetos", sourceObjectSource: "Editor de código fuente de objetos",
sourceDataGenerate: "Generador de datos", sourceDataGenerate: "Generador de datos",
sourceQueryHistory: "Reversión del historial de consultas", sourceQueryHistory: "Reversión del historial de consultas",
@@ -6916,5 +6936,28 @@ export default withEnglishFallback({
detailTitle: "Detalles de la traza de consumo", detailTitle: "Detalles de la traza de consumo",
traceTopicRouteMissing: 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.", "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",
}, },
}); });
+43
View File
@@ -5917,6 +5917,8 @@ export default withEnglishFallback({
viewTenant: "Visualizza tenant", viewTenant: "Visualizza tenant",
viewNamespace: "Visualizza namespace", viewNamespace: "Visualizza namespace",
viewTopic: "Visualizza topic", 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", readOnly: "Sola lettura",
allNamespaces: "Tutti i namespace", allNamespaces: "Tutti i namespace",
newNamespace: "Nuovo namespace", newNamespace: "Nuovo namespace",
@@ -6258,7 +6260,12 @@ export default withEnglishFallback({
searchPlaceholder: "Cerca nome sottoscrizione", searchPlaceholder: "Cerca nome sottoscrizione",
subscribedTopics: "Topic sottoscritti", subscribedTopics: "Topic sottoscritti",
refreshing: "Aggiornamento in corso...", 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", noConsumerGroups: "Nessun Consumer Group",
noMatches: "Nessun consumer group corrispondente",
backlogPartialFailed: "Caricamento backlog non riuscito per {count} topic: {error}",
viewDetail: "Dettagli", viewDetail: "Dettagli",
editConfig: "Configurazione", editConfig: "Configurazione",
save: "Salva", save: "Salva",
@@ -6269,7 +6276,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "Dettagli consumo", consumerConsumeDetail: "Dettagli consumo",
noOnlineConsumers: "Nessun consumatore online", noOnlineConsumers: "Nessun consumatore online",
noConsumeDetail: "Nessun dettaglio consumo", noConsumeDetail: "Nessun dettaglio consumo",
noQueueConsumeProgress: "Nessun progresso di consumo per coda",
operationTopic: "Topic", 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", consumeEnable: "Abilita consumo",
consumeBroadcastEnable: "Abilita consumo broadcast", consumeBroadcastEnable: "Abilita consumo broadcast",
consumeFromMinEnable: "Inizia dall'offset minimo", consumeFromMinEnable: "Inizia dall'offset minimo",
@@ -6284,6 +6302,7 @@ export default withEnglishFallback({
normal: "Normale", normal: "Normale",
fifo: "FIFO", fifo: "FIFO",
system: "Sistema", system: "Sistema",
unknown: "Sconosciuto",
}, },
rocketmqGroupMode: { rocketmqGroupMode: {
clustering: "Cluster", clustering: "Cluster",
@@ -6522,6 +6541,7 @@ export default withEnglishFallback({
sourceDataCompare: "Confronto dati", sourceDataCompare: "Confronto dati",
sourceExtension: "Gestione estensioni", sourceExtension: "Gestione estensioni",
sourceSidebar: "Albero oggetti", sourceSidebar: "Albero oggetti",
sourceMq: "Coda di messaggi",
sourceObjectSource: "Editor sorgente oggetto", sourceObjectSource: "Editor sorgente oggetto",
sourceDataGenerate: "Generatore dati", sourceDataGenerate: "Generatore dati",
sourceQueryHistory: "Rollback cronologia query", sourceQueryHistory: "Rollback cronologia query",
@@ -6916,5 +6936,28 @@ export default withEnglishFallback({
detailTitle: "Dettagli traccia consumo", detailTitle: "Dettagli traccia consumo",
traceTopicRouteMissing: 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.", "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",
}, },
}); });
+43
View File
@@ -5972,6 +5972,8 @@ export default withEnglishFallback({
viewTenant: "テナントを表示", viewTenant: "テナントを表示",
viewNamespace: "名前空間を表示", viewNamespace: "名前空間を表示",
viewTopic: "トピックを表示", viewTopic: "トピックを表示",
connectionMissing: "接続が見つかりません。再接続してから再試行してください。",
writeDeniedReadOnly: "現在の接続は読み取り専用モードです。書き込み操作は実行できません。",
readOnly: "読み取り専用", readOnly: "読み取り専用",
allNamespaces: "すべての名前空間", allNamespaces: "すべての名前空間",
newNamespace: "新規名前空間", newNamespace: "新規名前空間",
@@ -6313,7 +6315,12 @@ export default withEnglishFallback({
searchPlaceholder: "サブスクリプション名を検索", searchPlaceholder: "サブスクリプション名を検索",
subscribedTopics: "サブスクライブされた Topic", subscribedTopics: "サブスクライブされた Topic",
refreshing: "更新中...", refreshing: "更新中...",
enriching: "オンライン詳細を読み込み中...",
truncatedHint: "最初の {count} 件のコンシューマグループのみ表示しています(上限)。検索条件を絞ってください。",
enrichFailedHint: "オンラインメンバー/購読 Topic の読み込みに失敗しました({error})。高速一覧のみ表示しています。",
noConsumerGroups: "Consumer Group がありません", noConsumerGroups: "Consumer Group がありません",
noMatches: "一致するコンシューマグループがありません",
backlogPartialFailed: "{count} 件の Topic のバックログ読み込みに失敗しました: {error}",
viewDetail: "詳細", viewDetail: "詳細",
editConfig: "設定", editConfig: "設定",
save: "保存", save: "保存",
@@ -6324,7 +6331,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "消費詳細", consumerConsumeDetail: "消費詳細",
noOnlineConsumers: "現在オンラインのコンシューマはいません", noOnlineConsumers: "現在オンラインのコンシューマはいません",
noConsumeDetail: "消費詳細がありません", noConsumeDetail: "消費詳細がありません",
noQueueConsumeProgress: "キューの消費進捗がありません",
operationTopic: "Topic", operationTopic: "Topic",
consumeDelay: "バックログ",
invalidTopicScope: "トピックのスコープが無効です",
lastConsumeTime: "最終消費時間",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "消費を許可", consumeEnable: "消費を許可",
consumeBroadcastEnable: "ブロードキャスト消費を許可", consumeBroadcastEnable: "ブロードキャスト消費を許可",
consumeFromMinEnable: "最小 offset から開始", consumeFromMinEnable: "最小 offset から開始",
@@ -6339,6 +6357,7 @@ export default withEnglishFallback({
normal: "通常", normal: "通常",
fifo: "FIFO", fifo: "FIFO",
system: "システム", system: "システム",
unknown: "不明",
}, },
rocketmqGroupMode: { rocketmqGroupMode: {
clustering: "クラスタリング", clustering: "クラスタリング",
@@ -6577,6 +6596,7 @@ export default withEnglishFallback({
sourceDataCompare: "データ比較", sourceDataCompare: "データ比較",
sourceExtension: "拡張機能マネージャー", sourceExtension: "拡張機能マネージャー",
sourceSidebar: "オブジェクトツリー", sourceSidebar: "オブジェクトツリー",
sourceMq: "メッセージキュー",
sourceObjectSource: "オブジェクトソースエディター", sourceObjectSource: "オブジェクトソースエディター",
sourceDataGenerate: "データジェネレーター", sourceDataGenerate: "データジェネレーター",
sourceQueryHistory: "クエリ履歴のロールバック", sourceQueryHistory: "クエリ履歴のロールバック",
@@ -6971,5 +6991,28 @@ export default withEnglishFallback({
detailTitle: "消費トレース詳細", detailTitle: "消費トレース詳細",
traceTopicRouteMissing: traceTopicRouteMissing:
"トレース Topic が NameServer にルート情報がありません (CODE: 17)。\n\n一般的な原因:\n1. Broker でメッセージトレースが有効になっていない: broker.conf で traceTopicEnable=true を設定し Broker を再起動\n2. Producer/Consumer でトレースが有効になっていない: setEnableTrace(true)\n3. カスタムトレース Topic を使用している場合は、上で正しいトレース Topic を選択してください", "トレース 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: "生トレース",
}, },
}); });
+43
View File
@@ -3538,6 +3538,7 @@ export default withEnglishFallback({
sourceDataCompare: "데이터 비교", sourceDataCompare: "데이터 비교",
sourceExtension: "확장 관리자", sourceExtension: "확장 관리자",
sourceSidebar: "객체 트리", sourceSidebar: "객체 트리",
sourceMq: "메시지 큐",
sourceObjectSource: "객체 소스 편집기", sourceObjectSource: "객체 소스 편집기",
sourceDataGenerate: "데이터 생성기", sourceDataGenerate: "데이터 생성기",
sourceQueryHistory: "쿼리 기록 롤백", sourceQueryHistory: "쿼리 기록 롤백",
@@ -5585,6 +5586,8 @@ export default withEnglishFallback({
viewTenant: "테넌트 보기", viewTenant: "테넌트 보기",
viewNamespace: "네임스페이스 보기", viewNamespace: "네임스페이스 보기",
viewTopic: "토픽 보기", viewTopic: "토픽 보기",
connectionMissing: "연결을 찾을 수 없습니다. 다시 연결한 후 시도하세요.",
writeDeniedReadOnly: "이 연결은 읽기 전용이며 쓰기 작업을 수행할 수 없습니다.",
readOnly: "읽기 전용", readOnly: "읽기 전용",
allNamespaces: "모든 네임스페이스", allNamespaces: "모든 네임스페이스",
newNamespace: "새 네임스페이스", newNamespace: "새 네임스페이스",
@@ -5876,8 +5879,13 @@ export default withEnglishFallback({
loading: "불러오는 중...", loading: "불러오는 중...",
refresh: "새로고침", refresh: "새로고침",
refreshing: "새로고침 중...", refreshing: "새로고침 중...",
enriching: "온라인 상세 불러오는 중...",
truncatedHint: "처음 {count}개의 컨슈머 그룹만 표시합니다(상한). 검색 범위를 좁혀 주세요.",
enrichFailedHint: "온라인 멤버/구독 토픽 정보를 불러오지 못했습니다({error}). 빠른 목록만 표시합니다.",
noSubscriptions: "이 토픽에 구독이 없습니다", noSubscriptions: "이 토픽에 구독이 없습니다",
noConsumerGroups: "컨슈머 그룹이 없습니다", noConsumerGroups: "컨슈머 그룹이 없습니다",
noMatches: "일치하는 컨슈머 그룹이 없습니다",
backlogPartialFailed: "{count}개 토픽의 백로그를 불러오지 못했습니다: {error}",
viewDetail: "상세", viewDetail: "상세",
editConfig: "구성", editConfig: "구성",
save: "저장", save: "저장",
@@ -5888,7 +5896,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "소비 상세", consumerConsumeDetail: "소비 상세",
noOnlineConsumers: "온라인 컨슈머가 없습니다", noOnlineConsumers: "온라인 컨슈머가 없습니다",
noConsumeDetail: "소비 상세가 없습니다", noConsumeDetail: "소비 상세가 없습니다",
noQueueConsumeProgress: "큐 소비 진행 정보가 없습니다",
operationTopic: "토픽", operationTopic: "토픽",
consumeDelay: "적체",
invalidTopicScope: "잘못된 토픽 범위",
lastConsumeTime: "마지막 소비 시간",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "소비 활성화", consumeEnable: "소비 활성화",
consumeBroadcastEnable: "브로드캐스트 소비 활성화", consumeBroadcastEnable: "브로드캐스트 소비 활성화",
consumeFromMinEnable: "최소 오프셋부터 소비", consumeFromMinEnable: "최소 오프셋부터 소비",
@@ -5905,6 +5924,7 @@ export default withEnglishFallback({
normal: "일반", normal: "일반",
fifo: "순서 보장", fifo: "순서 보장",
system: "시스템", system: "시스템",
unknown: "알 수 없음",
}, },
rocketmqGroupMode: { rocketmqGroupMode: {
clustering: "클러스터", clustering: "클러스터",
@@ -6149,6 +6169,29 @@ export default withEnglishFallback({
detailTitle: "메시지 추적 상세", detailTitle: "메시지 추적 상세",
traceTopicRouteMissing: traceTopicRouteMissing:
"추적 토픽이 NameServer에 라우트 정보가 없습니다 (CODE: 17).\n\n일반적인 원인:\n1. Broker 추적이 비활성화됨: broker.conf에서 traceTopicEnable=true를 설정하고 Broker를 다시 시작\n2. Producer/Consumer 추적이 비활성화됨: setEnableTrace(true) 호출\n3. 사용자 지정 추적 토픽 사용: 위에서 올바른 추적 토픽을 선택하세요", "추적 토픽이 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: { mqRaw: {
title: "원시 API", title: "원시 API",
+43
View File
@@ -5919,6 +5919,8 @@ export default withEnglishFallback({
viewTenant: "Ver locatário", viewTenant: "Ver locatário",
viewNamespace: "Ver namespace", viewNamespace: "Ver namespace",
viewTopic: "Ver tópico", 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", readOnly: "Somente leitura",
allNamespaces: "Todos os namespaces", allNamespaces: "Todos os namespaces",
newNamespace: "Novo namespace", newNamespace: "Novo namespace",
@@ -6260,7 +6262,12 @@ export default withEnglishFallback({
searchPlaceholder: "Pesquisar nome da assinatura", searchPlaceholder: "Pesquisar nome da assinatura",
subscribedTopics: "Tópicos inscritos", subscribedTopics: "Tópicos inscritos",
refreshing: "Atualizando...", 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", noConsumerGroups: "Nenhum Consumer Group",
noMatches: "Nenhum grupo de consumidores correspondente",
backlogPartialFailed: "Falha ao carregar backlog de {count} tópico(s): {error}",
viewDetail: "Detalhes", viewDetail: "Detalhes",
editConfig: "Configuração", editConfig: "Configuração",
save: "Salvar", save: "Salvar",
@@ -6271,7 +6278,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "Detalhes do consumo", consumerConsumeDetail: "Detalhes do consumo",
noOnlineConsumers: "Nenhum consumidor online no momento", noOnlineConsumers: "Nenhum consumidor online no momento",
noConsumeDetail: "Nenhum detalhe de consumo", noConsumeDetail: "Nenhum detalhe de consumo",
noQueueConsumeProgress: "Sem progresso de consumo por fila",
operationTopic: "Topic", 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", consumeEnable: "Permitir consumo",
consumeBroadcastEnable: "Permitir consumo em broadcast", consumeBroadcastEnable: "Permitir consumo em broadcast",
consumeFromMinEnable: "Iniciar do offset mínimo", consumeFromMinEnable: "Iniciar do offset mínimo",
@@ -6286,6 +6304,7 @@ export default withEnglishFallback({
normal: "Normal", normal: "Normal",
fifo: "Ordenado", fifo: "Ordenado",
system: "Sistema", system: "Sistema",
unknown: "Desconhecido",
}, },
rocketmqGroupMode: { rocketmqGroupMode: {
clustering: "Cluster", clustering: "Cluster",
@@ -6524,6 +6543,7 @@ export default withEnglishFallback({
sourceDataCompare: "Comparação de dados", sourceDataCompare: "Comparação de dados",
sourceExtension: "Gerenciador de extensões", sourceExtension: "Gerenciador de extensões",
sourceSidebar: "Árvore de objetos", sourceSidebar: "Árvore de objetos",
sourceMq: "Fila de mensagens",
sourceObjectSource: "Editor de código-fonte de objetos", sourceObjectSource: "Editor de código-fonte de objetos",
sourceDataGenerate: "Gerador de dados", sourceDataGenerate: "Gerador de dados",
sourceQueryHistory: "Reversão do histórico de consultas", sourceQueryHistory: "Reversão do histórico de consultas",
@@ -6918,5 +6938,28 @@ export default withEnglishFallback({
detailTitle: "Detalhes do rastreamento de consumo", detailTitle: "Detalhes do rastreamento de consumo",
traceTopicRouteMissing: 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.", "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",
}, },
}); });
+43
View File
@@ -3990,6 +3990,7 @@ export default withEnglishFallback({
sourceDataCompare: "数据对比", sourceDataCompare: "数据对比",
sourceExtension: "扩展管理", sourceExtension: "扩展管理",
sourceSidebar: "对象树", sourceSidebar: "对象树",
sourceMq: "消息队列",
sourceObjectSource: "对象源码编辑器", sourceObjectSource: "对象源码编辑器",
sourceDataGenerate: "数据生成器", sourceDataGenerate: "数据生成器",
sourceQueryHistory: "查询历史回滚", sourceQueryHistory: "查询历史回滚",
@@ -6058,6 +6059,8 @@ export default withEnglishFallback({
viewTenant: "查看租户", viewTenant: "查看租户",
viewNamespace: "查看命名空间", viewNamespace: "查看命名空间",
viewTopic: "查看主题", viewTopic: "查看主题",
connectionMissing: "未找到连接,请重新连接后再试。",
writeDeniedReadOnly: "当前连接为只读模式,不能执行写操作。",
readOnly: "只读", readOnly: "只读",
allNamespaces: "全部命名空间", allNamespaces: "全部命名空间",
newNamespace: "新建命名空间", newNamespace: "新建命名空间",
@@ -6344,6 +6347,9 @@ export default withEnglishFallback({
createSubscription: "创建订阅", createSubscription: "创建订阅",
selectTopicFirst: "请先选择一个主题", selectTopicFirst: "请先选择一个主题",
searchPlaceholder: "搜索订阅名称", searchPlaceholder: "搜索订阅名称",
enriching: "正在补全在线信息...",
truncatedHint: "当前仅显示前 {count} 个消费组(已达上限),请缩小搜索范围查找其他组",
enrichFailedHint: "在线成员/订阅 Topic 信息加载失败({error}),当前仅显示快速列表",
subscribedTopics: "订阅 Topic", subscribedTopics: "订阅 Topic",
readOnly: "当前连接为只读模式,不能执行写操作", readOnly: "当前连接为只读模式,不能执行写操作",
loading: "加载中...", loading: "加载中...",
@@ -6351,6 +6357,8 @@ export default withEnglishFallback({
refreshing: "刷新中...", refreshing: "刷新中...",
noSubscriptions: "该主题暂无订阅", noSubscriptions: "该主题暂无订阅",
noConsumerGroups: "暂无 Consumer Group", noConsumerGroups: "暂无 Consumer Group",
noMatches: "没有匹配的 Consumer Group",
backlogPartialFailed: "{count} 个 Topic 的堆积信息加载失败:{error}",
viewDetail: "详情", viewDetail: "详情",
editConfig: "配置", editConfig: "配置",
save: "保存", save: "保存",
@@ -6361,7 +6369,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "消费详情", consumerConsumeDetail: "消费详情",
noOnlineConsumers: "当前没有在线消费者", noOnlineConsumers: "当前没有在线消费者",
noConsumeDetail: "暂无消费详情", noConsumeDetail: "暂无消费详情",
noQueueConsumeProgress: "暂无队列消费进度",
operationTopic: "Topic", operationTopic: "Topic",
consumeDelay: "积压",
invalidTopicScope: "无效的 Topic 作用域",
lastConsumeTime: "最后消费时间",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "允许消费", consumeEnable: "允许消费",
consumeBroadcastEnable: "允许广播消费", consumeBroadcastEnable: "允许广播消费",
consumeFromMinEnable: "从最小 offset 开始", consumeFromMinEnable: "从最小 offset 开始",
@@ -6378,6 +6397,7 @@ export default withEnglishFallback({
normal: "普通", normal: "普通",
fifo: "顺序", fifo: "顺序",
system: "系统", system: "系统",
unknown: "未知",
}, },
rocketmqGroupMode: { rocketmqGroupMode: {
clustering: "集群", clustering: "集群",
@@ -6620,6 +6640,29 @@ export default withEnglishFallback({
searchHint: "请先搜索消息,再点击「查看消费轨迹」", searchHint: "请先搜索消息,再点击「查看消费轨迹」",
viewTrace: "查看消费轨迹", viewTrace: "查看消费轨迹",
detailTitle: "消费轨迹详情", 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", traceTopicRouteMissing: "轨迹 Topic 在 NameServer 上没有路由信息(CODE: 17)。\n\n常见原因:\n1. Broker 未开启消息轨迹:在 broker.conf 中设置 traceTopicEnable=true 并重启 Broker\n2. Producer/Consumer 未开启轨迹:setEnableTrace(true)\n3. 使用了自定义轨迹 Topic,请在上方选择正确的轨迹 Topic",
}, },
mqRaw: { mqRaw: {
+43
View File
@@ -5912,6 +5912,8 @@ export default withEnglishFallback({
viewTenant: "檢視租用戶", viewTenant: "檢視租用戶",
viewNamespace: "檢視命名空間", viewNamespace: "檢視命名空間",
viewTopic: "檢視主題", viewTopic: "檢視主題",
connectionMissing: "找不到連線,請重新連線後再試。",
writeDeniedReadOnly: "目前連線為唯讀模式,無法執行寫入操作。",
readOnly: "唯讀", readOnly: "唯讀",
allNamespaces: "全部命名空間", allNamespaces: "全部命名空間",
newNamespace: "新建命名空間", newNamespace: "新建命名空間",
@@ -6253,7 +6255,12 @@ export default withEnglishFallback({
searchPlaceholder: "搜尋訂閱名稱", searchPlaceholder: "搜尋訂閱名稱",
subscribedTopics: "訂閱 Topic", subscribedTopics: "訂閱 Topic",
refreshing: "重新整理中...", refreshing: "重新整理中...",
enriching: "正在載入線上詳情...",
truncatedHint: "目前僅顯示前 {count} 個消費組(已達上限),請縮小搜尋範圍查找其他組",
enrichFailedHint: "線上成員/訂閱 Topic 資訊載入失敗({error}),目前僅顯示快速列表",
noConsumerGroups: "暫無 Consumer Group", noConsumerGroups: "暫無 Consumer Group",
noMatches: "沒有符合的消費組",
backlogPartialFailed: "{count} 個 Topic 的堆積資訊載入失敗:{error}",
viewDetail: "詳情", viewDetail: "詳情",
editConfig: "配置", editConfig: "配置",
save: "儲存", save: "儲存",
@@ -6264,7 +6271,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "消費詳情", consumerConsumeDetail: "消費詳情",
noOnlineConsumers: "當前沒有線上消費者", noOnlineConsumers: "當前沒有線上消費者",
noConsumeDetail: "暫無消費詳情", noConsumeDetail: "暫無消費詳情",
noQueueConsumeProgress: "暫無佇列消費進度",
operationTopic: "Topic", operationTopic: "Topic",
consumeDelay: "積壓",
invalidTopicScope: "無效的主題作用域",
lastConsumeTime: "最後消費時間",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "允許消費", consumeEnable: "允許消費",
consumeBroadcastEnable: "允許廣播消費", consumeBroadcastEnable: "允許廣播消費",
consumeFromMinEnable: "從最小 offset 開始", consumeFromMinEnable: "從最小 offset 開始",
@@ -6279,6 +6297,7 @@ export default withEnglishFallback({
normal: "普通", normal: "普通",
fifo: "順序", fifo: "順序",
system: "系統", system: "系統",
unknown: "未知",
}, },
rocketmqGroupMode: { rocketmqGroupMode: {
clustering: "集群", clustering: "集群",
@@ -6517,6 +6536,7 @@ export default withEnglishFallback({
sourceDataCompare: "資料比對", sourceDataCompare: "資料比對",
sourceExtension: "擴充功能管理", sourceExtension: "擴充功能管理",
sourceSidebar: "物件樹", sourceSidebar: "物件樹",
sourceMq: "訊息佇列",
sourceObjectSource: "物件原始碼編輯器", sourceObjectSource: "物件原始碼編輯器",
sourceDataGenerate: "資料產生器", sourceDataGenerate: "資料產生器",
sourceQueryHistory: "查詢歷史回復", sourceQueryHistory: "查詢歷史回復",
@@ -6909,6 +6929,29 @@ export default withEnglishFallback({
searchHint: "請先搜尋消息,再點擊「檢視消費軌跡」", searchHint: "請先搜尋消息,再點擊「檢視消費軌跡」",
viewTrace: "檢視消費軌跡", viewTrace: "檢視消費軌跡",
detailTitle: "消費軌跡詳情", 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", traceTopicRouteMissing: "軌跡 Topic 在 NameServer 上沒有路由資訊(CODE: 17)。\n\n常見原因:\n1. Broker 未開啟消息軌跡:在 broker.conf 中設置 traceTopicEnable=true 並重新啟動 Broker\n2. Producer/Consumer 未開啟軌跡:setEnableTrace(true)\n3. 使用了自定義軌跡 Topic,請在上方選擇正確的軌跡 Topic",
}, },
}); });
+1
View File
@@ -489,6 +489,7 @@ export const mqListClientConnections = forward("mqListClientConnections");
export const mqListClientChannels = forward("mqListClientChannels"); export const mqListClientChannels = forward("mqListClientChannels");
export const mqCloseClientConnection = forward("mqCloseClientConnection"); export const mqCloseClientConnection = forward("mqCloseClientConnection");
export const mqListSubscriptions = forward("mqListSubscriptions"); export const mqListSubscriptions = forward("mqListSubscriptions");
export const mqEnrichSubscriptions = forward("mqEnrichSubscriptions");
export const mqCreateSubscription = forward("mqCreateSubscription"); export const mqCreateSubscription = forward("mqCreateSubscription");
export const mqDeleteSubscription = forward("mqDeleteSubscription"); export const mqDeleteSubscription = forward("mqDeleteSubscription");
export const mqSkipMessages = forward("mqSkipMessages"); export const mqSkipMessages = forward("mqSkipMessages");
+4
View File
@@ -217,6 +217,10 @@ export async function mqListSubscriptions(connectionId: string, topic: TopicRef)
return post("/api/mq/subscriptions/list", { connectionId, topic }); 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> { export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
return post("/api/mq/subscriptions/create", { connectionId, topic, sub, pos }); return post("/api/mq/subscriptions/create", { connectionId, topic, sub, pos });
} }
+4
View File
@@ -211,6 +211,10 @@ export async function mqListSubscriptions(connectionId: string, topic: TopicRef)
return invoke("mq_list_subscriptions", { connectionId, topic }); 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> { export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
return invoke("mq_create_subscription", { connectionId, topic, sub, pos }); return invoke("mq_create_subscription", { connectionId, topic, sub, pos });
} }
@@ -5,13 +5,15 @@ describe("rocketmqConsumerGroupTypes", () => {
it("resolves group type from consumerGroupType or subType", () => { it("resolves group type from consumerGroupType or subType", () => {
expect(resolveRocketMqConsumerGroupType({ consumerGroupType: "FIFO", subType: "NORMAL" })).toBe("FIFO"); expect(resolveRocketMqConsumerGroupType({ consumerGroupType: "FIFO", subType: "NORMAL" })).toBe("FIFO");
expect(resolveRocketMqConsumerGroupType({ subType: "SYSTEM" })).toBe("SYSTEM"); expect(resolveRocketMqConsumerGroupType({ subType: "SYSTEM" })).toBe("SYSTEM");
expect(resolveRocketMqConsumerGroupType({ consumerGroupType: "UNKNOWN" })).toBe("UNKNOWN");
expect(resolveRocketMqConsumerGroupType({ subType: "CONSUME_PASSIVELY ? CLUSTERING" })).toBe("NORMAL"); expect(resolveRocketMqConsumerGroupType({ subType: "CONSUME_PASSIVELY ? CLUSTERING" })).toBe("NORMAL");
}); });
it("filters by dashboard group types", () => { 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: "NORMAL", subType: "NORMAL" }, filters)).toBe(true);
expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "SYSTEM", subType: "SYSTEM" }, filters)).toBe(false); expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "SYSTEM", subType: "SYSTEM" }, filters)).toBe(false);
expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "UNKNOWN", subType: "UNKNOWN" }, filters)).toBe(true);
}); });
it("resolves message model", () => { it("resolves message model", () => {
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest"; 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", () => { describe("rocketmqTraceUtils", () => {
it("detects RocketMQ trace topic route missing errors", () => { it("detects RocketMQ trace topic route missing errors", () => {
@@ -11,4 +18,75 @@ describe("rocketmqTraceUtils", () => {
const hint = "Enable traceTopicEnable on broker"; 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); 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([]);
});
}); });
@@ -1,19 +1,21 @@
import type { SubscriptionInfo } from "@/types/mq"; 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> = { export const DEFAULT_ROCKETMQ_CONSUMER_GROUP_TYPE_FILTERS: Record<RocketMqConsumerGroupType, boolean> = {
NORMAL: true, NORMAL: true,
FIFO: true, FIFO: true,
SYSTEM: false, SYSTEM: false,
UNKNOWN: true,
}; };
export function resolveRocketMqConsumerGroupType(sub: Pick<SubscriptionInfo, "consumerGroupType" | "subType">): RocketMqConsumerGroupType { export function resolveRocketMqConsumerGroupType(sub: Pick<SubscriptionInfo, "consumerGroupType" | "subType">): RocketMqConsumerGroupType {
const raw = (sub.consumerGroupType ?? sub.subType)?.trim().toUpperCase(); const raw = (sub.consumerGroupType ?? sub.subType)?.trim().toUpperCase();
if (raw === "FIFO" || raw === "ORDER" || raw === "ORDERLY") return "FIFO"; if (raw === "FIFO" || raw === "ORDER" || raw === "ORDERLY") return "FIFO";
if (raw === "SYSTEM") return "SYSTEM"; if (raw === "SYSTEM") return "SYSTEM";
if (raw === "UNKNOWN") return "UNKNOWN";
if (raw === "NORMAL") return "NORMAL"; if (raw === "NORMAL") return "NORMAL";
return "NORMAL"; return "NORMAL";
} }
@@ -1,4 +1,5 @@
import { formatError } from "@/lib/backend/errorUtils"; 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). */ /** RocketMQ CODE 17: trace topic has no route on NameServer (trace not enabled or wrong topic). */
export function isRocketMqTraceTopicRouteMissingError(error: unknown): boolean { export function isRocketMqTraceTopicRouteMissingError(error: unknown): boolean {
@@ -12,3 +13,184 @@ export function formatRocketMqTraceError(error: unknown, traceTopicMissingHint:
} }
return formatError(error); 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);
}
+18
View File
@@ -192,6 +192,8 @@ export interface SubscriptionInfo {
consumerGroupType?: string; consumerGroupType?: string;
/** RocketMQ consumer group message model: CLUSTERING / BROADCASTING. */ /** RocketMQ consumer group message model: CLUSTERING / BROADCASTING. */
messageModel?: string; messageModel?: string;
/** When true, backlog probe failed — do not render msgBacklog as healthy zero. */
backlogUnavailable?: boolean;
} }
export interface RocketMqConsumerGroupConfig { export interface RocketMqConsumerGroupConfig {
@@ -228,9 +230,25 @@ export type ResetPosition = { kind: "earliest" } | { kind: "latest" } | { kind:
export type SkipCount = { kind: "all" } | { kind: "count"; count: number }; 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 { export interface BacklogStats {
msgBacklog: number; msgBacklog: number;
backlogSize: number; backlogSize: number;
/** Optional queue-level progress; omitted or empty when adapter only exposes totals. */
partitions?: PartitionBacklog[];
} }
export interface ClusterInfo { export interface ClusterInfo {
+102 -5
View File
@@ -1292,6 +1292,12 @@ impl AppState {
if wait_for_drain { if wait_for_drain {
self.wait_for_pool_drain(&pool_key).await; 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 routing = self.pool_routing_control();
let previous = loop { let previous = loop {
let mut connections = self.connections.write().await; 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. // candidate never reaches this point, so the existing route keeps its state.
routing.stop_keepalive(&pool_key); routing.stop_keepalive(&pool_key);
activity.insert(pool_key.clone(), PoolActivity::now()); 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)); break Ok(connections.insert(pool_key.clone(), pool));
}; };
let previous = match previous { let previous = match previous {
@@ -1430,9 +1442,21 @@ impl AppState {
self.pool_routing_control().close_pool_with_timeout(pool_key, pool).await; 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 interval_secs = config.keepalive_interval_secs;
let mut target = keepalive_target_from_pool(pool, config); 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 { if interval_secs == 0 {
return; return;
} }
@@ -1445,10 +1469,28 @@ impl AppState {
let key = pool_key.to_string(); let key = pool_key.to_string();
let interval = Duration::from_secs(interval_secs.max(1)); 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 timeout = Duration::from_secs(config.effective_connect_timeout_secs().max(1));
let routing = self.pool_routing_control(); let routing = self.pool_routing_control();
let connections = self.connections.clone(); let connections = self.connections.clone();
let running_queries = self.running_queries.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 { self.task_supervisor.spawn_replace(format!("keepalive:{pool_key}"), move |shutdown| async move {
loop { loop {
tokio::select! { tokio::select! {
@@ -1468,6 +1510,17 @@ impl AppState {
log::warn!("Connection keepalive failed for '{key}': {err}; invalidating pool"); log::warn!("Connection keepalive failed for '{key}': {err}; invalidating pool");
let replace_runtime = let replace_runtime =
err.recovery_decision().is_some_and(RecoveryDecision::replaces_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( if !detach_keepalive_target_if_current(
&routing, &routing,
&connections, &connections,
@@ -1478,6 +1531,11 @@ impl AppState {
.await .await
{ {
log::debug!("Skipping stale keepalive result for replaced pool '{key}'"); 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; break;
} }
@@ -1486,8 +1544,22 @@ impl AppState {
"Connection keepalive timed out for '{key}' after {}s; invalidating pool", "Connection keepalive timed out for '{key}' after {}s; invalidating pool",
timeout.as_secs() 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 { if !detach_keepalive_target_if_current(&routing, &connections, &key, target, false).await {
log::debug!("Skipping stale keepalive timeout for replaced pool '{key}'"); 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; break;
} }
@@ -3369,7 +3441,13 @@ impl AppState {
} }
}; };
if let (Some(config), Some(client)) = (config, client) { 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), Postgres(deadpool_postgres::Pool),
Rqlite(db::rqlite_driver::RqliteClient), Rqlite(db::rqlite_driver::RqliteClient),
Turso(db::turso_driver::TursoClient), Turso(db::turso_driver::TursoClient),
MongoDb { client: mongodb::Client, database: Option<String> }, MongoDb {
client: mongodb::Client,
database: Option<String>,
},
ClickHouse(db::clickhouse_driver::ChClient), ClickHouse(db::clickhouse_driver::ChClient),
SqlServer(Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>), SqlServer(Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
Elasticsearch(db::elasticsearch_driver::EsClient), Elasticsearch(db::elasticsearch_driver::EsClient),
@@ -4093,6 +4174,8 @@ enum KeepaliveTarget {
InfluxDb(db::influxdb_driver::InfluxdbClient), InfluxDb(db::influxdb_driver::InfluxdbClient),
VictoriaMetrics(db::victoriametrics_driver::VictoriaMetricsClient), VictoriaMetrics(db::victoriametrics_driver::VictoriaMetricsClient),
Agent(Arc<db::agent_driver::PooledAgentClient>), Agent(Arc<db::agent_driver::PooledAgentClient>),
#[cfg(feature = "mq-admin")]
MessageQueue(Arc<dyn crate::mq::port::MessageQueueAdmin>),
} }
#[derive(Debug)] #[derive(Debug)]
@@ -4130,6 +4213,10 @@ impl KeepaliveTarget {
match (self, pool) { match (self, pool) {
(Self::Agent(expected), PoolKind::Agent(current)) => Arc::ptr_eq(expected, current), (Self::Agent(expected), PoolKind::Agent(current)) => Arc::ptr_eq(expected, current),
(Self::SqlServer(expected), PoolKind::SqlServer(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(_)) => { (Self::Agent(_), _) | (_, PoolKind::Agent(_)) | (Self::SqlServer(_), _) | (_, PoolKind::SqlServer(_)) => {
false false
} }
@@ -4245,6 +4332,10 @@ async fn ping_keepalive_target(target: &mut KeepaliveTarget, timeout: Duration)
Err(error) => Err(KeepaliveError::Agent(error)), 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 .await
.insert(pool_key.to_string(), super::PoolActivity::idle_for(std::time::Duration::from_secs(10))); .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()); 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; tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+8 -5
View File
@@ -63,12 +63,13 @@ impl KafkaAdmin {
let mut client = AgentDriverClient::spawn(launch).await?; let mut client = AgentDriverClient::spawn(launch).await?;
// Handshake // 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 // Build the connection params from MqAdminConfig
let conn_params = build_connection_params(&cfg); let conn_params = build_connection_params(&cfg);
let connect_params = serde_json::json!({ "connection": conn_params }); 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)); log::info!("Kafka admin connected via agent (bootstrap servers: {})", bootstrap_servers(&cfg));
@@ -82,7 +83,7 @@ impl KafkaAdmin {
params: serde_json::Value, params: serde_json::Value,
) -> Result<T, String> { ) -> Result<T, String> {
let mut client = self.client.lock().await; 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. /// The Kafka agent bounds message browsing with its configured request timeout.
@@ -538,8 +539,7 @@ impl MessageQueueAdmin for KafkaAdmin {
) )
.await?; .await?;
let total_lag = result.get("totalLag").and_then(|v| v.as_i64()).unwrap_or(0); Ok(backlog_stats_from_consumer_lag(&result))
Ok(BacklogStats { msg_backlog: total_lag, backlog_size: 0 })
} }
async fn get_cluster_info(&self) -> Result<ClusterInfo, String> { 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, "skip_verify": cfg.tls_skip_verify,
}, },
"properties": properties, "properties": properties,
"request_timeout_ms": cfg.request_timeout_ms(),
}) })
} }
@@ -802,6 +803,8 @@ mod tests {
token_signing: None, token_signing: None,
connect_override: None, connect_override: None,
management_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, extra,
} }
} }
+11 -6
View File
@@ -16,8 +16,6 @@ use crate::mq::port::MessageQueueAdmin;
use crate::mq::types::*; use crate::mq::types::*;
use crate::mq::util::truncate; 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. /// How long to wait for the initial version-probe during construction.
const PROBE_TIMEOUT_SECS: u64 = 8; const PROBE_TIMEOUT_SECS: u64 = 8;
const DETAIL_REQUEST_CONCURRENCY: usize = 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. /// the API profile. Probe failure degrades gracefully to the 3.1.x baseline.
pub async fn new(cfg: MqAdminConfig) -> Result<Self, String> { pub async fn new(cfg: MqAdminConfig) -> Result<Self, String> {
let base = normalize_base(&cfg.admin_url); 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 auth = cfg.auth.clone();
let token_cache = TokenCache::default(); let token_cache = TokenCache::default();
@@ -807,9 +810,9 @@ fn build_http_client(
tls_skip_verify: bool, tls_skip_verify: bool,
connect_override: Option<&MqConnectOverride>, connect_override: Option<&MqConnectOverride>,
admin_url: &str, admin_url: &str,
request_timeout: std::time::Duration,
) -> Result<reqwest::Client, String> { ) -> Result<reqwest::Client, String> {
let mut builder = crate::db::http_client_builder(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS)) let mut builder = crate::db::http_client_builder(request_timeout).timeout(request_timeout);
.timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS));
if tls_skip_verify { if tls_skip_verify {
builder = builder.danger_accept_invalid_certs(true); 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)), 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)] #[cfg(test)]
@@ -1151,6 +1154,8 @@ mod tests {
token_signing: None, token_signing: None,
connect_override: None, connect_override: None,
management_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, extra: serde_json::Value::Null,
}) })
.await .await
+8 -4
View File
@@ -64,12 +64,13 @@ impl RabbitMqAdmin {
let mut client = AgentDriverClient::spawn(launch).await?; let mut client = AgentDriverClient::spawn(launch).await?;
// Handshake // 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 // Build the connection params from MqAdminConfig
let conn_params = build_connection_params(&cfg)?; let conn_params = build_connection_params(&cfg)?;
let connect_params = serde_json::json!({ "connection": conn_params }); 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)); log::info!("RabbitMQ admin connected via agent (addresses: {})", addresses(&cfg));
@@ -83,7 +84,7 @@ impl RabbitMqAdmin {
params: serde_json::Value, params: serde_json::Value,
) -> Result<T, String> { ) -> Result<T, String> {
let mut client = self.client.lock().await; 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. /// 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 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); 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> { 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, "virtual_host": virtual_host,
"tls_skip_verify": cfg.tls_skip_verify, "tls_skip_verify": cfg.tls_skip_verify,
"properties": properties, "properties": properties,
"request_timeout_ms": cfg.request_timeout_ms(),
}); });
if let Some(username) = username.filter(|value| !value.trim().is_empty()) { if let Some(username) = username.filter(|value| !value.trim().is_empty()) {
params["username"] = serde_json::json!(username); params["username"] = serde_json::json!(username);
@@ -1027,6 +1029,8 @@ mod tests {
token_signing: None, token_signing: None,
connect_override: None, connect_override: None,
management_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, extra,
} }
} }
+158 -32
View File
@@ -9,11 +9,15 @@
//! 3. Delegate all `MessageQueueAdmin` trait methods to JSON-RPC calls //! 3. Delegate all `MessageQueueAdmin` trait methods to JSON-RPC calls
use std::collections::HashMap; use std::collections::HashMap;
use std::net::ToSocketAddrs;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use tokio::net::TcpStream;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::time::timeout;
use crate::db::agent_driver::{AgentDriverClient, AgentLaunchSpec}; use crate::db::agent_driver::{AgentDriverClient, AgentLaunchSpec};
use crate::mq::auth::MqAuth; use crate::mq::auth::MqAuth;
@@ -94,16 +98,20 @@ fn cluster_info_from_agent_result(result: &serde_json::Value) -> MqClusterInfo {
impl RocketMqAdmin { impl RocketMqAdmin {
/// Spawn the RocketMQ Java agent, perform handshake, and connect. /// 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> { pub async fn new(cfg: MqAdminConfig, launch: AgentLaunchSpec) -> Result<Self, String> {
let mut client = AgentDriverClient::spawn(launch).await?; let mut client = AgentDriverClient::spawn(launch).await?;
// Handshake // 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 // Build the connection params from MqAdminConfig
let conn_params = build_connection_params(&cfg); let conn_params = build_connection_params(&cfg);
let connect_params = serde_json::json!({ "connection": conn_params }); 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)); log::info!("RocketMQ admin connected via agent (namesrv: {})", namesrv_addr(&cfg));
@@ -117,7 +125,7 @@ impl RocketMqAdmin {
params: serde_json::Value, params: serde_json::Value,
) -> Result<T, String> { ) -> Result<T, String> {
let mut client = self.client.lock().await; 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. /// 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> { async fn list_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String> {
if topic.topic.is_empty() { 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 let result: serde_json::Value = self
.call( .call(
"mq_list_consumer_groups", "mq_list_consumer_groups",
serde_json::json!({ serde_json::json!({
"limit": 500, "limit": 500,
"offset": 0, "offset": 0,
"enrich": true, "enrich": false,
}), }),
) )
.await?; .await?;
@@ -369,8 +378,7 @@ impl MessageQueueAdmin for RocketMqAdmin {
return Ok(groups.iter().map(rocketmq_subscription_from_group).collect()); return Ok(groups.iter().map(rocketmq_subscription_from_group).collect());
} }
// Agent uses queryTopicConsumeByWho (Dashboard queryTopicConsumerInfo); include all // Topic path: skip list enrich; batch lag in one agent RPC (no N+1).
// returned groups even when consumers are offline and offsets are zero.
let result: serde_json::Value = self let result: serde_json::Value = self
.call( .call(
"mq_list_consumer_groups", "mq_list_consumer_groups",
@@ -378,34 +386,30 @@ impl MessageQueueAdmin for RocketMqAdmin {
"topic": topic.topic, "topic": topic.topic,
"limit": 200, "limit": 200,
"offset": 0, "offset": 0,
"enrich": true, "enrich": false,
"includeLag": true,
}), }),
) )
.await?; .await?;
let groups = result.get("groups").and_then(|v| v.as_array()).cloned().unwrap_or_default(); 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(); async fn enrich_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String> {
for group in groups { // Cluster-wide second pass fills memberCount/topics after the fast list paint.
let group_id = group.get("groupId").and_then(|v| v.as_str()).unwrap_or_default(); let mut params = serde_json::json!({
if group_id.is_empty() { "limit": 500,
continue; "offset": 0,
} "enrich": true,
let mut sub = rocketmq_subscription_from_group(&group); });
if let Ok(lag) = self if !topic.topic.is_empty() {
.call::<serde_json::Value>( params["topic"] = serde_json::json!(topic.topic);
"mq_get_consumer_lag", params["limit"] = serde_json::json!(200);
serde_json::json!({ params["includeLag"] = serde_json::json!(true);
"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);
} }
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> { async fn create_subscription(&self, _topic: &TopicRef, _sub: &str, _pos: ResetPosition) -> Result<(), String> {
@@ -673,8 +677,12 @@ impl MessageQueueAdmin for RocketMqAdmin {
) )
.await?; .await?;
let total_lag = result.get("totalLag").and_then(|v| v.as_i64()).unwrap_or(0); // Agent omits totalLag on probe failure; never coerce that to healthy zero backlog.
Ok(BacklogStats { msg_backlog: total_lag, backlog_size: 0 }) 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> { 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, "access_key": access_key,
"secret_key": secret_key, "secret_key": secret_key,
"tls_skip_verify": cfg.tls_skip_verify, "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, online_members: None,
consumer_group_type: None, consumer_group_type: None,
message_model: None, message_model: None,
backlog_unavailable: None,
}) })
} }
fn rocketmq_subscription_from_group(group: &serde_json::Value) -> SubscriptionInfo { 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_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 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 online_members = group.get("memberCount").and_then(|v| v.as_u64()).map(|v| v as u32);
let topics = group let topics = group
@@ -914,10 +925,13 @@ fn rocketmq_subscription_from_group(group: &serde_json::Value) -> SubscriptionIn
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>()) .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>())
.unwrap_or_default(); .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 { SubscriptionInfo {
name: group_id.to_string(), name: group_id.to_string(),
sub_type: group_type.clone(), 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_rate_out: 0.0,
msg_throughput_out: 0.0, msg_throughput_out: 0.0,
consumers: Vec::new(), consumers: Vec::new(),
@@ -925,9 +939,92 @@ fn rocketmq_subscription_from_group(group: &serde_json::Value) -> SubscriptionIn
online_members, online_members,
consumer_group_type: Some(group_type), consumer_group_type: Some(group_type),
message_model, 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 { fn peeked_message_from_agent_json(idx: usize, message: &serde_json::Value) -> PeekedMessage {
let mut properties = HashMap::new(); let mut properties = HashMap::new();
if let Some(partition) = message.get("partition").and_then(|v| v.as_i64()) { if let Some(partition) = message.get("partition").and_then(|v| v.as_i64()) {
@@ -975,10 +1072,18 @@ mod tests {
token_signing: None, token_signing: None,
connect_override: None, connect_override: None,
management_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, 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] #[test]
fn connection_params_map_namesrv_and_acl_credentials() { fn connection_params_map_namesrv_and_acl_credentials() {
let cfg = rocketmq_config( 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("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("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("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] #[test]
@@ -1031,6 +1137,26 @@ mod tests {
assert_eq!(sub.msg_backlog, 7); 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] #[test]
fn rocketmq_subscription_from_group_maps_offline_topic_consumer() { fn rocketmq_subscription_from_group_maps_offline_topic_consumer() {
let group = serde_json::json!({ let group = serde_json::json!({
+58
View File
@@ -4,12 +4,19 @@
//! `ConnectionConfig` rather than adding top-level fields, keeping the 50+ //! `ConnectionConfig` rather than adding top-level fields, keeping the 50+
//! database-type connection model untouched. //! database-type connection model untouched.
use std::time::Duration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::models::connection::ConnectionConfig; use crate::models::connection::ConnectionConfig;
use crate::mq::auth::MqAuth; use crate::mq::auth::MqAuth;
use crate::mq::types::{MqSystemKind, MqTokenSigningConfig}; 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. /// Runtime TCP endpoint override for an MQ transport.
/// ///
/// The logical broker endpoint remains unchanged so TLS hostname verification, /// 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. /// its Management HTTP API listens on an independently configured port.
#[serde(skip)] #[serde(skip)]
pub management_connect_override: Option<MqConnectOverride>, 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). /// System-specific extension fields (e.g. Kafka bootstrap servers).
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")] #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub extra: serde_json::Value, pub extra: serde_json::Value,
@@ -76,9 +89,35 @@ impl MqAdminConfig {
{ {
return Err("Message queue admin URL is empty".to_string()); 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) 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 { pub fn token_signing_configured(&self) -> bool {
self.token_signing.as_ref().is_some_and(MqTokenSigningConfig::is_configured) 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.system_kind, MqSystemKind::RocketMq);
assert_eq!(mqc.admin_url, ""); 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.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] #[test]
+37 -6
View File
@@ -45,10 +45,11 @@ pub use crate::mq::types::*;
/// Each connection has its own build-lock so concurrent first-use requests for /// 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 /// the same connection block until the first builder finishes, rather than both
/// racing to construct an adapter. /// 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 { pub struct MqAdminRegistry {
instances: RwLock<HashMap<String, CachedMqAdmin>>, instances: Arc<RwLock<HashMap<String, CachedMqAdmin>>>,
build_locks: RwLock<HashMap<String, Arc<Mutex<()>>>>, build_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
} }
struct CachedMqAdmin { struct CachedMqAdmin {
@@ -65,7 +66,7 @@ pub struct MqBuildResult {
impl MqAdminRegistry { impl MqAdminRegistry {
pub fn new() -> Self { 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 /// 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. // Config changed — drop the stale adapter so its agent process is released.
self.instances.write().await.remove(connection_id); 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 self.instances
.write() .write()
.await .await
@@ -116,6 +117,17 @@ impl MqAdminRegistry {
Ok(MqBuildResult { adapter, was_cached: false }) 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). /// Drop the cached adapter for a connection (called on disconnect).
pub async fn drop_connection(&self, connection_id: &str) { pub async fn drop_connection(&self, connection_id: &str) {
self.instances.write().await.remove(connection_id); self.instances.write().await.remove(connection_id);
@@ -144,7 +156,26 @@ impl MqAdminRegistry {
mqc: MqAdminConfig, mqc: MqAdminConfig,
agent_launch: Option<AgentLaunchSpec>, agent_launch: Option<AgentLaunchSpec>,
) -> Result<Arc<dyn MessageQueueAdmin>, String> { ) -> 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())),
} }
} }
+7
View File
@@ -149,6 +149,13 @@ pub trait MessageQueueAdmin: Send + Sync {
// ---- Subscriptions ---- // ---- Subscriptions ----
async fn list_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String>; 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 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 delete_subscription(&self, topic: &TopicRef, sub: &str, force: bool) -> Result<(), String>;
async fn skip_messages(&self, topic: &TopicRef, sub: &str, count: SkipCount) -> Result<(), String>; async fn skip_messages(&self, topic: &TopicRef, sub: &str, count: SkipCount) -> Result<(), String>;
+34
View File
@@ -390,6 +390,15 @@ pub async fn mq_list_subscriptions_core(
adapter.list_subscriptions(&topic).await 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( pub async fn mq_create_subscription_core(
state: &AppState, state: &AppState,
conn_id: &str, conn_id: &str,
@@ -842,6 +851,8 @@ async fn ensure_connection_writable(state: &AppState, conn_id: &str, operation:
config.name, 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(()) Ok(())
} }
@@ -939,6 +950,29 @@ mod tests {
(state, dir) (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] #[tokio::test]
async fn mutating_service_calls_block_read_only_connections_before_adapter_build() { async fn mutating_service_calls_block_read_only_connections_before_adapter_build() {
let (state, dir) = test_state_with(mq_connection(true)).await; let (state, dir) = test_state_with(mq_connection(true)).await;
+116
View File
@@ -415,6 +415,9 @@ pub struct SubscriptionInfo {
/// RocketMQ: CLUSTERING / BROADCASTING. /// RocketMQ: CLUSTERING / BROADCASTING.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub message_model: Option<String>, 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -465,11 +468,76 @@ pub enum SkipCount {
Count { count: u32 }, 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BacklogStats { pub struct BacklogStats {
pub msg_backlog: i64, pub msg_backlog: i64,
pub backlog_size: 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -1250,4 +1318,52 @@ mod tests {
assert!(!legacy.incomplete); assert!(!legacy.incomplete);
assert!(legacy.messages.is_empty()); 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());
}
} }
+1
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/query-by-topic", post(routes::mq::query_messages_by_topic))
.route("/mq/messages/trace", post(routes::mq::query_message_trace)) .route("/mq/messages/trace", post(routes::mq::query_message_trace))
.route("/mq/subscriptions/list", post(routes::mq::list_subscriptions)) .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/create", post(routes::mq::create_subscription))
.route("/mq/subscriptions/delete", post(routes::mq::delete_subscription)) .route("/mq/subscriptions/delete", post(routes::mq::delete_subscription))
.route("/mq/subscriptions/skip-messages", post(routes::mq::skip_messages)) .route("/mq/subscriptions/skip-messages", post(routes::mq::skip_messages))
+12
View File
@@ -751,6 +751,18 @@ pub async fn list_subscriptions(
Ok(Json(result)) 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( pub async fn create_subscription(
State(state): State<Arc<WebState>>, State(state): State<Arc<WebState>>,
headers: HeaderMap, headers: HeaderMap,
+9
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 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] #[tauri::command]
pub async fn mq_create_subscription( pub async fn mq_create_subscription(
state: State<'_, Arc<AppState>>, state: State<'_, Arc<AppState>>,
+2
View File
@@ -1873,6 +1873,8 @@ pub fn run() {
#[cfg(feature = "mq-admin")] #[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_list_subscriptions, commands::mq_cmd::mq_list_subscriptions,
#[cfg(feature = "mq-admin")] #[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_enrich_subscriptions,
#[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_create_subscription, commands::mq_cmd::mq_create_subscription,
#[cfg(feature = "mq-admin")] #[cfg(feature = "mq-admin")]
commands::mq_cmd::mq_delete_subscription, commands::mq_cmd::mq_delete_subscription,