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:
parent
5c281b1653
commit
2f9ca313cb
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.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.apache.rocketmq.client.exception.MQBrokerException;
|
||||
import org.apache.rocketmq.client.exception.MQClientException;
|
||||
import org.apache.rocketmq.common.TopicConfig;
|
||||
import org.apache.rocketmq.common.MixAll;
|
||||
import org.apache.rocketmq.common.message.Message;
|
||||
import org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
|
||||
import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
|
||||
import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
|
||||
import org.apache.rocketmq.remoting.protocol.admin.TopicStatsTable;
|
||||
import org.apache.rocketmq.remoting.protocol.admin.TopicOffset;
|
||||
import org.apache.rocketmq.remoting.protocol.body.ProducerInfo;
|
||||
|
|
@ -23,6 +27,7 @@ import com.google.gson.JsonObject;
|
|||
import com.google.gson.JsonParser;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
|
|
@ -129,6 +134,21 @@ class RocketMqAgentTest {
|
|||
assertEquals("127.0.0.1:10911", RocketMqAgent.remapBrokerAddrForClient("172.18.0.3:10911", conn));
|
||||
assertEquals("127.0.0.1:10911", RocketMqAgent.remapBrokerAddrForClient("10.0.0.5:10911", conn));
|
||||
assertEquals("broker.example.com:10911", RocketMqAgent.remapBrokerAddrForClient("broker.example.com:10911", conn));
|
||||
// 172.15/172.32 are not RFC1918 — leave reachable public/non-private hosts alone.
|
||||
assertEquals("172.15.0.1:10911", RocketMqAgent.remapBrokerAddrForClient("172.15.0.1:10911", conn));
|
||||
assertEquals("172.32.0.1:10911", RocketMqAgent.remapBrokerAddrForClient("172.32.0.1:10911", conn));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRfc1918PrivateIpv4MatchesOnlyPrivateRanges() {
|
||||
assertTrue(RocketMqAgent.isRfc1918PrivateIpv4("10.0.0.1"));
|
||||
assertTrue(RocketMqAgent.isRfc1918PrivateIpv4("172.16.0.1"));
|
||||
assertTrue(RocketMqAgent.isRfc1918PrivateIpv4("172.31.255.255"));
|
||||
assertTrue(RocketMqAgent.isRfc1918PrivateIpv4("192.168.1.1"));
|
||||
assertFalse(RocketMqAgent.isRfc1918PrivateIpv4("172.15.0.1"));
|
||||
assertFalse(RocketMqAgent.isRfc1918PrivateIpv4("172.32.0.1"));
|
||||
assertFalse(RocketMqAgent.isRfc1918PrivateIpv4("8.8.8.8"));
|
||||
assertFalse(RocketMqAgent.isRfc1918PrivateIpv4("broker.example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -255,6 +275,8 @@ class RocketMqAgentTest {
|
|||
SubscriptionGroupConfig normal = new SubscriptionGroupConfig();
|
||||
normal.setConsumeMessageOrderly(false);
|
||||
assertEquals("NORMAL", RocketMqAgent.classifyConsumerGroupType("MyGroup", normal));
|
||||
// Missing dump must not look like NORMAL (FIFO groups would be mislabeled).
|
||||
assertEquals("UNKNOWN", RocketMqAgent.classifyConsumerGroupType("MissingConfigGroup", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -291,13 +313,94 @@ class RocketMqAgentTest {
|
|||
|
||||
assertTrue(maxActive.get() > 1);
|
||||
for (Map<String, Object> row : rows) {
|
||||
// Successful empty connection probe → genuine offline 0.
|
||||
assertEquals(0, row.get("memberCount"));
|
||||
assertEquals(List.of(), row.get("topics"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void enrichConsumerGroupRowsReturnsDefaultsWhenBudgetExpires() {
|
||||
void requireConsumerConnectionProbeResultFailsClosedWhenNoMasterAnswers() throws Exception {
|
||||
assertThrows(
|
||||
MQClientException.class,
|
||||
() -> RocketMqAgent.requireConsumerConnectionProbeResult(
|
||||
1, 0, 0, 0, null, new IllegalStateException("timeout"), "g1")
|
||||
);
|
||||
ConsumerConnection offline = new ConsumerConnection();
|
||||
assertSame(
|
||||
offline,
|
||||
RocketMqAgent.requireConsumerConnectionProbeResult(1, 1, 0, 0, offline, null, "g1")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireConsumerConnectionProbeResultFailsClosedOnRemapped206WithUnreachableFallback() {
|
||||
Exception cause = new IllegalStateException("fallback unreachable");
|
||||
MQClientException err = assertThrows(
|
||||
MQClientException.class,
|
||||
() -> RocketMqAgent.requireConsumerConnectionProbeResult(
|
||||
1, 1, 1, 0, new ConsumerConnection(), cause, "g1")
|
||||
);
|
||||
assertTrue(err.getMessage().contains("Docker remap address collision"));
|
||||
assertSame(cause, err.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireConsumerConnectionProbeResultFailsClosedOnPartialRemappedOffline() {
|
||||
Exception cause = new IllegalStateException("broker-b timeout");
|
||||
MQClientException err = assertThrows(
|
||||
MQClientException.class,
|
||||
() -> RocketMqAgent.requireConsumerConnectionProbeResult(
|
||||
2, 1, 0, 0, new ConsumerConnection(), cause, "g1")
|
||||
);
|
||||
assertTrue(err.getMessage().contains("partial remapped probe"));
|
||||
assertSame(cause, err.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireConsumerConnectionProbeResultAllowsFullOfflineCoverage() throws Exception {
|
||||
ConsumerConnection offline = new ConsumerConnection();
|
||||
assertSame(
|
||||
offline,
|
||||
RocketMqAgent.requireConsumerConnectionProbeResult(2, 2, 0, 0, offline, null, "g1")
|
||||
);
|
||||
assertSame(
|
||||
offline,
|
||||
RocketMqAgent.requireConsumerConnectionProbeResult(1, 1, 1, 1, offline, null, "g1")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isConsumerGroupNotOnlineDetectsBrokerCode206() {
|
||||
assertTrue(RocketMqAgent.isConsumerGroupNotOnline(
|
||||
new MQBrokerException(206, "the consumer group[g1] not online")));
|
||||
assertTrue(RocketMqAgent.isConsumerGroupNotOnline(
|
||||
new MQClientException(206, "the consumer group[g1] not online")));
|
||||
assertTrue(RocketMqAgent.isConsumerGroupNotOnline(
|
||||
new RuntimeException(new MQBrokerException(206, "not online"))));
|
||||
assertFalse(RocketMqAgent.isConsumerGroupNotOnline(
|
||||
new MQBrokerException(1, "system error")));
|
||||
assertFalse(RocketMqAgent.isConsumerGroupNotOnline(new IllegalStateException("timeout")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void enrichConsumerGroupRowsOmitsMemberCountWhenConnectionProbeFails() {
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("groupId", "unreachable-group");
|
||||
rows.add(row);
|
||||
|
||||
RocketMqAgent.enrichConsumerGroupRows(rows, groupId -> {
|
||||
throw new MQClientException("Failed to examine consumer connection on all masters", null);
|
||||
}, 1, 1_000);
|
||||
|
||||
// All-master connection probe failure must not look like offline 0.
|
||||
assertFalse(row.containsKey("memberCount"));
|
||||
assertEquals(List.of(), row.get("topics"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void enrichConsumerGroupRowsOmitsMemberCountWhenBudgetExpires() {
|
||||
List<Map<String, Object>> rows = IntStream.range(0, 4)
|
||||
.mapToObj(index -> {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
|
|
@ -316,7 +419,8 @@ class RocketMqAgentTest {
|
|||
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
|
||||
assertTrue(elapsedMs < 1_000, "enrichment exceeded its response budget: " + elapsedMs + "ms");
|
||||
for (Map<String, Object> row : rows) {
|
||||
assertEquals(0, row.get("memberCount"));
|
||||
// Cancelled probes must not look offline (0); UI shows '-' when memberCount is absent.
|
||||
assertFalse(row.containsKey("memberCount"));
|
||||
assertEquals(List.of(), row.get("topics"));
|
||||
}
|
||||
}
|
||||
|
|
@ -575,4 +679,230 @@ class RocketMqAgentTest {
|
|||
assertTrue(RocketMqAgent.connectionMatches(base, same));
|
||||
assertFalse(RocketMqAgent.connectionMatches(base, differentNamesrv));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildConsumerLagResultIncludesBrokerClientAndTimestamp() {
|
||||
MessageQueue mq0 = new MessageQueue("TX_TOPIC", "broker-a", 0);
|
||||
MessageQueue mq1 = new MessageQueue("TX_TOPIC", "broker-a", 1);
|
||||
OffsetWrapper offset0 = new OffsetWrapper();
|
||||
offset0.setBrokerOffset(100);
|
||||
offset0.setConsumerOffset(90);
|
||||
offset0.setLastTimestamp(1_725_000_000_000L);
|
||||
OffsetWrapper offset1 = new OffsetWrapper();
|
||||
offset1.setBrokerOffset(50);
|
||||
offset1.setConsumerOffset(50);
|
||||
offset1.setLastTimestamp(0L);
|
||||
|
||||
ConsumeStats stats = new ConsumeStats();
|
||||
stats.getOffsetTable().put(mq0, offset0);
|
||||
stats.getOffsetTable().put(mq1, offset1);
|
||||
|
||||
Map<MessageQueue, String> clients = new HashMap<>();
|
||||
clients.put(mq0, "172.18.2.212@7#1");
|
||||
|
||||
Map<String, Object> result = RocketMqAgent.buildConsumerLagResult(stats, clients);
|
||||
assertEquals(10L, result.get("totalLag"));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> partitions = (List<Map<String, Object>>) result.get("partitions");
|
||||
assertEquals(2, partitions.size());
|
||||
assertEquals("broker-a", partitions.get(0).get("brokerName"));
|
||||
assertEquals(0, partitions.get(0).get("partition"));
|
||||
assertEquals(90L, partitions.get(0).get("currentOffset"));
|
||||
assertEquals(100L, partitions.get(0).get("endOffset"));
|
||||
assertEquals(10L, partitions.get(0).get("lag"));
|
||||
assertEquals(1_725_000_000_000L, partitions.get(0).get("lastTimestamp"));
|
||||
assertEquals("172.18.2.212@7#1", partitions.get(0).get("consumerClient"));
|
||||
// Missing client mapping still returns offsets with empty consumerClient.
|
||||
assertEquals("", partitions.get(1).get("consumerClient"));
|
||||
assertEquals(0L, partitions.get(1).get("lastTimestamp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildConsumerLagResultHandlesNullStatsAndEmptyClientMap() {
|
||||
Map<String, Object> empty = RocketMqAgent.buildConsumerLagResult(null, null);
|
||||
assertEquals(0L, empty.get("totalLag"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> partitions = (List<Map<String, Object>>) empty.get("partitions");
|
||||
assertTrue(partitions.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeSubscriptionGroupConfigsPrefersFifoAndKeepsLaterOnlyGroups() {
|
||||
Map<String, SubscriptionGroupConfig> merged = new LinkedHashMap<>();
|
||||
|
||||
SubscriptionGroupConfig normalA = new SubscriptionGroupConfig();
|
||||
normalA.setGroupName("group-a");
|
||||
normalA.setConsumeMessageOrderly(false);
|
||||
|
||||
SubscriptionGroupConfig fifoA = new SubscriptionGroupConfig();
|
||||
fifoA.setGroupName("group-a");
|
||||
fifoA.setConsumeMessageOrderly(true);
|
||||
|
||||
SubscriptionGroupConfig onlyOnLater = new SubscriptionGroupConfig();
|
||||
onlyOnLater.setGroupName("group-b");
|
||||
onlyOnLater.setConsumeMessageOrderly(true);
|
||||
|
||||
// Intermediate broker repeats group-a as NORMAL (size would not grow), then later adds FIFO-only group-b.
|
||||
RocketMqAgent.mergeSubscriptionGroupConfigs(merged, Map.of("group-a", normalA));
|
||||
RocketMqAgent.mergeSubscriptionGroupConfigs(merged, Map.of("group-a", normalA));
|
||||
RocketMqAgent.mergeSubscriptionGroupConfigs(
|
||||
merged,
|
||||
Map.of("group-a", fifoA, "group-b", onlyOnLater)
|
||||
);
|
||||
|
||||
assertEquals(2, merged.size());
|
||||
assertTrue(merged.get("group-a").isConsumeMessageOrderly());
|
||||
assertTrue(merged.get("group-b").isConsumeMessageOrderly());
|
||||
assertEquals("FIFO", RocketMqAgent.classifyConsumerGroupType("group-a", merged.get("group-a")));
|
||||
assertEquals("FIFO", RocketMqAgent.classifyConsumerGroupType("group-b", merged.get("group-b")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeSubscriptionGroupConfigsDoesNotDowngradeFifoToNormal() {
|
||||
Map<String, SubscriptionGroupConfig> merged = new LinkedHashMap<>();
|
||||
SubscriptionGroupConfig fifo = new SubscriptionGroupConfig();
|
||||
fifo.setGroupName("ordered");
|
||||
fifo.setConsumeMessageOrderly(true);
|
||||
SubscriptionGroupConfig normal = new SubscriptionGroupConfig();
|
||||
normal.setGroupName("ordered");
|
||||
normal.setConsumeMessageOrderly(false);
|
||||
|
||||
RocketMqAgent.mergeSubscriptionGroupConfigs(merged, Map.of("ordered", fifo));
|
||||
RocketMqAgent.mergeSubscriptionGroupConfigs(merged, Map.of("ordered", normal));
|
||||
|
||||
assertTrue(merged.get("ordered").isConsumeMessageOrderly());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureConsumeStatsProbeSucceededRejectsAllBrokerFailures() {
|
||||
MQClientException noMasters = assertThrows(
|
||||
MQClientException.class,
|
||||
() -> RocketMqAgent.ensureConsumeStatsProbeSucceeded(0, 0, true, null, "GID_A"));
|
||||
assertTrue(noMasters.getMessage().contains("No reachable RocketMQ master"));
|
||||
|
||||
Exception cause = new RuntimeException("broker down");
|
||||
MQClientException allFailed = assertThrows(
|
||||
MQClientException.class,
|
||||
() -> RocketMqAgent.ensureConsumeStatsProbeSucceeded(2, 0, true, cause, "GID_A"));
|
||||
assertTrue(allFailed.getMessage().contains("Failed to examine consume stats"));
|
||||
assertSame(cause, allFailed.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureConsumeStatsProbeSucceededRejectsPartialFailureWithEmptyMerge() {
|
||||
Exception cause = new RuntimeException("one broker down");
|
||||
MQClientException partialEmpty = assertThrows(
|
||||
MQClientException.class,
|
||||
() -> RocketMqAgent.ensureConsumeStatsProbeSucceeded(3, 1, true, cause, "GID_A"));
|
||||
assertTrue(partialEmpty.getMessage().contains("partial failure"));
|
||||
assertSame(cause, partialEmpty.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureConsumeStatsProbeSucceededAllowsEmptyOffsetsWhenAllBrokersSucceed() throws Exception {
|
||||
// Genuine offline / unused group: every broker answered, offset table empty.
|
||||
RocketMqAgent.ensureConsumeStatsProbeSucceeded(1, 1, true, null, "GID_A");
|
||||
RocketMqAgent.ensureConsumeStatsProbeSucceeded(3, 3, true, null, "GID_A");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureConsumeStatsProbeSucceededAllowsPartialFailureWhenMergeHasOffsets() throws Exception {
|
||||
// One broker failed but another returned queue offsets — keep partial lag.
|
||||
RocketMqAgent.ensureConsumeStatsProbeSucceeded(
|
||||
3, 1, false, new RuntimeException("partial"), "GID_A");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedOnCollisionEmptyWhenSiblingFallbacksUnreachable() {
|
||||
assertTrue(RocketMqAgent.shouldFailClosedOnCollisionEmpty(true, 1, 0));
|
||||
assertTrue(RocketMqAgent.shouldFailClosedOnCollisionEmpty(true, 2, 1));
|
||||
assertFalse(RocketMqAgent.shouldFailClosedOnCollisionEmpty(false, 1, 0));
|
||||
assertFalse(RocketMqAgent.shouldFailClosedOnCollisionEmpty(true, 0, 0));
|
||||
assertFalse(RocketMqAgent.shouldFailClosedOnCollisionEmpty(true, 2, 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedOnCollisionPartialMutationWhenSiblingFallbacksUnreachable() {
|
||||
assertTrue(RocketMqAgent.shouldFailClosedOnCollisionPartialMutation(1, 0));
|
||||
assertTrue(RocketMqAgent.shouldFailClosedOnCollisionPartialMutation(2, 1));
|
||||
assertFalse(RocketMqAgent.shouldFailClosedOnCollisionPartialMutation(0, 0));
|
||||
assertFalse(RocketMqAgent.shouldFailClosedOnCollisionPartialMutation(2, 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureSubscriptionGroupMutationSucceededRejectsCollisionPartialUpdate() {
|
||||
Exception cause = new RuntimeException("fallback unreachable");
|
||||
MQClientException err = assertThrows(
|
||||
MQClientException.class,
|
||||
() -> RocketMqAgent.ensureSubscriptionGroupMutationSucceeded(
|
||||
1, 1, 1, 0, cause, "delete", "GID_A"));
|
||||
assertTrue(err.getMessage().contains("Docker remap address collision"));
|
||||
assertSame(cause, err.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureSubscriptionGroupMutationSucceededRejectsPartialRemappedUpdate() {
|
||||
Exception cause = new RuntimeException("broker-b down");
|
||||
MQClientException err = assertThrows(
|
||||
MQClientException.class,
|
||||
() -> RocketMqAgent.ensureSubscriptionGroupMutationSucceeded(
|
||||
2, 1, 0, 0, cause, "update", "GID_A"));
|
||||
assertTrue(err.getMessage().contains("partial remapped update"));
|
||||
assertSame(cause, err.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureSubscriptionGroupMutationSucceededAllowsFullCoverage() throws Exception {
|
||||
RocketMqAgent.ensureSubscriptionGroupMutationSucceeded(2, 2, 0, 0, null, "delete", "GID_A");
|
||||
RocketMqAgent.ensureSubscriptionGroupMutationSucceeded(1, 1, 1, 1, null, "update", "GID_A");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masterBrokerAddrsFromClusterInfoKeepsCollisionFallbacksSeparate() {
|
||||
org.apache.rocketmq.remoting.protocol.body.ClusterInfo clusterInfo =
|
||||
new org.apache.rocketmq.remoting.protocol.body.ClusterInfo();
|
||||
HashMap<String, org.apache.rocketmq.remoting.protocol.route.BrokerData> table = new HashMap<>();
|
||||
table.put("broker-a", brokerData("broker-a", "172.18.0.2:10911"));
|
||||
table.put("broker-b", brokerData("broker-b", "172.18.0.3:10911"));
|
||||
clusterInfo.setBrokerAddrTable(table);
|
||||
|
||||
JsonObject conn = JsonParser.parseString("""
|
||||
{"namesrvAddr":"127.0.0.1:9876"}
|
||||
""").getAsJsonObject();
|
||||
RocketMqAgent.MasterBrokerAddrPlan plan =
|
||||
RocketMqAgent.masterBrokerAddrsFromClusterInfo(clusterInfo, conn, null);
|
||||
|
||||
// Both masters remap to the same published host:port; one remapped + one fallback.
|
||||
assertEquals(1, plan.remappedCount());
|
||||
assertEquals(1, plan.fallbackCount());
|
||||
assertTrue(plan.allAddrs().contains("127.0.0.1:10911"));
|
||||
assertTrue(plan.isCollisionFallback("172.18.0.3:10911") || plan.isCollisionFallback("172.18.0.2:10911"));
|
||||
// Remapped answered with offsets: collision fallbacks may stay unreachable on host agents.
|
||||
try {
|
||||
RocketMqAgent.ensureConsumeStatsProbeSucceeded(
|
||||
plan.remappedCount(), 1, false, new RuntimeException("fallback unreachable"), "GID_A");
|
||||
} catch (MQClientException e) {
|
||||
throw new AssertionError("non-empty remapped merge should be allowed", e);
|
||||
}
|
||||
// Empty merge is still allowed by the shared gate when remappedCount==success; the
|
||||
// examineConsumeStatsOnMasters collision check rejects empty+unreachable siblings.
|
||||
try {
|
||||
RocketMqAgent.ensureConsumeStatsProbeSucceeded(
|
||||
plan.remappedCount(), 1, true, null, "GID_A");
|
||||
} catch (MQClientException e) {
|
||||
throw new AssertionError("empty remapped-only success remains allowed at gate layer", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static org.apache.rocketmq.remoting.protocol.route.BrokerData brokerData(
|
||||
String name, String masterAddr) {
|
||||
org.apache.rocketmq.remoting.protocol.route.BrokerData data =
|
||||
new org.apache.rocketmq.remoting.protocol.route.BrokerData();
|
||||
data.setBrokerName(name);
|
||||
HashMap<Long, String> addrs = new HashMap<>();
|
||||
addrs.put(0L, masterAddr);
|
||||
data.setBrokerAddrs(addrs);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2896,12 +2896,13 @@ const productionDatabaseSummary = computed(() => {
|
|||
return t("production.databasesSelectedCount", { selected, total: productionDatabaseNames.value.length });
|
||||
});
|
||||
const productionScope = computed<ProductionScope>({
|
||||
get: () => (isSingleDatabase(form.value.db_type) || form.value.is_production ? "connection" : "databases"),
|
||||
get: () => (isSingleDatabase(form.value.db_type) || form.value.db_type === "mq" || form.value.db_type === "mqtt" || form.value.is_production ? "connection" : "databases"),
|
||||
set: (scope) => {
|
||||
form.value.is_production = isSingleDatabase(form.value.db_type) || scope === "connection";
|
||||
form.value.is_production = isSingleDatabase(form.value.db_type) || form.value.db_type === "mq" || form.value.db_type === "mqtt" || scope === "connection";
|
||||
},
|
||||
});
|
||||
const canSelectProductionDatabases = computed(() => !isSingleDatabase(form.value.db_type));
|
||||
// MQ/MQTT have no database list — production protection is always connection-scoped.
|
||||
const canSelectProductionDatabases = computed(() => !isSingleDatabase(form.value.db_type) && form.value.db_type !== "mq" && form.value.db_type !== "mqtt");
|
||||
|
||||
function setProductionProtectionEnabled(enabled: boolean) {
|
||||
productionProtectionEnabled.value = enabled;
|
||||
|
|
@ -3415,8 +3416,8 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
|
|||
}
|
||||
if (!config.one_time) config.one_time = undefined;
|
||||
if (!config.read_only) config.read_only = undefined;
|
||||
if (isSingleDatabase(config.db_type) && config.production_databases?.length) {
|
||||
// Single-database drivers expose schemas or internal names, not independently selectable databases.
|
||||
if ((isSingleDatabase(config.db_type) || config.db_type === "mq" || config.db_type === "mqtt") && config.production_databases?.length) {
|
||||
// Single-database / MQ drivers expose no independently selectable database list for PROD scope.
|
||||
config.is_production = true;
|
||||
config.production_databases = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,6 +205,8 @@ onUnmounted(() => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.broker-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
|
@ -253,26 +255,6 @@ onUnmounted(() => {
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-sm:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-sm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panel-error,
|
||||
.panel-loading,
|
||||
.empty-state {
|
||||
|
|
@ -408,20 +390,6 @@ onUnmounted(() => {
|
|||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { mqBind, mqCreateExchange, mqDeleteExchange, mqListBindings, mqListExcha
|
|||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { isBuiltinRabbitMqExchange, rabbitMqExchangeDisplayName, RABBITMQ_EXCHANGE_TYPES } from "@/lib/mq/rabbitmqExchanges";
|
||||
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -17,6 +18,7 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const exchanges = ref<MqExchangeInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
|
|
@ -79,12 +81,12 @@ function nsRefFor(row: { namespace?: string } | undefined): NamespaceRef | null
|
|||
return { tenant: props.tenant, namespace };
|
||||
}
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqExchanges.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadExchanges() {
|
||||
|
|
@ -143,14 +145,17 @@ function selectExchange(exchange: MqExchangeInfo) {
|
|||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqExchanges.readOnly");
|
||||
return;
|
||||
}
|
||||
dialogError.value = undefined;
|
||||
createForm.value = { name: "", type: "direct", durable: true, autoDelete: false };
|
||||
showCreateDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqExchanges.create")))) return;
|
||||
const ns = nsRef();
|
||||
if (!createForm.value.name.trim() || !ns) {
|
||||
dialogError.value = t("mqExchanges.nameRequired");
|
||||
|
|
@ -176,13 +181,17 @@ async function handleCreate() {
|
|||
}
|
||||
|
||||
function openDeleteDialog(exchange: MqExchangeInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqExchanges.readOnly");
|
||||
return;
|
||||
}
|
||||
if (isBuiltinRabbitMqExchange(exchange)) return;
|
||||
deleteTarget.value = exchange;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!(await guardWritable(t("mqExchanges.delete")))) return;
|
||||
const target = deleteTarget.value;
|
||||
if (!target) return;
|
||||
const ns = nsRefFor(target);
|
||||
|
|
@ -208,7 +217,10 @@ async function confirmDelete() {
|
|||
}
|
||||
|
||||
function openBindDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqExchanges.readOnly");
|
||||
return;
|
||||
}
|
||||
if (!selectedExchange.value) return;
|
||||
dialogError.value = undefined;
|
||||
bindForm.value = { destinationType: "queue", destination: "", routingKey: "", argumentsText: "" };
|
||||
|
|
@ -227,7 +239,7 @@ function parseBindingArguments(): Record<string, unknown> | undefined {
|
|||
}
|
||||
|
||||
async function handleBind() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqExchanges.bind")))) return;
|
||||
const exchange = selectedExchange.value;
|
||||
if (!exchange) return;
|
||||
const ns = nsRefFor(exchange);
|
||||
|
|
@ -260,12 +272,16 @@ async function handleBind() {
|
|||
}
|
||||
|
||||
function openUnbindDialog(bindingRow: MqBindingInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqExchanges.readOnly");
|
||||
return;
|
||||
}
|
||||
unbindTarget.value = bindingRow;
|
||||
showUnbindDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmUnbind() {
|
||||
if (!(await guardWritable(t("mqExchanges.unbind")))) return;
|
||||
const target = unbindTarget.value;
|
||||
if (!target) return;
|
||||
const ns = nsRefFor(target ?? selectedExchange.value);
|
||||
|
|
@ -522,6 +538,8 @@ watch(
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.exchanges-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -700,45 +718,6 @@ td {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
@ -778,16 +757,6 @@ button:disabled {
|
|||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
|
|
|
|||
|
|
@ -225,6 +225,8 @@ watch(kafkaStartPosition, () => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.message-browser {
|
||||
margin-top: 4px;
|
||||
padding: 14px;
|
||||
|
|
@ -257,20 +259,6 @@ watch(kafkaStartPosition, () => {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-sm:hover:not(:disabled) {
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.btn-sm:disabled,
|
||||
.peek-controls input:disabled,
|
||||
.peek-controls :deep(.message-browser-start-position[data-disabled]) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { formatRocketMqMessagePayload, formatRocketMqTimestamp, parseRocketMqMes
|
|||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { buildRocketMqTraceTopicOptions, DEFAULT_ROCKETMQ_TRACE_TOPIC, resolveRocketMqMessageType } from "@/lib/mq/rocketmqTopicTypes";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import RocketMqTraceDetailDialog from "./RocketMqTraceDetailDialog.vue";
|
||||
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
|
||||
import RocketMqMessageDetailDialog from "./shared/RocketMqMessageDetailDialog.vue";
|
||||
|
|
@ -25,6 +26,7 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const topicSelectRef = ref<InstanceType<typeof RocketMqTopicSelect>>();
|
||||
const availableTopics = ref<TopicInfo[]>([]);
|
||||
|
|
@ -258,6 +260,7 @@ async function resendMessage() {
|
|||
resendError.value = t("mqMessages.readOnlyCannotSend");
|
||||
return;
|
||||
}
|
||||
if (!(await confirmMqWrite(t("mqMessages.resendMessage")))) return;
|
||||
const topic = resendTopic.value;
|
||||
const payloadText = detailPayload.value.trim();
|
||||
if (!topic) {
|
||||
|
|
@ -493,6 +496,8 @@ watch(topicName, () => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.message-query-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
|
@ -587,32 +592,6 @@ watch(topicName, () => {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-icon:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.spin {
|
||||
display: inline-block;
|
||||
animation: spin-anim 0.8s linear infinite;
|
||||
|
|
@ -711,39 +690,12 @@ watch(topicName, () => {
|
|||
word-break: break-all;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-sm {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.operation-cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-sm:hover:not(:disabled) {
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 7px 16px;
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -791,15 +743,6 @@ watch(topicName, () => {
|
|||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
|
|
@ -843,16 +786,6 @@ watch(topicName, () => {
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.detail-payload {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
|
|||
import type { MqAdminConfig, MqClusterInfo, MqSystemKind, NamespaceRef, TopicInfo } from "@/types/mq";
|
||||
import { mqCreateNamespace, mqListNamespaces, mqTestConnection } from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import { mqClusterOptionsFromExtra } from "@/lib/mq/mqTenantForm";
|
||||
import {
|
||||
defaultMqCapabilitiesForSystemKind,
|
||||
|
|
@ -50,6 +51,8 @@ interface Props {
|
|||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
const isProductionConnection = computed(() => !!connectionStore.getConfig(props.connectionId)?.is_production);
|
||||
const configuredSystemKind = computed(() => resolveMqSystemKindFromConnection(connectionStore.getConfig(props.connectionId)));
|
||||
const FLAT_MQ_CONTEXT = "_flat_mq";
|
||||
|
||||
|
|
@ -225,6 +228,7 @@ function handleNamespaceSelect(value: AcceptableValue) {
|
|||
|
||||
async function handleCreateNamespace() {
|
||||
if (props.readOnly) return;
|
||||
if (!(await confirmMqWrite(t("mqNamespaces.create")))) return;
|
||||
const name = createNamespaceName.value.trim();
|
||||
if (!name) {
|
||||
createNamespaceError.value = t("mqNamespaces.namespaceNameRequired");
|
||||
|
|
@ -398,6 +402,7 @@ onMounted(async () => {
|
|||
<button v-if="selectedTopic" class="breadcrumb-button" @click="goToTopicLevel" :title="t('mqAdmin.viewTopic')">{{ selectedTopic.shortName }}</button>
|
||||
</div>
|
||||
<div class="toolbar-status">
|
||||
<span v-if="isProductionConnection" class="prod-badge">PROD</span>
|
||||
<span v-if="readOnly" class="readonly-badge">{{ t("mqAdmin.readOnly") }}</span>
|
||||
<span v-if="error" class="toolbar-error">{{ error }}</span>
|
||||
</div>
|
||||
|
|
@ -560,6 +565,8 @@ onMounted(async () => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.mq-admin-console {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -635,6 +642,15 @@ onMounted(async () => {
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prod-badge {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgb(220 38 38 / 0.55);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
color: rgb(185 28 28);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mq-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -735,16 +751,6 @@ onMounted(async () => {
|
|||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
|
@ -784,28 +790,6 @@ onMounted(async () => {
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ref, watch } from "vue";
|
|||
import { useI18n } from "vue-i18n";
|
||||
import type { NamespaceRef, NamespaceInfo, NamespaceConfig } from "@/types/mq";
|
||||
import { mqListNamespaces, mqCreateNamespace, mqDeleteNamespace } from "@/lib/backend/api";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -23,6 +24,7 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const namespaces = ref<NamespaceInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
|
|
@ -37,12 +39,12 @@ const formData = ref({
|
|||
namespace: "",
|
||||
});
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqNamespaces.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadNamespaces() {
|
||||
|
|
@ -62,7 +64,10 @@ async function loadNamespaces() {
|
|||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqNamespaces.readOnly");
|
||||
return;
|
||||
}
|
||||
formData.value = {
|
||||
namespace: "",
|
||||
};
|
||||
|
|
@ -70,7 +75,7 @@ function openCreateDialog() {
|
|||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqNamespaces.create")))) return;
|
||||
if (!formData.value.namespace.trim() || !props.tenant) {
|
||||
error.value = t("mqNamespaces.namespaceNameRequired");
|
||||
return;
|
||||
|
|
@ -94,12 +99,16 @@ async function handleCreate() {
|
|||
}
|
||||
|
||||
function handleDelete(ns: NamespaceInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqNamespaces.readOnly");
|
||||
return;
|
||||
}
|
||||
deleteTarget.value = ns;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!(await guardWritable(t("mqNamespaces.delete")))) return;
|
||||
const ns = deleteTarget.value;
|
||||
if (!ns || !props.tenant) return;
|
||||
deleting.value = true;
|
||||
|
|
@ -217,6 +226,8 @@ watch(
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.namespaces-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
|
@ -307,45 +318,6 @@ td {
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
@ -414,16 +386,6 @@ button:disabled {
|
|||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
|
|||
import type { AuthAction, MqIssuedToken, MqSystemKind, MqTokenRecord, PermissionMap, PolicyScope, TopicInfo } from "@/types/mq";
|
||||
import { mqGrantPermission, mqIssueToken, mqListPermissions, mqListTokenRecords, mqRevokePermission } from "@/lib/backend/api";
|
||||
import { formatMqTokenIssueError, type MqTokenIssueErrorView } from "@/lib/mq/mqTokenErrors";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
|
|
@ -17,6 +18,7 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const actionOptions: AuthAction[] = ["produce", "consume", "functions", "sources", "sinks", "packages"];
|
||||
|
||||
|
|
@ -78,13 +80,13 @@ const permissionRows = computed(() => {
|
|||
return Object.entries(permissions.value).map(([role, actions]) => ({ role, actions }));
|
||||
});
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage.value;
|
||||
notice.value = undefined;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
function formatActionLabel(action: AuthAction): string {
|
||||
|
|
@ -120,7 +122,7 @@ async function loadPermissions() {
|
|||
}
|
||||
|
||||
async function grantPermission() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqPermissions.grant")))) return;
|
||||
const current = scope.value;
|
||||
const role = roleName.value.trim();
|
||||
roleNameError.value = "";
|
||||
|
|
@ -163,7 +165,7 @@ async function grantPermission() {
|
|||
}
|
||||
|
||||
async function revokePermission(role: string) {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqPermissions.revoke")))) return;
|
||||
const current = scope.value;
|
||||
if (!current) {
|
||||
error.value = t("mqPermissions.selectNamespaceOrTopic");
|
||||
|
|
@ -228,6 +230,10 @@ async function issueToken() {
|
|||
tokenIssueError.value = undefined;
|
||||
return;
|
||||
}
|
||||
if (!(await confirmMqWrite(t("mqPermissions.generateToken")))) {
|
||||
tokenIssueError.value = undefined;
|
||||
return;
|
||||
}
|
||||
const current = scope.value;
|
||||
if (!current) {
|
||||
tokenError.value = t("mqPermissions.selectNamespaceOrTopic");
|
||||
|
|
@ -475,6 +481,8 @@ watch(
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.permissions-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
|
@ -677,30 +685,6 @@ th {
|
|||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -736,15 +720,6 @@ th {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 18px 20px;
|
||||
overflow: auto;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
|
|||
import type { BacklogQuota, DispatchRate, PolicyScope, PublishRate, RetentionPolicy, SubscribeRate, TopicInfo } from "@/types/mq";
|
||||
import { mqGetEffectivePolicies, mqSetBacklogQuota, mqSetDispatchRate, mqSetPublishRate, mqSetRetention, mqSetSubscribeRate } from "@/lib/backend/api";
|
||||
import { defaultMqPolicyForms, policyFormsFromEffectivePolicies } from "@/lib/mq/mqPolicyForms";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
|
|
@ -20,6 +21,7 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const policies = ref<unknown>();
|
||||
const loading = ref(false);
|
||||
|
|
@ -70,13 +72,13 @@ const scopeLabel = computed(() => {
|
|||
|
||||
const formattedPolicies = computed(() => JSON.stringify(policies.value ?? {}, null, 2));
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage.value;
|
||||
notice.value = undefined;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadPolicies() {
|
||||
|
|
@ -100,7 +102,7 @@ async function loadPolicies() {
|
|||
}
|
||||
|
||||
async function applyPolicy(kind: string, action: (current: PolicyScope) => Promise<void>) {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(kind))) return;
|
||||
const current = scope.value;
|
||||
if (!current) {
|
||||
error.value = scopePlaceholderMessage.value;
|
||||
|
|
@ -279,6 +281,8 @@ watch(
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.policies-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
|
@ -404,23 +408,6 @@ pre {
|
|||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { computed, ref, watch } from "vue";
|
|||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsumerInfo, ProducerInfo, SubscriptionInfo, TopicInfo, TopicRef, TopicStats, MqSystemKind } from "@/types/mq";
|
||||
import { mqGetTopicStats, mqListConsumers, mqListProducers, mqListSubscriptions, mqUnloadTopic } from "@/lib/backend/api";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -26,6 +27,7 @@ const emit = defineEmits<{
|
|||
topicSelected: [topic: TopicInfo | undefined];
|
||||
}>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
interface PartitionClientRow {
|
||||
name: string;
|
||||
|
|
@ -273,6 +275,7 @@ async function loadRuntimeClients() {
|
|||
async function unloadTopic() {
|
||||
const current = topicRef.value;
|
||||
if (!current || props.readOnly || unloading.value) return;
|
||||
if (!(await confirmMqWrite(t("mqClients.unloadTopic")))) return;
|
||||
if (!confirm(t("mqClients.confirmUnload"))) return;
|
||||
|
||||
unloading.value = true;
|
||||
|
|
@ -762,6 +765,8 @@ watch(
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.producer-consumer-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -828,44 +833,6 @@ watch(
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-sm:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-sm.danger {
|
||||
border-color: var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-sm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtime-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
|
|||
import type { MqRawResponse, TopicInfo } from "@/types/mq";
|
||||
import { mqRawRequest } from "@/lib/backend/api";
|
||||
import { safeJsonFormat } from "@/lib/common/safeJsonFormat";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
|
|
@ -16,6 +17,7 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const methods = ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"];
|
||||
type RawApiPreset = {
|
||||
|
|
@ -184,6 +186,10 @@ async function executeRequest() {
|
|||
error.value = readOnlyMessage.value;
|
||||
return;
|
||||
}
|
||||
// Mutating raw admin calls need the same production confirmation as console writes.
|
||||
if (!isReadMethod.value && !(await confirmMqWrite(t("mqRaw.sendRequest")))) {
|
||||
return;
|
||||
}
|
||||
if (!path.value.trim()) {
|
||||
error.value = t("mqRaw.pathRequired");
|
||||
return;
|
||||
|
|
@ -277,6 +283,8 @@ async function executeRequest() {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.raw-api-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
|
@ -514,31 +522,6 @@ pre {
|
|||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-secondary.compact {
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { mqQueryMessageTrace } from "@/lib/backend/api";
|
||||
import { formatRocketMqTraceError } from "@/lib/mq/rocketmqTraceUtils";
|
||||
import { formatRocketMqTimestamp, parseRocketMqMessagesFromResult, rocketMqMessagePayload, type RocketMqDisplayMessage } from "@/lib/mq/rocketmqMessageUtils";
|
||||
import { formatRocketMqTraceError, isParsedRocketMqTraceRecord, parseRocketMqTracePayload, rocketMqTraceRecordTimeLabel, splitRocketMqTraceKeys, type RocketMqTraceFieldKey, type RocketMqTraceRecord, type RocketMqTraceType } from "@/lib/mq/rocketmqTraceUtils";
|
||||
import { parseRocketMqMessagesFromResult, rocketMqMessagePayload, type RocketMqDisplayMessage } from "@/lib/mq/rocketmqMessageUtils";
|
||||
import { DEFAULT_ROCKETMQ_TRACE_TOPIC } from "@/lib/mq/rocketmqTopicTypes";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -13,6 +13,15 @@ interface Props {
|
|||
traceTopic?: string;
|
||||
}
|
||||
|
||||
interface TraceCardModel {
|
||||
index: number;
|
||||
message: RocketMqDisplayMessage;
|
||||
record: RocketMqTraceRecord;
|
||||
parsed: boolean;
|
||||
rawPayload: string;
|
||||
headerEntries: Array<[string, string]>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
traceTopic: DEFAULT_ROCKETMQ_TRACE_TOPIC,
|
||||
});
|
||||
|
|
@ -26,6 +35,71 @@ const { t } = useI18n();
|
|||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const messages = ref<RocketMqDisplayMessage[]>([]);
|
||||
let loadSeq = 0;
|
||||
|
||||
const FIELD_I18N: Record<RocketMqTraceFieldKey, string> = {
|
||||
regionId: "mqTrace.fieldRegionId",
|
||||
group: "mqTrace.fieldGroup",
|
||||
topic: "mqTrace.fieldTopic",
|
||||
msgId: "mqTrace.fieldMsgId",
|
||||
tags: "mqTrace.fieldTags",
|
||||
keys: "mqTrace.fieldKeys",
|
||||
storeHost: "mqTrace.fieldStoreHost",
|
||||
clientHost: "mqTrace.fieldClientHost",
|
||||
bodyLength: "mqTrace.fieldBodyLength",
|
||||
costTime: "mqTrace.fieldCostTime",
|
||||
msgType: "mqTrace.fieldMsgType",
|
||||
offsetMsgId: "mqTrace.fieldOffsetMsgId",
|
||||
requestId: "mqTrace.fieldRequestId",
|
||||
retryTimes: "mqTrace.fieldRetryTimes",
|
||||
contextCode: "mqTrace.fieldContextCode",
|
||||
};
|
||||
|
||||
function typeLabel(type: RocketMqTraceType): string {
|
||||
switch (type) {
|
||||
case "Pub":
|
||||
return t("mqTrace.typePub");
|
||||
case "SubBefore":
|
||||
return t("mqTrace.typeSubBefore");
|
||||
case "SubAfter":
|
||||
return t("mqTrace.typeSubAfter");
|
||||
case "EndTransaction":
|
||||
return t("mqTrace.typeEndTransaction");
|
||||
default:
|
||||
return t("mqTrace.typeUnknown");
|
||||
}
|
||||
}
|
||||
|
||||
function fieldLabel(key: RocketMqTraceFieldKey): string {
|
||||
return t(FIELD_I18N[key]);
|
||||
}
|
||||
|
||||
function headerEntries(message: RocketMqDisplayMessage): Array<[string, string]> {
|
||||
if (!message.headers) return [];
|
||||
return Object.entries(message.headers);
|
||||
}
|
||||
|
||||
const cards = computed<TraceCardModel[]>(() => {
|
||||
const result: TraceCardModel[] = [];
|
||||
let index = 0;
|
||||
for (const message of messages.value) {
|
||||
const rawPayload = rocketMqMessagePayload(message);
|
||||
const records = parseRocketMqTracePayload(rawPayload);
|
||||
const units = records.length ? records : [{ type: "Unknown" as const, fields: [], raw: rawPayload }];
|
||||
for (const record of units) {
|
||||
index += 1;
|
||||
result.push({
|
||||
index,
|
||||
message,
|
||||
record,
|
||||
parsed: isParsedRocketMqTraceRecord(record),
|
||||
rawPayload: record.raw || rawPayload,
|
||||
headerEntries: headerEntries(message),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
async function loadTrace() {
|
||||
const msgId = props.msgId.trim();
|
||||
|
|
@ -35,17 +109,20 @@ async function loadTrace() {
|
|||
return;
|
||||
}
|
||||
|
||||
const seq = ++loadSeq;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
messages.value = [];
|
||||
try {
|
||||
const traceTopic = props.traceTopic.trim() || DEFAULT_ROCKETMQ_TRACE_TOPIC;
|
||||
const result = await mqQueryMessageTrace(props.connectionId, msgId, traceTopic);
|
||||
if (seq !== loadSeq) return;
|
||||
messages.value = parseRocketMqMessagesFromResult(result);
|
||||
} catch (e: unknown) {
|
||||
if (seq !== loadSeq) return;
|
||||
error.value = formatRocketMqTraceError(e, t("mqTrace.traceTopicRouteMissing"));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (seq === loadSeq) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +132,7 @@ watch(
|
|||
if (open) {
|
||||
void loadTrace();
|
||||
} else {
|
||||
loadSeq += 1;
|
||||
loading.value = false;
|
||||
error.value = undefined;
|
||||
messages.value = [];
|
||||
|
|
@ -80,19 +158,49 @@ watch(
|
|||
|
||||
<div v-if="loading" class="panel-placeholder">{{ t("mqTrace.querying") }}</div>
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
<div v-else-if="!messages.length" class="panel-placeholder">{{ t("mqTrace.noTrace") }}</div>
|
||||
<div v-else-if="!cards.length" class="panel-placeholder">{{ t("mqTrace.noTrace") }}</div>
|
||||
<div v-else class="message-list">
|
||||
<article v-for="(message, index) in messages" :key="`${message.messageId ?? index}-${message.timestamp ?? 0}`" class="message-row">
|
||||
<article v-for="card in cards" :key="`${card.index}-${card.record.type}-${card.record.timestamp ?? 0}`" class="message-row">
|
||||
<div class="message-meta">
|
||||
<span>#{{ index + 1 }}</span>
|
||||
<span v-if="message.topic">{{ message.topic }}</span>
|
||||
<span v-if="message.partition != null">{{ t("mqMessages.metaPartition", { partition: message.partition }) }}</span>
|
||||
<span>{{ formatRocketMqTimestamp(message.timestamp) }}</span>
|
||||
<span>#{{ card.index }}</span>
|
||||
<span class="type-badge" :data-type="card.record.type">{{ typeLabel(card.record.type) }}</span>
|
||||
<span>{{ rocketMqTraceRecordTimeLabel(card.record, card.message.timestamp) }}</span>
|
||||
<span v-if="card.message.partition != null">{{ t("mqMessages.metaPartition", { partition: card.message.partition }) }}</span>
|
||||
<span v-if="card.record.success != null" class="status-badge" :class="card.record.success ? 'status-success' : 'status-fail'">
|
||||
{{ card.record.success ? t("mqTrace.statusSuccess") : t("mqTrace.statusFail") }}
|
||||
</span>
|
||||
</div>
|
||||
<pre class="message-payload">{{ rocketMqMessagePayload(message) }}</pre>
|
||||
<div v-if="message.headers && Object.keys(message.headers).length" class="message-headers">
|
||||
<span v-for="(value, key) in message.headers" :key="key">{{ key }}: {{ value }}</span>
|
||||
|
||||
<div v-if="card.parsed" class="detail-grid trace-fields">
|
||||
<template v-for="field in card.record.fields" :key="field.key">
|
||||
<span>{{ fieldLabel(field.key) }}</span>
|
||||
<span v-if="field.key === 'keys'" class="keys-chips">
|
||||
<template v-if="splitRocketMqTraceKeys(field.value).length">
|
||||
<span v-for="key in splitRocketMqTraceKeys(field.value)" :key="key" class="key-chip mono">{{ key }}</span>
|
||||
</template>
|
||||
<span v-else class="mono">{{ field.value }}</span>
|
||||
</span>
|
||||
<span v-else class="mono">{{ field.value }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<pre v-else class="message-payload">{{ card.rawPayload || "-" }}</pre>
|
||||
|
||||
<div v-if="card.headerEntries.length" class="message-headers">
|
||||
<template v-for="[key, value] in card.headerEntries" :key="key">
|
||||
<div v-if="key === 'KEYS'" class="header-keys">
|
||||
<span class="header-keys-label">{{ key }}:</span>
|
||||
<span class="keys-chips">
|
||||
<span v-for="chip in splitRocketMqTraceKeys(value)" :key="chip" class="key-chip mono">{{ chip }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>{{ key }}: {{ value }}</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<details v-if="card.parsed && card.rawPayload" class="raw-payload">
|
||||
<summary>{{ t("mqTrace.rawPayload") }}</summary>
|
||||
<pre class="message-payload">{{ card.rawPayload }}</pre>
|
||||
</details>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -176,6 +284,10 @@ watch(
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
.trace-fields {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
|
|
@ -213,11 +325,54 @@ watch(
|
|||
.message-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.type-badge,
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.type-badge[data-type="Pub"] {
|
||||
border-color: color-mix(in srgb, var(--color-primary, #3b82f6) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--color-primary, #3b82f6) 12%, transparent);
|
||||
color: var(--color-primary, #2563eb);
|
||||
}
|
||||
|
||||
.type-badge[data-type="SubBefore"],
|
||||
.type-badge[data-type="SubAfter"] {
|
||||
border-color: color-mix(in srgb, var(--color-info, #0ea5e9) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--color-info, #0ea5e9) 12%, transparent);
|
||||
color: var(--color-info, #0284c7);
|
||||
}
|
||||
|
||||
.status-success {
|
||||
border: 1px solid color-mix(in srgb, var(--color-success, #22c55e) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--color-success, #22c55e) 12%, transparent);
|
||||
color: var(--color-success, #16a34a);
|
||||
}
|
||||
|
||||
.status-fail {
|
||||
border: 1px solid color-mix(in srgb, var(--color-error, #ef4444) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--color-error, #ef4444) 12%, transparent);
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.message-payload {
|
||||
margin: 8px 0 0;
|
||||
padding: 10px;
|
||||
|
|
@ -238,12 +393,62 @@ watch(
|
|||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.message-headers span {
|
||||
.message-headers > span {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.header-keys {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.header-keys-label {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.keys-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.key-chip {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.raw-payload {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.raw-payload summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.raw-payload .message-payload {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { MqSystemKind, TopicInfo, TopicRef, SendMessageRequest, SendMessage
|
|||
import { mqSendMessage, mqListTopics } from "@/lib/backend/api";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { resolveRabbitMqSendNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import MessageBrowser from "./MessageBrowser.vue";
|
||||
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const topicName = ref("");
|
||||
const messageKey = ref("");
|
||||
|
|
@ -82,12 +84,12 @@ onUnmounted(() => {
|
|||
if (successTimer) clearTimeout(successTimer);
|
||||
});
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage.value;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(t("mqMessages.sendMessage"));
|
||||
}
|
||||
|
||||
async function loadTopics() {
|
||||
|
|
@ -138,7 +140,7 @@ function parseHeaders(): Record<string, string> {
|
|||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable())) return;
|
||||
error.value = undefined;
|
||||
success.value = undefined;
|
||||
|
||||
|
|
@ -327,6 +329,8 @@ watch(
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.send-message-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
|
@ -463,27 +467,6 @@ watch(
|
|||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.spin {
|
||||
display: inline-block;
|
||||
animation: spin-anim 0.8s linear infinite;
|
||||
|
|
@ -587,35 +570,11 @@ input[type="number"]:focus {
|
|||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-sm {
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
/* Keep send CTA visually balanced next to optional secondary actions */
|
||||
.form-actions .btn-primary {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
textarea:disabled,
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import { formatError } from "@/lib/backend/errorUtils";
|
|||
import { ref, watch, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { TopicRef, TopicInfo, SubscriptionInfo, ResetPosition, SkipCount, PeekedMessage, MqSystemKind } from "@/types/mq";
|
||||
import { mqListSubscriptions, mqCreateSubscription, mqDeleteSubscription, mqResetCursor, mqSkipMessages, mqClearBacklog, mqPeekMessages, mqExpireMessages } from "@/lib/backend/api";
|
||||
import { mqListSubscriptions, mqEnrichSubscriptions, mqCreateSubscription, mqDeleteSubscription, mqResetCursor, mqSkipMessages, mqClearBacklog, mqPeekMessages, mqExpireMessages } from "@/lib/backend/api";
|
||||
import RocketMqConsumerGroupDialogs, { type RocketMqConsumerGroupDialogKind } from "./rocketmq/RocketMqConsumerGroupDialogs.vue";
|
||||
import MqTypeFilterBar from "./shared/MqTypeFilterBar.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import { DEFAULT_ROCKETMQ_CONSUMER_GROUP_TYPE_FILTERS, matchesRocketMqConsumerGroupTypeFilters, resolveRocketMqConsumerGroupMessageModel, resolveRocketMqConsumerGroupType, ROCKETMQ_CONSUMER_GROUP_TYPES, type RocketMqConsumerGroupType } from "@/lib/mq/rocketmqConsumerGroupTypes";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
|
|
@ -31,10 +32,15 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const subscriptions = ref<SubscriptionInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const enriching = ref(false);
|
||||
const truncatedHint = ref<string>();
|
||||
const enrichFailedHint = ref<string>();
|
||||
const error = ref<string>();
|
||||
let loadSeq = 0;
|
||||
const showCreateDialog = ref(false);
|
||||
const showResetDialog = ref(false);
|
||||
const showSkipDialog = ref(false);
|
||||
|
|
@ -82,14 +88,23 @@ const canShowPanel = computed(() => isClusterWideMode.value || !!props.topic);
|
|||
const rocketMqConsumerGroupTypeOptions = ROCKETMQ_CONSUMER_GROUP_TYPES;
|
||||
const panelTitle = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.consumerGroupTitle") : t("mqSubscriptions.title")));
|
||||
const searchPlaceholder = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.searchConsumerGroup") : t("mqSubscriptions.searchPlaceholder")));
|
||||
const filteredSubscriptions = computed(() => {
|
||||
// Match TopicsPanel: denominator tracks type filters; numerator also applies search.
|
||||
const typeFilteredSubscriptions = computed(() => {
|
||||
let rows = subscriptions.value;
|
||||
if (isRocketMqCluster.value) {
|
||||
rows = rows.filter((sub) => matchesRocketMqConsumerGroupTypeFilters(sub, consumerGroupTypeFilters.value));
|
||||
const filters = consumerGroupTypeFilters.value;
|
||||
// Read each flag so checkbox toggles always invalidate this computed.
|
||||
for (const type of ROCKETMQ_CONSUMER_GROUP_TYPES) {
|
||||
void filters[type];
|
||||
}
|
||||
rows = rows.filter((sub) => matchesRocketMqConsumerGroupTypeFilters(sub, filters));
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
const filteredSubscriptions = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase();
|
||||
if (!keyword) return rows;
|
||||
return rows.filter((sub) => sub.name.toLowerCase().includes(keyword));
|
||||
if (!keyword) return typeFilteredSubscriptions.value;
|
||||
return typeFilteredSubscriptions.value.filter((sub) => sub.name.toLowerCase().includes(keyword));
|
||||
});
|
||||
|
||||
function consumerGroupTypeLabel(sub: SubscriptionInfo): string {
|
||||
|
|
@ -100,7 +115,7 @@ function consumerGroupTypeLabel(sub: SubscriptionInfo): string {
|
|||
function consumerGroupTypeBadgeClass(sub: SubscriptionInfo): string {
|
||||
const type = resolveRocketMqConsumerGroupType(sub);
|
||||
if (type === "FIFO") return "badge badge-info";
|
||||
if (type === "SYSTEM") return "badge badge-muted";
|
||||
if (type === "SYSTEM" || type === "UNKNOWN") return "badge badge-muted";
|
||||
return "badge";
|
||||
}
|
||||
|
||||
|
|
@ -109,12 +124,12 @@ function consumerGroupModeLabel(sub: SubscriptionInfo): string {
|
|||
return t(`mqSubscriptions.rocketmqGroupMode.${mode.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
function getListTopicRef(): TopicRef | null {
|
||||
|
|
@ -153,21 +168,57 @@ async function loadSubscriptions() {
|
|||
const topicRef = getListTopicRef();
|
||||
if (!topicRef) {
|
||||
subscriptions.value = [];
|
||||
truncatedHint.value = undefined;
|
||||
enrichFailedHint.value = undefined;
|
||||
return;
|
||||
}
|
||||
const seq = ++loadSeq;
|
||||
loading.value = true;
|
||||
enriching.value = false;
|
||||
error.value = undefined;
|
||||
truncatedHint.value = undefined;
|
||||
enrichFailedHint.value = undefined;
|
||||
try {
|
||||
subscriptions.value = await mqListSubscriptions(props.connectionId, topicRef);
|
||||
// Fast list first (no enrich / online members) so large clusters paint quickly.
|
||||
const page = await mqListSubscriptions(props.connectionId, topicRef);
|
||||
if (seq !== loadSeq) return;
|
||||
subscriptions.value = page;
|
||||
syncSelectedSubscription(page);
|
||||
if (isClusterWideMode.value && page.length >= 500) {
|
||||
truncatedHint.value = t("mqSubscriptions.truncatedHint", { count: page.length });
|
||||
}
|
||||
if (isClusterWideMode.value) {
|
||||
enriching.value = true;
|
||||
try {
|
||||
const enriched = await mqEnrichSubscriptions(props.connectionId, topicRef);
|
||||
if (seq !== loadSeq) return;
|
||||
subscriptions.value = enriched;
|
||||
// Detail dialog holds a snapshot; refresh so topics/members arrive after enrich.
|
||||
syncSelectedSubscription(enriched);
|
||||
if (enriched.length >= 500) {
|
||||
truncatedHint.value = t("mqSubscriptions.truncatedHint", { count: enriched.length });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
// Keep the fast list if enrichment times out; surface why online columns stayed empty.
|
||||
if (seq === loadSeq) {
|
||||
enrichFailedHint.value = t("mqSubscriptions.enrichFailedHint", { error: formatError(e) });
|
||||
}
|
||||
} finally {
|
||||
if (seq === loadSeq) enriching.value = false;
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
if (seq === loadSeq) error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (seq === loadSeq) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
formData.value = {
|
||||
subName: "",
|
||||
startFrom: "latest",
|
||||
|
|
@ -175,13 +226,24 @@ function openCreateDialog() {
|
|||
showCreateDialog.value = true;
|
||||
}
|
||||
|
||||
/** Keep open dialog/selection pointing at the latest list row for the same group name. */
|
||||
function syncSelectedSubscription(rows: SubscriptionInfo[]) {
|
||||
const current = selectedSub.value;
|
||||
if (!current) return;
|
||||
const updated = rows.find((row) => row.name === current.name);
|
||||
if (updated) selectedSub.value = updated;
|
||||
}
|
||||
|
||||
function openRocketMqDetail(sub: SubscriptionInfo) {
|
||||
selectedSub.value = sub;
|
||||
activeRocketMqDialog.value = "detail";
|
||||
}
|
||||
|
||||
function openRocketMqConfig(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
selectedSub.value = sub;
|
||||
activeRocketMqDialog.value = "config";
|
||||
}
|
||||
|
|
@ -191,7 +253,10 @@ function closeRocketMqDialog() {
|
|||
}
|
||||
|
||||
function openResetDialog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
selectedSub.value = sub;
|
||||
resetFormData.value = {
|
||||
position: "latest",
|
||||
|
|
@ -201,7 +266,10 @@ function openResetDialog(sub: SubscriptionInfo) {
|
|||
}
|
||||
|
||||
function openSkipDialog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
selectedSub.value = sub;
|
||||
skipFormData.value = {
|
||||
mode: "count",
|
||||
|
|
@ -220,7 +288,10 @@ function openPeekDialog(sub: SubscriptionInfo) {
|
|||
}
|
||||
|
||||
function openExpireDialog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
selectedSub.value = sub;
|
||||
expireSeconds.value = 3600;
|
||||
showExpireDialog.value = true;
|
||||
|
|
@ -236,7 +307,7 @@ function selectSubscription(sub: SubscriptionInfo) {
|
|||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqSubscriptions.create")))) return;
|
||||
const topicRef = getPulsarTopicRef();
|
||||
if (!formData.value.subName.trim() || !topicRef) {
|
||||
error.value = t("mqSubscriptions.subscriptionNameRequired");
|
||||
|
|
@ -257,12 +328,16 @@ async function handleCreate() {
|
|||
}
|
||||
|
||||
function handleDelete(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
deleteTarget.value = sub;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!(await guardWritable(t("mqSubscriptions.delete")))) return;
|
||||
const sub = deleteTarget.value;
|
||||
if (!sub) return;
|
||||
const topicRef = getListTopicRef();
|
||||
|
|
@ -281,7 +356,7 @@ async function confirmDelete() {
|
|||
}
|
||||
|
||||
async function handleResetCursor() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqSubscriptions.reset")))) return;
|
||||
const topicRef = getPulsarTopicRef();
|
||||
if (!selectedSub.value || !topicRef) return;
|
||||
loading.value = true;
|
||||
|
|
@ -304,7 +379,7 @@ async function handleResetCursor() {
|
|||
}
|
||||
|
||||
async function handleSkipMessages() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqSubscriptions.skip")))) return;
|
||||
const topicRef = getPulsarTopicRef();
|
||||
if (!selectedSub.value || !topicRef) return;
|
||||
loading.value = true;
|
||||
|
|
@ -322,12 +397,16 @@ async function handleSkipMessages() {
|
|||
}
|
||||
|
||||
function handleClearBacklog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
clearBacklogTarget.value = sub;
|
||||
showClearBacklogDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmClearBacklog() {
|
||||
if (!(await guardWritable(t("mqSubscriptions.clearBacklog")))) return;
|
||||
const sub = clearBacklogTarget.value;
|
||||
if (!sub) return;
|
||||
const topicRef = getPulsarTopicRef();
|
||||
|
|
@ -381,7 +460,7 @@ function invalidatePeekRequest() {
|
|||
}
|
||||
|
||||
async function handleExpireMessages() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqSubscriptions.expire")))) return;
|
||||
const topicRef = getPulsarTopicRef();
|
||||
if (!selectedSub.value || !topicRef) return;
|
||||
loading.value = true;
|
||||
|
|
@ -414,10 +493,11 @@ watch(
|
|||
<div class="toolbar-left">
|
||||
<h3>{{ panelTitle }}</h3>
|
||||
<input v-if="isClusterWideMode" v-model="searchKeyword" type="search" class="topic-search" :placeholder="searchPlaceholder" />
|
||||
<span v-if="isClusterWideMode && subscriptions.length" class="topic-count"> {{ filteredSubscriptions.length }} / {{ subscriptions.length }} </span>
|
||||
<span v-if="isClusterWideMode && subscriptions.length" class="topic-count" data-testid="subscription-count"> {{ filteredSubscriptions.length }} / {{ typeFilteredSubscriptions.length }} </span>
|
||||
<span v-if="enriching" class="topic-count">{{ t("mqSubscriptions.enriching") }}</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button v-if="isClusterWideMode" class="btn-secondary" :disabled="loading" @click="loadSubscriptions">
|
||||
<button v-if="isClusterWideMode" class="btn-secondary" :disabled="loading || enriching" @click="loadSubscriptions">
|
||||
{{ loading ? t("mqSubscriptions.refreshing") : t("mqSubscriptions.refresh") }}
|
||||
</button>
|
||||
<button v-if="supportsCreateSubscription !== false && !isRocketMqCluster" @click="openCreateDialog" :disabled="loading || readOnly || !topic" class="btn-primary">+ {{ t("mqSubscriptions.createSubscription") }}</button>
|
||||
|
|
@ -435,14 +515,16 @@ watch(
|
|||
|
||||
<template v-else>
|
||||
<div v-if="error" class="panel-error">{{ error }}</div>
|
||||
<div v-if="truncatedHint && !error" class="panel-hint">{{ truncatedHint }}</div>
|
||||
<div v-if="enrichFailedHint && !error" class="panel-hint">{{ enrichFailedHint }}</div>
|
||||
|
||||
<div v-else-if="loading && !subscriptions.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
|
||||
<div v-if="!error && loading && !subscriptions.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
|
||||
|
||||
<div v-else-if="!filteredSubscriptions.length" class="panel-placeholder">
|
||||
{{ isClusterWideMode ? t("mqSubscriptions.noConsumerGroups") : t("mqSubscriptions.noSubscriptions") }}
|
||||
<div v-else-if="!error && !filteredSubscriptions.length" class="panel-placeholder">
|
||||
{{ isClusterWideMode ? (subscriptions.length ? t("mqSubscriptions.noMatches") : t("mqSubscriptions.noConsumerGroups")) : t("mqSubscriptions.noSubscriptions") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="subscriptions-table">
|
||||
<div v-else-if="!error && filteredSubscriptions.length" class="subscriptions-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -471,12 +553,19 @@ watch(
|
|||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td v-if="!isClusterWideMode">
|
||||
<span :class="{ 'text-warning': sub.msgBacklog > 1000 }">
|
||||
<span v-if="sub.backlogUnavailable" class="text-muted">-</span>
|
||||
<span v-else :class="{ 'text-warning': sub.msgBacklog > 1000 }">
|
||||
{{ sub.msgBacklog.toLocaleString() }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="!isClusterWideMode">{{ t("mqSubscriptions.msgRate", { rate: sub.msgRateOut.toFixed(2) }) }}</td>
|
||||
<td>{{ isClusterWideMode ? (sub.onlineMembers ?? 0) : t("mqSubscriptions.consumerCount", { count: sub.consumers.length }) }}</td>
|
||||
<td data-testid="online-members">
|
||||
<template v-if="isClusterWideMode">
|
||||
<span v-if="sub.onlineMembers == null" class="text-muted">-</span>
|
||||
<span v-else>{{ sub.onlineMembers }}</span>
|
||||
</template>
|
||||
<template v-else>{{ t("mqSubscriptions.consumerCount", { count: sub.consumers.length }) }}</template>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<template v-if="isRocketMqCluster">
|
||||
<button @click.stop="openRocketMqDetail(sub)" class="btn-sm">{{ t("mqSubscriptions.viewDetail") }}</button>
|
||||
|
|
@ -692,6 +781,8 @@ watch(
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.subscriptions-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
|
@ -744,20 +835,6 @@ watch(
|
|||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.subscription-type-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -829,6 +906,7 @@ watch(
|
|||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-hint,
|
||||
.panel-loading {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
|
|
@ -839,6 +917,12 @@ watch(
|
|||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.panel-hint {
|
||||
padding: 8px 16px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.subscriptions-table {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
|
|
@ -902,52 +986,16 @@ td {
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
@ -992,16 +1040,6 @@ button:disabled {
|
|||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
|
|||
import type { TenantInfo, TenantConfig } from "@/types/mq";
|
||||
import { mqListTenants, mqCreateTenant, mqUpdateTenant, mqDeleteTenant } from "@/lib/backend/api";
|
||||
import { defaultTenantConfig, normalizeClusterOptions, validateTenantForm } from "@/lib/mq/mqTenantForm";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
|
|
@ -19,6 +20,7 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const tenants = ref<TenantInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
|
|
@ -46,12 +48,12 @@ const selectedAllowedClusters = computed(() => normalizeClusterOptions(formData.
|
|||
const displayedSelectedClusters = computed(() => [...normalizedClusterOptions.value.filter((cluster) => selectedAllowedClusters.value.includes(cluster)), ...selectedAllowedClusters.value.filter((cluster) => !clusterOptionSet.value.has(cluster))]);
|
||||
const canSubmitTenant = computed(() => Boolean(formData.value.name.trim()) && selectedAllowedClusters.value.length > 0);
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTenants.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
|
|
@ -67,7 +69,10 @@ async function loadTenants() {
|
|||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTenants.readOnly");
|
||||
return;
|
||||
}
|
||||
formData.value = {
|
||||
name: "",
|
||||
config: defaultTenantConfig(normalizedClusterOptions.value),
|
||||
|
|
@ -79,7 +84,10 @@ function openCreateDialog() {
|
|||
}
|
||||
|
||||
function openEditDialog(tenant: TenantInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTenants.readOnly");
|
||||
return;
|
||||
}
|
||||
editingTenant.value = tenant;
|
||||
formData.value = {
|
||||
name: tenant.name,
|
||||
|
|
@ -95,7 +103,7 @@ function openEditDialog(tenant: TenantInfo) {
|
|||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqTenants.create")))) return;
|
||||
const validationError = validateTenantForm(formData.value.name, formData.value.config);
|
||||
if (validationError) {
|
||||
error.value = t(validationError);
|
||||
|
|
@ -116,7 +124,7 @@ async function handleCreate() {
|
|||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqTenants.edit")))) return;
|
||||
if (!editingTenant.value) return;
|
||||
const validationError = validateTenantForm(formData.value.name, formData.value.config);
|
||||
if (validationError) {
|
||||
|
|
@ -138,7 +146,7 @@ async function handleUpdate() {
|
|||
}
|
||||
|
||||
async function handleDelete(tenant: TenantInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqTenants.delete")))) return;
|
||||
if (!confirm(t("mqTenants.confirmDelete", { name: tenant.name }))) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
|
|
@ -392,6 +400,8 @@ watch(normalizedClusterOptions, (clusters) => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.tenants-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
|
@ -509,45 +519,6 @@ td {
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
@ -588,16 +559,6 @@ button:disabled {
|
|||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { MqTab } from "@/lib/mq/mqConsoleDefaults";
|
|||
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS, isProtectedRocketMqTopic, isRocketMqBusinessMessageType, matchesRocketMqTypeFilters, resolveRocketMqMessageType, ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES, ROCKETMQ_TOPIC_MESSAGE_TYPES } from "@/lib/mq/rocketmqTopicTypes";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
const TOPIC_ROW_HEIGHT = 44;
|
||||
|
|
@ -193,12 +194,14 @@ const canSubmitPartitionUpdate = computed(() => {
|
|||
return !props.readOnly && current > 0 && Number.isFinite(newPartitions.value) && newPartitions.value > current;
|
||||
});
|
||||
|
||||
function guardWritable() {
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTopics.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadTopics() {
|
||||
|
|
@ -234,7 +237,10 @@ async function loadClusterInfo() {
|
|||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTopics.readOnly");
|
||||
return;
|
||||
}
|
||||
dialogError.value = undefined;
|
||||
const defaultBroker = rocketMqMasterBrokers.value[0]?.brokerName ?? rocketMqBrokerOptions.value[0]?.brokerName ?? "";
|
||||
formData.value = {
|
||||
|
|
@ -307,7 +313,10 @@ function isDlqTopic(topic: TopicInfo): boolean {
|
|||
}
|
||||
|
||||
function openPartitionsDialog(topic: TopicInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTopics.readOnly");
|
||||
return;
|
||||
}
|
||||
dialogError.value = undefined;
|
||||
if (!topic.partitions || topic.partitions < 1) {
|
||||
error.value = t("mqTopics.currentPartitionsUnknown");
|
||||
|
|
@ -319,7 +328,7 @@ function openPartitionsDialog(topic: TopicInfo) {
|
|||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqTopics.create")))) return;
|
||||
if (!formData.value.topicName.trim() || !props.tenant || !props.namespace) {
|
||||
dialogError.value = t("mqTopics.topicNameRequired");
|
||||
return;
|
||||
|
|
@ -353,12 +362,16 @@ async function handleCreate() {
|
|||
}
|
||||
|
||||
function handleDelete(topic: TopicInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTopics.readOnly");
|
||||
return;
|
||||
}
|
||||
deleteTarget.value = topic;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!(await guardWritable(t("mqTopics.delete")))) return;
|
||||
const topic = deleteTarget.value;
|
||||
if (!topic || !props.tenant || !props.namespace) return;
|
||||
const namespace = resolveMqRowNamespace(topic, props.namespace);
|
||||
|
|
@ -390,7 +403,7 @@ async function confirmDelete() {
|
|||
}
|
||||
|
||||
async function handleUpdatePartitions() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqTopics.adjustPartitions")))) return;
|
||||
if (!editingTopic.value || !props.tenant || !props.namespace) return;
|
||||
const currentPartitions = editingTopic.value.partitions;
|
||||
if (!currentPartitions || currentPartitions < 1) {
|
||||
|
|
@ -719,6 +732,8 @@ watch(newPartitions, () => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.topics-panel {
|
||||
--topics-surface: var(--card, var(--color-background, #ffffff));
|
||||
--topics-header-bg: color-mix(in srgb, var(--secondary, #f5f5f5) 86%, var(--card, #ffffff));
|
||||
|
|
@ -1010,45 +1025,6 @@ watch(newPartitions, () => {
|
|||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
@ -1089,16 +1065,6 @@ button:disabled {
|
|||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ vi.mock("@/lib/backend/api", () => ({
|
|||
mqSendMessage: backend.mqSendMessage,
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useMqMutationGuard", () => ({
|
||||
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
|
||||
}));
|
||||
|
||||
import MessageQueryPanel from "@/components/mq/MessageQueryPanel.vue";
|
||||
|
||||
const TOPIC = {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ vi.mock("@/lib/backend/api", () => ({
|
|||
mqSendMessage: backend.mqSendMessage,
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useMqMutationGuard", () => ({
|
||||
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/select", async () => (await import("./selectStub")).createSelectStub());
|
||||
|
||||
import SendMessagePanel from "@/components/mq/SendMessagePanel.vue";
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { PeekedMessage } from "@/types/mq";
|
|||
|
||||
const backend = vi.hoisted(() => ({
|
||||
mqListSubscriptions: vi.fn(),
|
||||
mqEnrichSubscriptions: vi.fn(),
|
||||
mqCreateSubscription: vi.fn(),
|
||||
mqDeleteSubscription: vi.fn(),
|
||||
mqResetCursor: vi.fn(),
|
||||
|
|
@ -21,6 +22,10 @@ vi.mock("vue-i18n", () => ({
|
|||
|
||||
vi.mock("@/lib/backend/api", () => backend);
|
||||
|
||||
vi.mock("@/composables/useMqMutationGuard", () => ({
|
||||
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
|
@ -61,7 +66,7 @@ function deferred<T>() {
|
|||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function mountPanel() {
|
||||
async function mountPanel(overrides: Record<string, unknown> = {}) {
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
app = createApp(SubscriptionsPanel, {
|
||||
|
|
@ -71,15 +76,29 @@ async function mountPanel() {
|
|||
namespace: "default",
|
||||
mqSystemKind: "kafka",
|
||||
supportsPeekMessages: true,
|
||||
...overrides,
|
||||
});
|
||||
app.mount(root);
|
||||
await flushUi();
|
||||
return root;
|
||||
}
|
||||
|
||||
function subscription(name: string, consumerGroupType: string) {
|
||||
return {
|
||||
name,
|
||||
subType: consumerGroupType,
|
||||
consumerGroupType,
|
||||
messageModel: "CLUSTERING",
|
||||
msgBacklog: 0,
|
||||
msgRateOut: 0,
|
||||
msgThroughputOut: 0,
|
||||
consumers: [],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.values(backend).forEach((mock) => mock.mockReset());
|
||||
backend.mqListSubscriptions.mockResolvedValue([
|
||||
const rows = [
|
||||
{
|
||||
name: "orders-consumer",
|
||||
subType: "shared",
|
||||
|
|
@ -88,7 +107,9 @@ beforeEach(() => {
|
|||
msgThroughputOut: 0,
|
||||
consumers: [],
|
||||
},
|
||||
]);
|
||||
];
|
||||
backend.mqListSubscriptions.mockResolvedValue(rows);
|
||||
backend.mqEnrichSubscriptions.mockResolvedValue(rows);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -176,3 +197,63 @@ describe("SubscriptionsPanel message peek", () => {
|
|||
expect(panel.textContent).not.toContain("orders message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubscriptionsPanel RocketMQ online members", () => {
|
||||
it("shows '-' when onlineMembers is unknown before enrich completes", async () => {
|
||||
const enrich = deferred<ReturnType<typeof subscription>[]>();
|
||||
const rows = [subscription("orders-group", "NORMAL")];
|
||||
backend.mqListSubscriptions.mockResolvedValue(rows);
|
||||
backend.mqEnrichSubscriptions.mockReturnValueOnce(enrich.promise);
|
||||
|
||||
const panel = await mountPanel({
|
||||
topic: undefined,
|
||||
tenant: "_rocketmq",
|
||||
namespace: "default",
|
||||
mqSystemKind: "rocketmq",
|
||||
supportsPeekMessages: false,
|
||||
});
|
||||
|
||||
// Fast list paints before enrich; unknown member count must not look like healthy zero.
|
||||
const membersCell = () => panel.querySelector('[data-testid="online-members"]')?.textContent?.trim();
|
||||
expect(membersCell()).toBe("-");
|
||||
|
||||
enrich.resolve([{ ...rows[0], onlineMembers: 2, topics: ["orders"] }]);
|
||||
await flushUi();
|
||||
expect(membersCell()).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubscriptionsPanel RocketMQ filter count", () => {
|
||||
it("updates the visible/total count when type filters or search change", async () => {
|
||||
const rows = [subscription("orders-group", "NORMAL"), subscription("payments-fifo", "FIFO"), subscription("CID_SYS_GROUP", "SYSTEM"), subscription("orders-retry", "NORMAL")];
|
||||
backend.mqListSubscriptions.mockResolvedValue(rows);
|
||||
backend.mqEnrichSubscriptions.mockResolvedValue(rows);
|
||||
|
||||
const panel = await mountPanel({
|
||||
topic: undefined,
|
||||
tenant: "_rocketmq",
|
||||
namespace: "default",
|
||||
mqSystemKind: "rocketmq",
|
||||
supportsPeekMessages: false,
|
||||
});
|
||||
|
||||
const count = () => panel.querySelector('[data-testid="subscription-count"]')?.textContent?.replace(/\s+/g, " ").trim();
|
||||
// Default: SYSTEM off → 3 type-filtered groups.
|
||||
expect(count()).toBe("3 / 3");
|
||||
|
||||
const systemCheckbox = [...panel.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find((input) => input.closest("label")?.textContent?.toLowerCase().includes("system"));
|
||||
expect(systemCheckbox).toBeTruthy();
|
||||
expect(systemCheckbox!.checked).toBe(false);
|
||||
systemCheckbox!.click();
|
||||
await flushUi();
|
||||
expect(count()).toBe("4 / 4");
|
||||
|
||||
const search = panel.querySelector<HTMLInputElement>("input.topic-search");
|
||||
expect(search).toBeTruthy();
|
||||
search!.focus();
|
||||
search!.value = "orders";
|
||||
search!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flushUi();
|
||||
expect(count()).toBe("2 / 4");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { MqChannelInfo, MqClientConnectionInfo, NamespaceRef } from "@/type
|
|||
import { mqCloseClientConnection, mqListClientChannels, mqListClientConnections } from "@/lib/backend/api";
|
||||
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -16,6 +17,7 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const connections = ref<MqClientConnectionInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
|
|
@ -48,12 +50,12 @@ function nsRef(namespace?: string): NamespaceRef {
|
|||
return { tenant: RABBITMQ_MQ_TENANT, namespace: namespace ?? "" };
|
||||
}
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqClientConnections.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadConnections() {
|
||||
|
|
@ -112,12 +114,16 @@ function toggleExpanded(connection: MqClientConnectionInfo) {
|
|||
}
|
||||
|
||||
function openCloseDialog(connection: MqClientConnectionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqClientConnections.readOnly");
|
||||
return;
|
||||
}
|
||||
closeTarget.value = connection;
|
||||
showCloseDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmClose() {
|
||||
if (!(await guardWritable(t("mqClientConnections.closeConnection")))) return;
|
||||
const target = closeTarget.value;
|
||||
if (!target) return;
|
||||
const namespace = resolveMqRowNamespace(target, props.namespace);
|
||||
|
|
@ -274,6 +280,8 @@ watch(
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../shared/mqPanel.css";
|
||||
|
||||
.clients-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -442,37 +450,6 @@ td {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
|
|||
|
|
@ -253,6 +253,8 @@ onUnmounted(() => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../shared/mqPanel.css";
|
||||
|
||||
.rabbitmq-monitoring-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -415,21 +417,6 @@ td {
|
|||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { MqUserInfo, MqVhostPermission } from "@/types/mq";
|
|||
import { mqCreateUser, mqDeleteUser, mqGrantUserPermission, mqListNamespaces, mqListUserPermissions, mqListUsers, mqRevokeUserPermission } from "@/lib/backend/api";
|
||||
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -16,6 +17,7 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const users = ref<MqUserInfo[]>([]);
|
||||
const usersLoading = ref(false);
|
||||
|
|
@ -58,12 +60,12 @@ const filteredPermissions = computed(() => {
|
|||
return permissions.value.filter((permission) => permission.user === selectedUser.value);
|
||||
});
|
||||
|
||||
function guardWritable(): boolean {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqUserPermissions.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
|
|
@ -114,7 +116,10 @@ function toggleUserFilter(user: MqUserInfo) {
|
|||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqUserPermissions.readOnly");
|
||||
return;
|
||||
}
|
||||
createForm.value = { name: "", password: "", tagsText: "" };
|
||||
dialogError.value = undefined;
|
||||
showCreateDialog.value = true;
|
||||
|
|
@ -129,6 +134,7 @@ function parseTags(text: string): string[] | undefined {
|
|||
}
|
||||
|
||||
async function handleCreateUser() {
|
||||
if (!(await guardWritable(t("mqUserPermissions.createUser")))) return;
|
||||
const name = createForm.value.name.trim();
|
||||
const password = createForm.value.password;
|
||||
if (!name) {
|
||||
|
|
@ -153,12 +159,16 @@ async function handleCreateUser() {
|
|||
}
|
||||
|
||||
function openDeleteDialog(user: MqUserInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqUserPermissions.readOnly");
|
||||
return;
|
||||
}
|
||||
deleteTarget.value = user;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!(await guardWritable(t("mqUserPermissions.delete")))) return;
|
||||
const target = deleteTarget.value;
|
||||
if (!target) return;
|
||||
deleting.value = true;
|
||||
|
|
@ -175,7 +185,10 @@ async function confirmDelete() {
|
|||
}
|
||||
|
||||
function openGrantDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqUserPermissions.readOnly");
|
||||
return;
|
||||
}
|
||||
const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : "";
|
||||
grantForm.value = {
|
||||
user: selectedUser.value ?? users.value[0]?.name ?? "",
|
||||
|
|
@ -194,6 +207,7 @@ function patternOrDefault(value: string): string | undefined {
|
|||
}
|
||||
|
||||
async function handleGrant() {
|
||||
if (!(await guardWritable(t("mqUserPermissions.grant")))) return;
|
||||
const user = grantForm.value.user.trim();
|
||||
const virtualHost = grantForm.value.virtualHost.trim();
|
||||
if (!user) {
|
||||
|
|
@ -222,12 +236,16 @@ async function handleGrant() {
|
|||
}
|
||||
|
||||
function openRevokeDialog(permission: MqVhostPermission) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqUserPermissions.readOnly");
|
||||
return;
|
||||
}
|
||||
revokeTarget.value = permission;
|
||||
showRevokeDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmRevoke() {
|
||||
if (!(await guardWritable(t("mqUserPermissions.revoke")))) return;
|
||||
const target = revokeTarget.value;
|
||||
if (!target) return;
|
||||
revoking.value = true;
|
||||
|
|
@ -457,6 +475,8 @@ watch(
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../shared/mqPanel.css";
|
||||
|
||||
.user-permissions-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -639,48 +659,6 @@ td {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
@ -721,16 +699,6 @@ button:disabled {
|
|||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { MqPolicyInfo, MqPolicyUpsertRequest } from "@/types/mq";
|
|||
import { mqDeletePolicy, mqListNamespaces, mqListPolicies, mqSetPolicy } from "@/lib/backend/api";
|
||||
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -21,6 +22,7 @@ interface DefinitionEntry {
|
|||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
/** Well-known policy definition keys offered in the editor dropdown. */
|
||||
const COMMON_DEFINITION_KEYS = ["message-ttl", "expires", "max-length", "max-length-bytes", "dead-letter-exchange", "dead-letter-routing-key", "max-age"];
|
||||
|
|
@ -47,12 +49,12 @@ const showDeleteDialog = ref(false);
|
|||
const deleteTarget = ref<MqPolicyInfo>();
|
||||
const deleting = ref(false);
|
||||
|
||||
function guardWritable(): boolean {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqRabbitMqPolicies.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadPolicies() {
|
||||
|
|
@ -88,7 +90,10 @@ function definitionEntriesFrom(policy: MqPolicyInfo): DefinitionEntry[] {
|
|||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqRabbitMqPolicies.readOnly");
|
||||
return;
|
||||
}
|
||||
const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : "";
|
||||
editingPolicy.value = undefined;
|
||||
editForm.value = { name: "", virtualHost: currentVhost || vhosts.value[0] || "", pattern: "", applyTo: "queues", priority: 0 };
|
||||
|
|
@ -98,7 +103,10 @@ function openCreateDialog() {
|
|||
}
|
||||
|
||||
function openEditDialog(policy: MqPolicyInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqRabbitMqPolicies.readOnly");
|
||||
return;
|
||||
}
|
||||
editingPolicy.value = policy;
|
||||
editForm.value = { name: policy.name, virtualHost: policy.vhost, pattern: policy.pattern, applyTo: policy.applyTo || "queues", priority: policy.priority };
|
||||
definitionEntries.value = definitionEntriesFrom(policy);
|
||||
|
|
@ -130,6 +138,7 @@ function buildDefinition(): Record<string, unknown> {
|
|||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!(await guardWritable(t("mqRabbitMqPolicies.save")))) return;
|
||||
const name = editForm.value.name.trim();
|
||||
const virtualHost = editForm.value.virtualHost.trim();
|
||||
const pattern = editForm.value.pattern.trim();
|
||||
|
|
@ -167,12 +176,16 @@ async function handleSave() {
|
|||
}
|
||||
|
||||
function openDeleteDialog(policy: MqPolicyInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqRabbitMqPolicies.readOnly");
|
||||
return;
|
||||
}
|
||||
deleteTarget.value = policy;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!(await guardWritable(t("mqRabbitMqPolicies.delete")))) return;
|
||||
const target = deleteTarget.value;
|
||||
if (!target) return;
|
||||
deleting.value = true;
|
||||
|
|
@ -331,6 +344,8 @@ watch(
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../shared/mqPanel.css";
|
||||
|
||||
.policies-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -467,48 +482,6 @@ td {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
@ -549,16 +522,6 @@ button:disabled {
|
|||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 70vh;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsumerInfo, RocketMqConsumerGroupConfig, SubscriptionInfo, TopicRef } from "@/types/mq";
|
||||
import type { ConsumerInfo, PartitionBacklog, RocketMqConsumerGroupConfig, SubscriptionInfo, TopicRef } from "@/types/mq";
|
||||
import { mqAlterConsumerGroupConfig, mqGetBacklog, mqGetConsumerGroupConfig, mqListConsumers } from "@/lib/backend/api";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { resolveRocketMqConsumerGroupMessageModel, resolveRocketMqConsumerGroupType } from "@/lib/mq/rocketmqConsumerGroupTypes";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
export type RocketMqConsumerGroupDialogKind = "detail" | "config";
|
||||
|
||||
interface TopicConsumeDetail {
|
||||
topic: string;
|
||||
/** Undefined when backlog probe failed — do not render as healthy zero. */
|
||||
delay?: number;
|
||||
lastTimestamp?: number;
|
||||
partitions: PartitionBacklog[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
tenant?: string;
|
||||
|
|
@ -24,11 +34,15 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const loading = ref(false);
|
||||
const dialogError = ref<string>();
|
||||
const terminals = ref<ConsumerInfo[]>([]);
|
||||
const topicBacklogs = ref<Array<{ topic: string; backlog: number }>>([]);
|
||||
const topicConsumeDetails = ref<TopicConsumeDetail[]>([]);
|
||||
/** Drops stale detail/config responses when enrich reloads the open dialog. */
|
||||
let detailLoadSeq = 0;
|
||||
let configLoadSeq = 0;
|
||||
const configForm = ref<RocketMqConsumerGroupConfig>({
|
||||
groupName: "",
|
||||
consumeEnable: true,
|
||||
|
|
@ -67,35 +81,75 @@ function closeDialog() {
|
|||
emit("close");
|
||||
}
|
||||
|
||||
/** Format consume timestamp; treat missing/zero as unavailable (avoid epoch display). */
|
||||
function formatConsumeTimestamp(ms?: number | null): string {
|
||||
if (ms == null || ms <= 0) return "-";
|
||||
const date = new Date(ms);
|
||||
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString();
|
||||
}
|
||||
|
||||
function partitionRowKey(topic: string, partition: PartitionBacklog, index: number): string {
|
||||
return `${topic}:${partition.brokerName ?? ""}:${partition.partition}:${index}`;
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.group || !props.tenant || !props.namespace) return;
|
||||
const seq = ++detailLoadSeq;
|
||||
loading.value = true;
|
||||
dialogError.value = undefined;
|
||||
terminals.value = [];
|
||||
topicBacklogs.value = [];
|
||||
topicConsumeDetails.value = [];
|
||||
try {
|
||||
const topicRef = buildTopicRef("");
|
||||
if (topicRef) {
|
||||
terminals.value = await mqListConsumers(props.connectionId, topicRef, props.group.name);
|
||||
const online = await mqListConsumers(props.connectionId, topicRef, props.group.name);
|
||||
if (seq !== detailLoadSeq) return;
|
||||
terminals.value = online;
|
||||
}
|
||||
const topics = subscribedTopics.value;
|
||||
const backlogRows = await Promise.all(
|
||||
const detailRows = await Promise.all(
|
||||
topics.map(async (topic) => {
|
||||
const ref = buildTopicRef(topic);
|
||||
if (!ref) return { topic, backlog: 0 };
|
||||
if (!ref) {
|
||||
return { topic, partitions: [] as PartitionBacklog[], error: t("mqSubscriptions.invalidTopicScope") };
|
||||
}
|
||||
try {
|
||||
const stats = await mqGetBacklog(props.connectionId, ref, props.group!.name);
|
||||
return { topic, backlog: stats.msgBacklog };
|
||||
} catch {
|
||||
return { topic, backlog: 0 };
|
||||
const partitions = stats.partitions ?? [];
|
||||
// Topic-level last consume time = max of queue lastTimestamp values (> 0).
|
||||
const lastTimestamp = partitions
|
||||
.map((p) => p.lastTimestamp ?? 0)
|
||||
.filter((ts) => ts > 0)
|
||||
.reduce<number | undefined>((max, ts) => (max == null || ts > max ? ts : max), undefined);
|
||||
return {
|
||||
topic,
|
||||
delay: stats.msgBacklog,
|
||||
lastTimestamp,
|
||||
partitions,
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
// Keep offsets empty; omit delay so the row does not look like lag=0.
|
||||
return {
|
||||
topic,
|
||||
partitions: [] as PartitionBacklog[],
|
||||
error: formatError(e) || String(e),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
topicBacklogs.value = backlogRows;
|
||||
if (seq !== detailLoadSeq) return;
|
||||
topicConsumeDetails.value = detailRows;
|
||||
const backlogFailures = detailRows.filter((row) => row.error);
|
||||
if (backlogFailures.length) {
|
||||
dialogError.value = t("mqSubscriptions.backlogPartialFailed", {
|
||||
count: backlogFailures.length,
|
||||
error: backlogFailures[0]?.error ?? "",
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
dialogError.value = formatError(e);
|
||||
if (seq === detailLoadSeq) dialogError.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (seq === detailLoadSeq) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,11 +169,13 @@ function resetConfigForm(groupName: string) {
|
|||
|
||||
async function loadConfig() {
|
||||
if (!props.group) return;
|
||||
const seq = ++configLoadSeq;
|
||||
resetConfigForm(props.group.name);
|
||||
loading.value = true;
|
||||
dialogError.value = undefined;
|
||||
try {
|
||||
const config = await mqGetConsumerGroupConfig(props.connectionId, props.group.name);
|
||||
if (seq !== configLoadSeq) return;
|
||||
configForm.value = {
|
||||
groupName: config.groupName || props.group.name,
|
||||
consumeEnable: config.consumeEnable ?? true,
|
||||
|
|
@ -132,14 +188,15 @@ async function loadConfig() {
|
|||
whichBrokerWhenConsumeSlowly: config.whichBrokerWhenConsumeSlowly ?? 0,
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
dialogError.value = formatError(e);
|
||||
if (seq === configLoadSeq) dialogError.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (seq === configLoadSeq) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!props.group || props.readOnly) return;
|
||||
if (!(await confirmMqWrite(t("mqSubscriptions.editConfig")))) return;
|
||||
loading.value = true;
|
||||
dialogError.value = undefined;
|
||||
try {
|
||||
|
|
@ -163,11 +220,20 @@ async function saveConfig() {
|
|||
}
|
||||
|
||||
watch(
|
||||
() => [props.dialog, props.group?.name],
|
||||
// Detail reloads when enrich fills topics; config must not reset the form on topics-only updates.
|
||||
() => [props.dialog, props.group?.name, (props.group?.topics ?? []).join("\0")] as const,
|
||||
() => {
|
||||
if (props.dialog === "detail") {
|
||||
void loadDetail();
|
||||
} else if (props.dialog === "config") {
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.dialog, props.group?.name] as const,
|
||||
() => {
|
||||
if (props.dialog === "config") {
|
||||
void loadConfig();
|
||||
}
|
||||
},
|
||||
|
|
@ -193,7 +259,7 @@ watch(
|
|||
<span>{{ t("mqSubscriptions.type") }}</span
|
||||
><span>{{ groupTypeLabel }}</span> <span>{{ t("mqSubscriptions.mode") }}</span
|
||||
><span>{{ groupModeLabel }}</span> <span>{{ t("mqSubscriptions.consumers") }}</span
|
||||
><span>{{ group.onlineMembers ?? 0 }}</span>
|
||||
><span>{{ group.onlineMembers == null ? "-" : group.onlineMembers }}</span>
|
||||
<span>{{ t("mqSubscriptions.subscribedTopics") }}</span>
|
||||
<span>{{ subscribedTopics.length ? subscribedTopics.join(", ") : "-" }}</span>
|
||||
</div>
|
||||
|
|
@ -226,21 +292,55 @@ watch(
|
|||
|
||||
<section class="detail-section">
|
||||
<h4>{{ t("mqSubscriptions.consumerConsumeDetail") }}</h4>
|
||||
<div v-if="!topicBacklogs.length" class="panel-placeholder">{{ t("mqSubscriptions.noConsumeDetail") }}</div>
|
||||
<table v-else class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqSubscriptions.operationTopic") }}</th>
|
||||
<th>{{ t("mqSubscriptions.backlog") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in topicBacklogs" :key="row.topic">
|
||||
<td>{{ row.topic }}</td>
|
||||
<td>{{ row.backlog.toLocaleString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="loading && !topicConsumeDetails.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
|
||||
<div v-else-if="!topicConsumeDetails.length" class="panel-placeholder">{{ t("mqSubscriptions.noConsumeDetail") }}</div>
|
||||
<div v-else class="consume-detail-list">
|
||||
<div v-for="detail in topicConsumeDetails" :key="detail.topic" class="consume-topic-block">
|
||||
<div class="consume-topic-header">
|
||||
<span
|
||||
><strong>{{ t("mqSubscriptions.operationTopic") }}:</strong> {{ detail.topic }}</span
|
||||
>
|
||||
<span
|
||||
><strong>{{ t("mqSubscriptions.consumeDelay") }}:</strong> {{ detail.delay == null ? "-" : detail.delay.toLocaleString() }}</span
|
||||
>
|
||||
<span
|
||||
><strong>{{ t("mqSubscriptions.lastConsumeTime") }}:</strong> {{ formatConsumeTimestamp(detail.lastTimestamp) }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="detail.error" class="panel-placeholder panel-placeholder-compact form-error">
|
||||
{{ detail.error }}
|
||||
</div>
|
||||
<div v-else-if="!detail.partitions.length" class="panel-placeholder panel-placeholder-compact">
|
||||
{{ t("mqSubscriptions.noQueueConsumeProgress") }}
|
||||
</div>
|
||||
<div v-else class="detail-table-scroll">
|
||||
<table class="detail-table detail-table-wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqSubscriptions.broker") }}</th>
|
||||
<th>{{ t("mqSubscriptions.queue") }}</th>
|
||||
<th>{{ t("mqSubscriptions.consumerClient") }}</th>
|
||||
<th>{{ t("mqSubscriptions.brokerOffset") }}</th>
|
||||
<th>{{ t("mqSubscriptions.consumerOffset") }}</th>
|
||||
<th>{{ t("mqSubscriptions.diffTotal") }}</th>
|
||||
<th>{{ t("mqSubscriptions.lastTimestamp") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(partition, index) in detail.partitions" :key="partitionRowKey(detail.topic, partition, index)">
|
||||
<td>{{ partition.brokerName || "-" }}</td>
|
||||
<td>{{ partition.partition }}</td>
|
||||
<td>{{ partition.consumerClient || "-" }}</td>
|
||||
<td>{{ partition.endOffset.toLocaleString() }}</td>
|
||||
<td>{{ partition.currentOffset.toLocaleString() }}</td>
|
||||
<td>{{ partition.lag.toLocaleString() }}</td>
|
||||
<td>{{ formatConsumeTimestamp(partition.lastTimestamp) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
|
@ -303,6 +403,8 @@ watch(
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../shared/mqPanel.css";
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -325,7 +427,7 @@ watch(
|
|||
}
|
||||
|
||||
.dialog-wide {
|
||||
max-width: 860px;
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.dialog-header,
|
||||
|
|
@ -354,14 +456,6 @@ watch(
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.detail-sections {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
|
|
@ -388,17 +482,44 @@ watch(
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
.consume-detail-list {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.consume-topic-block {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.consume-topic-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.detail-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.detail-table-wide {
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.detail-table th,
|
||||
.detail-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-table th {
|
||||
|
|
@ -454,6 +575,10 @@ watch(
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-placeholder-compact {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
|
|
@ -463,26 +588,8 @@ watch(
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BrokerNode, ResetPosition, SubscriptionInfo, TopicInfo, TopicRef } from "@/types/mq";
|
||||
import { mqAlterTopicConfig, mqGetTopicInternalStats, mqGetTopicRoute, mqGetTopicStats, mqListSubscriptions, mqResetCursor, mqSkipTopicAccumulation } from "@/lib/backend/api";
|
||||
import { mqAlterTopicConfig, mqEnrichSubscriptions, mqGetTopicInternalStats, mqGetTopicRoute, mqGetTopicStats, mqListSubscriptions, mqResetCursor, mqSkipTopicAccumulation } from "@/lib/backend/api";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
export type RocketMqTopicDialogKind = "status" | "route" | "consumers" | "config" | "reset" | "skip";
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const loading = ref(false);
|
||||
const dialogError = ref<string>();
|
||||
|
|
@ -194,8 +196,13 @@ async function loadResetSubscriptions(topic: TopicRef): Promise<SubscriptionInfo
|
|||
return sortResetSubscriptions(topicSpecific, topic.topic);
|
||||
}
|
||||
// Reset offset uses force=true; fall back to all cluster groups when the topic has no committed offsets yet.
|
||||
// Prefer enrich so subscribed-topic sorting still works after the fast-list path dropped enrich.
|
||||
const clusterWide: TopicRef = { ...topic, topic: "" };
|
||||
return sortResetSubscriptions(await mqListSubscriptions(props.connectionId, clusterWide), topic.topic);
|
||||
try {
|
||||
return sortResetSubscriptions(await mqEnrichSubscriptions(props.connectionId, clusterWide), topic.topic);
|
||||
} catch {
|
||||
return sortResetSubscriptions(await mqListSubscriptions(props.connectionId, clusterWide), topic.topic);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTimeLocal(date: Date): string {
|
||||
|
|
@ -215,7 +222,12 @@ async function loadDialogData() {
|
|||
} else if (props.dialog === "route") {
|
||||
routeRaw.value = await mqGetTopicRoute(props.connectionId, topic);
|
||||
} else if (props.dialog === "consumers") {
|
||||
subscriptions.value = await mqListSubscriptions(props.connectionId, topic);
|
||||
// Topic consumer dialog shows lag; includeLag is on the list path. Enrich fills members when cheap.
|
||||
try {
|
||||
subscriptions.value = await mqEnrichSubscriptions(props.connectionId, topic);
|
||||
} catch {
|
||||
subscriptions.value = await mqListSubscriptions(props.connectionId, topic);
|
||||
}
|
||||
selectedSubscription.value = subscriptions.value[0]?.name ?? "";
|
||||
} else if (props.dialog === "reset") {
|
||||
subscriptions.value = await loadResetSubscriptions(topic);
|
||||
|
|
@ -237,6 +249,7 @@ async function loadDialogData() {
|
|||
async function saveConfig() {
|
||||
const topic = topicRef.value;
|
||||
if (!topic || props.readOnly) return;
|
||||
if (!(await confirmMqWrite(t("mqTopics.actionConfig")))) return;
|
||||
loading.value = true;
|
||||
dialogError.value = undefined;
|
||||
try {
|
||||
|
|
@ -258,6 +271,7 @@ async function saveConfig() {
|
|||
async function confirmSkip() {
|
||||
const topic = topicRef.value;
|
||||
if (!topic || props.readOnly) return;
|
||||
if (!(await confirmMqWrite(t("mqTopics.actionSkip")))) return;
|
||||
if (!confirm(t("mqTopics.confirmSkip", { name: topic.topic }))) return;
|
||||
loading.value = true;
|
||||
dialogError.value = undefined;
|
||||
|
|
@ -275,6 +289,7 @@ async function confirmSkip() {
|
|||
async function confirmReset() {
|
||||
const topic = topicRef.value;
|
||||
if (!topic || props.readOnly || !selectedSubscription.value) return;
|
||||
if (!(await confirmMqWrite(t("mqTopics.actionReset")))) return;
|
||||
loading.value = true;
|
||||
dialogError.value = undefined;
|
||||
try {
|
||||
|
|
@ -496,6 +511,8 @@ watch(
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../shared/mqPanel.css";
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -534,14 +551,6 @@ watch(
|
|||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
|
@ -674,28 +683,6 @@ watch(
|
|||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
select:disabled {
|
||||
|
|
|
|||
|
|
@ -233,13 +233,4 @@ const { t } = useI18n();
|
|||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
/* Shared MQ panel chrome + button system.
|
||||
* Prefer .mq-btn-* in new code; .btn-* aliases keep existing panels consistent. */
|
||||
|
||||
.mq-panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -51,9 +54,20 @@
|
|||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---- Buttons (canonical + legacy aliases) ---- */
|
||||
|
||||
.mq-btn-primary,
|
||||
.mq-btn-secondary,
|
||||
.mq-btn-sm {
|
||||
.mq-btn-sm,
|
||||
.mq-btn-danger,
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
|
|
@ -61,35 +75,115 @@
|
|||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mq-btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
filter 0.15s ease;
|
||||
}
|
||||
|
||||
/* MQ chrome stays on the page surface in light theme — avoid solid --color-primary
|
||||
* fills (often near-black) that clash with toolbar/refresh and table action buttons. */
|
||||
.mq-btn-primary:hover:not(:disabled),
|
||||
.mq-btn-secondary:hover:not(:disabled),
|
||||
.mq-btn-sm:hover:not(:disabled) {
|
||||
.mq-btn-sm:hover:not(:disabled),
|
||||
.btn-primary:hover:not(:disabled),
|
||||
.btn-secondary:hover:not(:disabled),
|
||||
.btn-sm:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.mq-btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
.mq-btn-danger,
|
||||
.mq-btn-sm.mq-btn-danger,
|
||||
.mq-btn-sm.btn-danger,
|
||||
.btn-danger,
|
||||
.btn-sm.btn-danger,
|
||||
.btn-sm.danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.mq-btn-danger:hover:not(:disabled),
|
||||
.mq-btn-sm.mq-btn-danger:hover:not(:disabled),
|
||||
.mq-btn-sm.btn-danger:hover:not(:disabled),
|
||||
.btn-danger:hover:not(:disabled),
|
||||
.btn-sm.btn-danger:hover:not(:disabled),
|
||||
.btn-sm.danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: #fff;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.mq-btn-sm,
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mq-btn-primary:disabled,
|
||||
.mq-btn-secondary:disabled,
|
||||
.mq-btn-sm:disabled {
|
||||
.mq-btn-sm:disabled,
|
||||
.mq-btn-danger:disabled,
|
||||
.btn-primary:disabled,
|
||||
.btn-secondary:disabled,
|
||||
.btn-sm:disabled,
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Icon buttons only — do not style .btn-icon on nested SVG/icons */
|
||||
.mq-btn-icon,
|
||||
button.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.mq-btn-icon:hover:not(:disabled),
|
||||
button.btn-icon:hover:not(:disabled) {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mq-btn-icon:disabled,
|
||||
button.btn-icon:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mq-btn-sm.btn-danger,
|
||||
.mq-btn-sm.mq-btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
.mq-btn-close,
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mq-btn-close:hover,
|
||||
.btn-close:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mq-query-mode-tabs {
|
||||
|
|
@ -154,26 +248,6 @@
|
|||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.mq-btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mq-btn-icon:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mq-spin {
|
||||
display: inline-block;
|
||||
animation: mq-spin-anim 0.8s linear infinite;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -3991,6 +3991,7 @@ export default {
|
|||
sourceDataCompare: "Data compare",
|
||||
sourceExtension: "Extension manager",
|
||||
sourceSidebar: "Object tree",
|
||||
sourceMq: "Message queue",
|
||||
sourceObjectSource: "Object source editor",
|
||||
sourceDataGenerate: "Data generator",
|
||||
sourceQueryHistory: "Query history rollback",
|
||||
|
|
@ -6061,6 +6062,8 @@ export default {
|
|||
viewTenant: "View tenant",
|
||||
viewNamespace: "View namespace",
|
||||
viewTopic: "View topic",
|
||||
connectionMissing: "Connection not found. Reconnect and try again.",
|
||||
writeDeniedReadOnly: "This connection is read-only and cannot perform write operations.",
|
||||
readOnly: "Read-only",
|
||||
allNamespaces: "All namespaces",
|
||||
newNamespace: "New namespace",
|
||||
|
|
@ -6352,8 +6355,13 @@ export default {
|
|||
loading: "Loading...",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing...",
|
||||
enriching: "Loading online details...",
|
||||
truncatedHint: "Showing the first {count} consumer groups (list capped). Narrow the search to find others.",
|
||||
enrichFailedHint: "Online member/topic details could not be loaded ({error}). Showing the fast list only.",
|
||||
noSubscriptions: "No subscriptions for this topic",
|
||||
noConsumerGroups: "No consumer groups",
|
||||
noMatches: "No matching consumer groups",
|
||||
backlogPartialFailed: "Failed to load backlog for {count} topic(s): {error}",
|
||||
viewDetail: "Details",
|
||||
editConfig: "Config",
|
||||
save: "Save",
|
||||
|
|
@ -6364,7 +6372,18 @@ export default {
|
|||
consumerConsumeDetail: "Consumption details",
|
||||
noOnlineConsumers: "No online consumers",
|
||||
noConsumeDetail: "No consumption details",
|
||||
noQueueConsumeProgress: "No queue consumption progress",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "Backlog",
|
||||
invalidTopicScope: "Invalid topic scope",
|
||||
lastConsumeTime: "Last consumption time",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "Consume enabled",
|
||||
consumeBroadcastEnable: "Broadcast consume enabled",
|
||||
consumeFromMinEnable: "Consume from min offset",
|
||||
|
|
@ -6381,6 +6400,7 @@ export default {
|
|||
normal: "Normal",
|
||||
fifo: "Ordered",
|
||||
system: "System",
|
||||
unknown: "Unknown",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "Cluster",
|
||||
|
|
@ -6624,6 +6644,29 @@ export default {
|
|||
searchHint: "Search for messages first, then click View trace",
|
||||
viewTrace: "View trace",
|
||||
detailTitle: "Message trace details",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "Unknown",
|
||||
statusSuccess: "Success",
|
||||
statusFail: "Failed",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store host",
|
||||
fieldClientHost: "Client host",
|
||||
fieldBodyLength: "Body length",
|
||||
fieldCostTime: "Cost",
|
||||
fieldMsgType: "Msg type",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "Retry",
|
||||
fieldContextCode: "Context code",
|
||||
rawPayload: "Raw trace",
|
||||
traceTopicRouteMissing:
|
||||
"The trace topic has no route on NameServer (CODE: 17).\n\nCommon causes:\n1. Broker trace is disabled: set traceTopicEnable=true in broker.conf and restart the broker\n2. Producer/Consumer trace is disabled: call setEnableTrace(true)\n3. A custom trace topic is used: select the correct trace topic above",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5917,6 +5917,8 @@ export default withEnglishFallback({
|
|||
viewTenant: "Ver tenant",
|
||||
viewNamespace: "Ver namespace",
|
||||
viewTopic: "Ver tema",
|
||||
connectionMissing: "No se encontró la conexión. Vuelve a conectar e inténtalo de nuevo.",
|
||||
writeDeniedReadOnly: "Esta conexión es de solo lectura y no puede realizar operaciones de escritura.",
|
||||
readOnly: "Solo lectura",
|
||||
allNamespaces: "Todos los namespaces",
|
||||
newNamespace: "Nuevo namespace",
|
||||
|
|
@ -6258,7 +6260,12 @@ export default withEnglishFallback({
|
|||
searchPlaceholder: "Buscar nombre de suscripción",
|
||||
subscribedTopics: "Topics suscritos",
|
||||
refreshing: "Actualizando...",
|
||||
enriching: "Cargando detalles en línea...",
|
||||
truncatedHint: "Mostrando los primeros {count} grupos de consumidores (límite). Restrinja la búsqueda para encontrar otros.",
|
||||
enrichFailedHint: "No se pudieron cargar los detalles en línea ({error}). Solo se muestra la lista rápida.",
|
||||
noConsumerGroups: "No hay grupos de consumidores",
|
||||
noMatches: "No hay grupos de consumidores coincidentes",
|
||||
backlogPartialFailed: "Error al cargar el backlog de {count} topic(s): {error}",
|
||||
viewDetail: "Detalles",
|
||||
editConfig: "Configurar",
|
||||
save: "Guardar",
|
||||
|
|
@ -6269,7 +6276,18 @@ export default withEnglishFallback({
|
|||
consumerConsumeDetail: "Detalles de consumo",
|
||||
noOnlineConsumers: "No hay consumidores en línea actualmente",
|
||||
noConsumeDetail: "Sin detalles de consumo",
|
||||
noQueueConsumeProgress: "Sin progreso de consumo por cola",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "Backlog",
|
||||
invalidTopicScope: "Ámbito de tema no válido",
|
||||
lastConsumeTime: "Último consumo",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "Permitir consumo",
|
||||
consumeBroadcastEnable: "Permitir consumo en difusión",
|
||||
consumeFromMinEnable: "Comenzar desde el offset mínimo",
|
||||
|
|
@ -6284,6 +6302,7 @@ export default withEnglishFallback({
|
|||
normal: "Normal",
|
||||
fifo: "FIFO",
|
||||
system: "Sistema",
|
||||
unknown: "Desconocido",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "Cluster",
|
||||
|
|
@ -6522,6 +6541,7 @@ export default withEnglishFallback({
|
|||
sourceDataCompare: "Comparación de datos",
|
||||
sourceExtension: "Gestor de extensiones",
|
||||
sourceSidebar: "Árbol de objetos",
|
||||
sourceMq: "Cola de mensajes",
|
||||
sourceObjectSource: "Editor de código fuente de objetos",
|
||||
sourceDataGenerate: "Generador de datos",
|
||||
sourceQueryHistory: "Reversión del historial de consultas",
|
||||
|
|
@ -6916,5 +6936,28 @@ export default withEnglishFallback({
|
|||
detailTitle: "Detalles de la traza de consumo",
|
||||
traceTopicRouteMissing:
|
||||
"El Topic de trazas no tiene información de ruta en NameServer (CODE: 17).\n\nCausas comunes:\n1. Broker no tiene habilitada la traza de mensajes: configure traceTopicEnable=true en broker.conf y reinicie el Broker\n2. Producer/Consumer no tiene habilitada la traza: setEnableTrace(true)\n3. Se utilizó un Topic de trazas personalizado, seleccione el Topic de trazas correcto arriba.",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "Desconocido",
|
||||
statusSuccess: "Correcto",
|
||||
statusFail: "Fallido",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store host",
|
||||
fieldClientHost: "Client host",
|
||||
fieldBodyLength: "Body length",
|
||||
fieldCostTime: "Coste",
|
||||
fieldMsgType: "Msg type",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "Reintentos",
|
||||
fieldContextCode: "Context code",
|
||||
rawPayload: "Traza en bruto",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5917,6 +5917,8 @@ export default withEnglishFallback({
|
|||
viewTenant: "Visualizza tenant",
|
||||
viewNamespace: "Visualizza namespace",
|
||||
viewTopic: "Visualizza topic",
|
||||
connectionMissing: "Connessione non trovata. Riconnetti e riprova.",
|
||||
writeDeniedReadOnly: "La connessione corrente è in modalità sola lettura, impossibile eseguire operazioni di scrittura.",
|
||||
readOnly: "Sola lettura",
|
||||
allNamespaces: "Tutti i namespace",
|
||||
newNamespace: "Nuovo namespace",
|
||||
|
|
@ -6258,7 +6260,12 @@ export default withEnglishFallback({
|
|||
searchPlaceholder: "Cerca nome sottoscrizione",
|
||||
subscribedTopics: "Topic sottoscritti",
|
||||
refreshing: "Aggiornamento in corso...",
|
||||
enriching: "Caricamento dettagli online...",
|
||||
truncatedHint: "Vengono mostrati i primi {count} consumer group (limite). Restringi la ricerca per trovarne altri.",
|
||||
enrichFailedHint: "Impossibile caricare i dettagli online ({error}). Viene mostrata solo l'elenco rapido.",
|
||||
noConsumerGroups: "Nessun Consumer Group",
|
||||
noMatches: "Nessun consumer group corrispondente",
|
||||
backlogPartialFailed: "Caricamento backlog non riuscito per {count} topic: {error}",
|
||||
viewDetail: "Dettagli",
|
||||
editConfig: "Configurazione",
|
||||
save: "Salva",
|
||||
|
|
@ -6269,7 +6276,18 @@ export default withEnglishFallback({
|
|||
consumerConsumeDetail: "Dettagli consumo",
|
||||
noOnlineConsumers: "Nessun consumatore online",
|
||||
noConsumeDetail: "Nessun dettaglio consumo",
|
||||
noQueueConsumeProgress: "Nessun progresso di consumo per coda",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "Backlog",
|
||||
invalidTopicScope: "Ambito topic non valido",
|
||||
lastConsumeTime: "Ultimo consumo",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "Abilita consumo",
|
||||
consumeBroadcastEnable: "Abilita consumo broadcast",
|
||||
consumeFromMinEnable: "Inizia dall'offset minimo",
|
||||
|
|
@ -6284,6 +6302,7 @@ export default withEnglishFallback({
|
|||
normal: "Normale",
|
||||
fifo: "FIFO",
|
||||
system: "Sistema",
|
||||
unknown: "Sconosciuto",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "Cluster",
|
||||
|
|
@ -6522,6 +6541,7 @@ export default withEnglishFallback({
|
|||
sourceDataCompare: "Confronto dati",
|
||||
sourceExtension: "Gestione estensioni",
|
||||
sourceSidebar: "Albero oggetti",
|
||||
sourceMq: "Coda di messaggi",
|
||||
sourceObjectSource: "Editor sorgente oggetto",
|
||||
sourceDataGenerate: "Generatore dati",
|
||||
sourceQueryHistory: "Rollback cronologia query",
|
||||
|
|
@ -6916,5 +6936,28 @@ export default withEnglishFallback({
|
|||
detailTitle: "Dettagli traccia consumo",
|
||||
traceTopicRouteMissing:
|
||||
"Il Topic di traccia non ha informazioni di instradamento sul NameServer (CODE: 17).\n\nCause comuni:\n1. Il Broker non ha abilitato la traccia messaggi: imposta traceTopicEnable=true in broker.conf e riavvia il Broker\n2. Producer/Consumer non hanno abilitato la traccia: setEnableTrace(true)\n3. È stato utilizzato un Topic di traccia personalizzato, seleziona il Topic di traccia corretto sopra.",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "Sconosciuto",
|
||||
statusSuccess: "Riuscito",
|
||||
statusFail: "Non riuscito",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store host",
|
||||
fieldClientHost: "Client host",
|
||||
fieldBodyLength: "Body length",
|
||||
fieldCostTime: "Costo",
|
||||
fieldMsgType: "Msg type",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "Tentativi",
|
||||
fieldContextCode: "Context code",
|
||||
rawPayload: "Traccia grezza",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5972,6 +5972,8 @@ export default withEnglishFallback({
|
|||
viewTenant: "テナントを表示",
|
||||
viewNamespace: "名前空間を表示",
|
||||
viewTopic: "トピックを表示",
|
||||
connectionMissing: "接続が見つかりません。再接続してから再試行してください。",
|
||||
writeDeniedReadOnly: "現在の接続は読み取り専用モードです。書き込み操作は実行できません。",
|
||||
readOnly: "読み取り専用",
|
||||
allNamespaces: "すべての名前空間",
|
||||
newNamespace: "新規名前空間",
|
||||
|
|
@ -6313,7 +6315,12 @@ export default withEnglishFallback({
|
|||
searchPlaceholder: "サブスクリプション名を検索",
|
||||
subscribedTopics: "サブスクライブされた Topic",
|
||||
refreshing: "更新中...",
|
||||
enriching: "オンライン詳細を読み込み中...",
|
||||
truncatedHint: "最初の {count} 件のコンシューマグループのみ表示しています(上限)。検索条件を絞ってください。",
|
||||
enrichFailedHint: "オンラインメンバー/購読 Topic の読み込みに失敗しました({error})。高速一覧のみ表示しています。",
|
||||
noConsumerGroups: "Consumer Group がありません",
|
||||
noMatches: "一致するコンシューマグループがありません",
|
||||
backlogPartialFailed: "{count} 件の Topic のバックログ読み込みに失敗しました: {error}",
|
||||
viewDetail: "詳細",
|
||||
editConfig: "設定",
|
||||
save: "保存",
|
||||
|
|
@ -6324,7 +6331,18 @@ export default withEnglishFallback({
|
|||
consumerConsumeDetail: "消費詳細",
|
||||
noOnlineConsumers: "現在オンラインのコンシューマはいません",
|
||||
noConsumeDetail: "消費詳細がありません",
|
||||
noQueueConsumeProgress: "キューの消費進捗がありません",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "バックログ",
|
||||
invalidTopicScope: "トピックのスコープが無効です",
|
||||
lastConsumeTime: "最終消費時間",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "消費を許可",
|
||||
consumeBroadcastEnable: "ブロードキャスト消費を許可",
|
||||
consumeFromMinEnable: "最小 offset から開始",
|
||||
|
|
@ -6339,6 +6357,7 @@ export default withEnglishFallback({
|
|||
normal: "通常",
|
||||
fifo: "FIFO",
|
||||
system: "システム",
|
||||
unknown: "不明",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "クラスタリング",
|
||||
|
|
@ -6577,6 +6596,7 @@ export default withEnglishFallback({
|
|||
sourceDataCompare: "データ比較",
|
||||
sourceExtension: "拡張機能マネージャー",
|
||||
sourceSidebar: "オブジェクトツリー",
|
||||
sourceMq: "メッセージキュー",
|
||||
sourceObjectSource: "オブジェクトソースエディター",
|
||||
sourceDataGenerate: "データジェネレーター",
|
||||
sourceQueryHistory: "クエリ履歴のロールバック",
|
||||
|
|
@ -6971,5 +6991,28 @@ export default withEnglishFallback({
|
|||
detailTitle: "消費トレース詳細",
|
||||
traceTopicRouteMissing:
|
||||
"トレース Topic が NameServer にルート情報がありません (CODE: 17)。\n\n一般的な原因:\n1. Broker でメッセージトレースが有効になっていない: broker.conf で traceTopicEnable=true を設定し Broker を再起動\n2. Producer/Consumer でトレースが有効になっていない: setEnableTrace(true)\n3. カスタムトレース Topic を使用している場合は、上で正しいトレース Topic を選択してください",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "不明",
|
||||
statusSuccess: "成功",
|
||||
statusFail: "失敗",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store Host",
|
||||
fieldClientHost: "Client Host",
|
||||
fieldBodyLength: "Body Length",
|
||||
fieldCostTime: "所要時間",
|
||||
fieldMsgType: "メッセージ種別",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "リトライ回数",
|
||||
fieldContextCode: "Context Code",
|
||||
rawPayload: "生トレース",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3538,6 +3538,7 @@ export default withEnglishFallback({
|
|||
sourceDataCompare: "데이터 비교",
|
||||
sourceExtension: "확장 관리자",
|
||||
sourceSidebar: "객체 트리",
|
||||
sourceMq: "메시지 큐",
|
||||
sourceObjectSource: "객체 소스 편집기",
|
||||
sourceDataGenerate: "데이터 생성기",
|
||||
sourceQueryHistory: "쿼리 기록 롤백",
|
||||
|
|
@ -5585,6 +5586,8 @@ export default withEnglishFallback({
|
|||
viewTenant: "테넌트 보기",
|
||||
viewNamespace: "네임스페이스 보기",
|
||||
viewTopic: "토픽 보기",
|
||||
connectionMissing: "연결을 찾을 수 없습니다. 다시 연결한 후 시도하세요.",
|
||||
writeDeniedReadOnly: "이 연결은 읽기 전용이며 쓰기 작업을 수행할 수 없습니다.",
|
||||
readOnly: "읽기 전용",
|
||||
allNamespaces: "모든 네임스페이스",
|
||||
newNamespace: "새 네임스페이스",
|
||||
|
|
@ -5876,8 +5879,13 @@ export default withEnglishFallback({
|
|||
loading: "불러오는 중...",
|
||||
refresh: "새로고침",
|
||||
refreshing: "새로고침 중...",
|
||||
enriching: "온라인 상세 불러오는 중...",
|
||||
truncatedHint: "처음 {count}개의 컨슈머 그룹만 표시합니다(상한). 검색 범위를 좁혀 주세요.",
|
||||
enrichFailedHint: "온라인 멤버/구독 토픽 정보를 불러오지 못했습니다({error}). 빠른 목록만 표시합니다.",
|
||||
noSubscriptions: "이 토픽에 구독이 없습니다",
|
||||
noConsumerGroups: "컨슈머 그룹이 없습니다",
|
||||
noMatches: "일치하는 컨슈머 그룹이 없습니다",
|
||||
backlogPartialFailed: "{count}개 토픽의 백로그를 불러오지 못했습니다: {error}",
|
||||
viewDetail: "상세",
|
||||
editConfig: "구성",
|
||||
save: "저장",
|
||||
|
|
@ -5888,7 +5896,18 @@ export default withEnglishFallback({
|
|||
consumerConsumeDetail: "소비 상세",
|
||||
noOnlineConsumers: "온라인 컨슈머가 없습니다",
|
||||
noConsumeDetail: "소비 상세가 없습니다",
|
||||
noQueueConsumeProgress: "큐 소비 진행 정보가 없습니다",
|
||||
operationTopic: "토픽",
|
||||
consumeDelay: "적체",
|
||||
invalidTopicScope: "잘못된 토픽 범위",
|
||||
lastConsumeTime: "마지막 소비 시간",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "소비 활성화",
|
||||
consumeBroadcastEnable: "브로드캐스트 소비 활성화",
|
||||
consumeFromMinEnable: "최소 오프셋부터 소비",
|
||||
|
|
@ -5905,6 +5924,7 @@ export default withEnglishFallback({
|
|||
normal: "일반",
|
||||
fifo: "순서 보장",
|
||||
system: "시스템",
|
||||
unknown: "알 수 없음",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "클러스터",
|
||||
|
|
@ -6149,6 +6169,29 @@ export default withEnglishFallback({
|
|||
detailTitle: "메시지 추적 상세",
|
||||
traceTopicRouteMissing:
|
||||
"추적 토픽이 NameServer에 라우트 정보가 없습니다 (CODE: 17).\n\n일반적인 원인:\n1. Broker 추적이 비활성화됨: broker.conf에서 traceTopicEnable=true를 설정하고 Broker를 다시 시작\n2. Producer/Consumer 추적이 비활성화됨: setEnableTrace(true) 호출\n3. 사용자 지정 추적 토픽 사용: 위에서 올바른 추적 토픽을 선택하세요",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "알 수 없음",
|
||||
statusSuccess: "성공",
|
||||
statusFail: "실패",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store Host",
|
||||
fieldClientHost: "Client Host",
|
||||
fieldBodyLength: "Body Length",
|
||||
fieldCostTime: "소요 시간",
|
||||
fieldMsgType: "메시지 유형",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "재시도",
|
||||
fieldContextCode: "Context Code",
|
||||
rawPayload: "원본 추적",
|
||||
},
|
||||
mqRaw: {
|
||||
title: "원시 API",
|
||||
|
|
|
|||
|
|
@ -5919,6 +5919,8 @@ export default withEnglishFallback({
|
|||
viewTenant: "Ver locatário",
|
||||
viewNamespace: "Ver namespace",
|
||||
viewTopic: "Ver tópico",
|
||||
connectionMissing: "Conexão não encontrada. Reconecte e tente novamente.",
|
||||
writeDeniedReadOnly: "Esta conexão é somente leitura e não pode executar operações de gravação.",
|
||||
readOnly: "Somente leitura",
|
||||
allNamespaces: "Todos os namespaces",
|
||||
newNamespace: "Novo namespace",
|
||||
|
|
@ -6260,7 +6262,12 @@ export default withEnglishFallback({
|
|||
searchPlaceholder: "Pesquisar nome da assinatura",
|
||||
subscribedTopics: "Tópicos inscritos",
|
||||
refreshing: "Atualizando...",
|
||||
enriching: "Carregando detalhes online...",
|
||||
truncatedHint: "Mostrando os primeiros {count} grupos de consumidores (limite). Restrinja a busca para encontrar outros.",
|
||||
enrichFailedHint: "Não foi possível carregar os detalhes online ({error}). Mostrando apenas a lista rápida.",
|
||||
noConsumerGroups: "Nenhum Consumer Group",
|
||||
noMatches: "Nenhum grupo de consumidores correspondente",
|
||||
backlogPartialFailed: "Falha ao carregar backlog de {count} tópico(s): {error}",
|
||||
viewDetail: "Detalhes",
|
||||
editConfig: "Configuração",
|
||||
save: "Salvar",
|
||||
|
|
@ -6271,7 +6278,18 @@ export default withEnglishFallback({
|
|||
consumerConsumeDetail: "Detalhes do consumo",
|
||||
noOnlineConsumers: "Nenhum consumidor online no momento",
|
||||
noConsumeDetail: "Nenhum detalhe de consumo",
|
||||
noQueueConsumeProgress: "Sem progresso de consumo por fila",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "Backlog",
|
||||
invalidTopicScope: "Escopo de tópico inválido",
|
||||
lastConsumeTime: "Último consumo",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "Permitir consumo",
|
||||
consumeBroadcastEnable: "Permitir consumo em broadcast",
|
||||
consumeFromMinEnable: "Iniciar do offset mínimo",
|
||||
|
|
@ -6286,6 +6304,7 @@ export default withEnglishFallback({
|
|||
normal: "Normal",
|
||||
fifo: "Ordenado",
|
||||
system: "Sistema",
|
||||
unknown: "Desconhecido",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "Cluster",
|
||||
|
|
@ -6524,6 +6543,7 @@ export default withEnglishFallback({
|
|||
sourceDataCompare: "Comparação de dados",
|
||||
sourceExtension: "Gerenciador de extensões",
|
||||
sourceSidebar: "Árvore de objetos",
|
||||
sourceMq: "Fila de mensagens",
|
||||
sourceObjectSource: "Editor de código-fonte de objetos",
|
||||
sourceDataGenerate: "Gerador de dados",
|
||||
sourceQueryHistory: "Reversão do histórico de consultas",
|
||||
|
|
@ -6918,5 +6938,28 @@ export default withEnglishFallback({
|
|||
detailTitle: "Detalhes do rastreamento de consumo",
|
||||
traceTopicRouteMissing:
|
||||
"O tópico de rastreamento não possui informações de rota no NameServer (CODE: 17).\n\nCausas comuns:\n1. Broker não habilitou rastreamento de mensagens: defina traceTopicEnable=true no broker.conf e reinicie o Broker\n2. Producer/Consumer não habilitou rastreamento: setEnableTrace(true)\n3. Usou um tópico de rastreamento personalizado, selecione o tópico correto acima.",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "Desconhecido",
|
||||
statusSuccess: "Sucesso",
|
||||
statusFail: "Falhou",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store host",
|
||||
fieldClientHost: "Client host",
|
||||
fieldBodyLength: "Body length",
|
||||
fieldCostTime: "Custo",
|
||||
fieldMsgType: "Msg type",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "Tentativas",
|
||||
fieldContextCode: "Context code",
|
||||
rawPayload: "Rastreamento bruto",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3990,6 +3990,7 @@ export default withEnglishFallback({
|
|||
sourceDataCompare: "数据对比",
|
||||
sourceExtension: "扩展管理",
|
||||
sourceSidebar: "对象树",
|
||||
sourceMq: "消息队列",
|
||||
sourceObjectSource: "对象源码编辑器",
|
||||
sourceDataGenerate: "数据生成器",
|
||||
sourceQueryHistory: "查询历史回滚",
|
||||
|
|
@ -6058,6 +6059,8 @@ export default withEnglishFallback({
|
|||
viewTenant: "查看租户",
|
||||
viewNamespace: "查看命名空间",
|
||||
viewTopic: "查看主题",
|
||||
connectionMissing: "未找到连接,请重新连接后再试。",
|
||||
writeDeniedReadOnly: "当前连接为只读模式,不能执行写操作。",
|
||||
readOnly: "只读",
|
||||
allNamespaces: "全部命名空间",
|
||||
newNamespace: "新建命名空间",
|
||||
|
|
@ -6344,6 +6347,9 @@ export default withEnglishFallback({
|
|||
createSubscription: "创建订阅",
|
||||
selectTopicFirst: "请先选择一个主题",
|
||||
searchPlaceholder: "搜索订阅名称",
|
||||
enriching: "正在补全在线信息...",
|
||||
truncatedHint: "当前仅显示前 {count} 个消费组(已达上限),请缩小搜索范围查找其他组",
|
||||
enrichFailedHint: "在线成员/订阅 Topic 信息加载失败({error}),当前仅显示快速列表",
|
||||
subscribedTopics: "订阅 Topic",
|
||||
readOnly: "当前连接为只读模式,不能执行写操作",
|
||||
loading: "加载中...",
|
||||
|
|
@ -6351,6 +6357,8 @@ export default withEnglishFallback({
|
|||
refreshing: "刷新中...",
|
||||
noSubscriptions: "该主题暂无订阅",
|
||||
noConsumerGroups: "暂无 Consumer Group",
|
||||
noMatches: "没有匹配的 Consumer Group",
|
||||
backlogPartialFailed: "{count} 个 Topic 的堆积信息加载失败:{error}",
|
||||
viewDetail: "详情",
|
||||
editConfig: "配置",
|
||||
save: "保存",
|
||||
|
|
@ -6361,7 +6369,18 @@ export default withEnglishFallback({
|
|||
consumerConsumeDetail: "消费详情",
|
||||
noOnlineConsumers: "当前没有在线消费者",
|
||||
noConsumeDetail: "暂无消费详情",
|
||||
noQueueConsumeProgress: "暂无队列消费进度",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "积压",
|
||||
invalidTopicScope: "无效的 Topic 作用域",
|
||||
lastConsumeTime: "最后消费时间",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "允许消费",
|
||||
consumeBroadcastEnable: "允许广播消费",
|
||||
consumeFromMinEnable: "从最小 offset 开始",
|
||||
|
|
@ -6378,6 +6397,7 @@ export default withEnglishFallback({
|
|||
normal: "普通",
|
||||
fifo: "顺序",
|
||||
system: "系统",
|
||||
unknown: "未知",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "集群",
|
||||
|
|
@ -6620,6 +6640,29 @@ export default withEnglishFallback({
|
|||
searchHint: "请先搜索消息,再点击「查看消费轨迹」",
|
||||
viewTrace: "查看消费轨迹",
|
||||
detailTitle: "消费轨迹详情",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "未知",
|
||||
statusSuccess: "成功",
|
||||
statusFail: "失败",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store Host",
|
||||
fieldClientHost: "Client Host",
|
||||
fieldBodyLength: "Body Length",
|
||||
fieldCostTime: "耗时",
|
||||
fieldMsgType: "消息类型",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "重试次数",
|
||||
fieldContextCode: "Context Code",
|
||||
rawPayload: "原始轨迹",
|
||||
traceTopicRouteMissing: "轨迹 Topic 在 NameServer 上没有路由信息(CODE: 17)。\n\n常见原因:\n1. Broker 未开启消息轨迹:在 broker.conf 中设置 traceTopicEnable=true 并重启 Broker\n2. Producer/Consumer 未开启轨迹:setEnableTrace(true)\n3. 使用了自定义轨迹 Topic,请在上方选择正确的轨迹 Topic",
|
||||
},
|
||||
mqRaw: {
|
||||
|
|
|
|||
|
|
@ -5912,6 +5912,8 @@ export default withEnglishFallback({
|
|||
viewTenant: "檢視租用戶",
|
||||
viewNamespace: "檢視命名空間",
|
||||
viewTopic: "檢視主題",
|
||||
connectionMissing: "找不到連線,請重新連線後再試。",
|
||||
writeDeniedReadOnly: "目前連線為唯讀模式,無法執行寫入操作。",
|
||||
readOnly: "唯讀",
|
||||
allNamespaces: "全部命名空間",
|
||||
newNamespace: "新建命名空間",
|
||||
|
|
@ -6253,7 +6255,12 @@ export default withEnglishFallback({
|
|||
searchPlaceholder: "搜尋訂閱名稱",
|
||||
subscribedTopics: "訂閱 Topic",
|
||||
refreshing: "重新整理中...",
|
||||
enriching: "正在載入線上詳情...",
|
||||
truncatedHint: "目前僅顯示前 {count} 個消費組(已達上限),請縮小搜尋範圍查找其他組",
|
||||
enrichFailedHint: "線上成員/訂閱 Topic 資訊載入失敗({error}),目前僅顯示快速列表",
|
||||
noConsumerGroups: "暫無 Consumer Group",
|
||||
noMatches: "沒有符合的消費組",
|
||||
backlogPartialFailed: "{count} 個 Topic 的堆積資訊載入失敗:{error}",
|
||||
viewDetail: "詳情",
|
||||
editConfig: "配置",
|
||||
save: "儲存",
|
||||
|
|
@ -6264,7 +6271,18 @@ export default withEnglishFallback({
|
|||
consumerConsumeDetail: "消費詳情",
|
||||
noOnlineConsumers: "當前沒有線上消費者",
|
||||
noConsumeDetail: "暫無消費詳情",
|
||||
noQueueConsumeProgress: "暫無佇列消費進度",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "積壓",
|
||||
invalidTopicScope: "無效的主題作用域",
|
||||
lastConsumeTime: "最後消費時間",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "允許消費",
|
||||
consumeBroadcastEnable: "允許廣播消費",
|
||||
consumeFromMinEnable: "從最小 offset 開始",
|
||||
|
|
@ -6279,6 +6297,7 @@ export default withEnglishFallback({
|
|||
normal: "普通",
|
||||
fifo: "順序",
|
||||
system: "系統",
|
||||
unknown: "未知",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "集群",
|
||||
|
|
@ -6517,6 +6536,7 @@ export default withEnglishFallback({
|
|||
sourceDataCompare: "資料比對",
|
||||
sourceExtension: "擴充功能管理",
|
||||
sourceSidebar: "物件樹",
|
||||
sourceMq: "訊息佇列",
|
||||
sourceObjectSource: "物件原始碼編輯器",
|
||||
sourceDataGenerate: "資料產生器",
|
||||
sourceQueryHistory: "查詢歷史回復",
|
||||
|
|
@ -6909,6 +6929,29 @@ export default withEnglishFallback({
|
|||
searchHint: "請先搜尋消息,再點擊「檢視消費軌跡」",
|
||||
viewTrace: "檢視消費軌跡",
|
||||
detailTitle: "消費軌跡詳情",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "未知",
|
||||
statusSuccess: "成功",
|
||||
statusFail: "失敗",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store Host",
|
||||
fieldClientHost: "Client Host",
|
||||
fieldBodyLength: "Body Length",
|
||||
fieldCostTime: "耗時",
|
||||
fieldMsgType: "訊息類型",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "重試次數",
|
||||
fieldContextCode: "Context Code",
|
||||
rawPayload: "原始軌跡",
|
||||
traceTopicRouteMissing: "軌跡 Topic 在 NameServer 上沒有路由資訊(CODE: 17)。\n\n常見原因:\n1. Broker 未開啟消息軌跡:在 broker.conf 中設置 traceTopicEnable=true 並重新啟動 Broker\n2. Producer/Consumer 未開啟軌跡:setEnableTrace(true)\n3. 使用了自定義軌跡 Topic,請在上方選擇正確的軌跡 Topic",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -489,6 +489,7 @@ export const mqListClientConnections = forward("mqListClientConnections");
|
|||
export const mqListClientChannels = forward("mqListClientChannels");
|
||||
export const mqCloseClientConnection = forward("mqCloseClientConnection");
|
||||
export const mqListSubscriptions = forward("mqListSubscriptions");
|
||||
export const mqEnrichSubscriptions = forward("mqEnrichSubscriptions");
|
||||
export const mqCreateSubscription = forward("mqCreateSubscription");
|
||||
export const mqDeleteSubscription = forward("mqDeleteSubscription");
|
||||
export const mqSkipMessages = forward("mqSkipMessages");
|
||||
|
|
|
|||
|
|
@ -217,6 +217,10 @@ export async function mqListSubscriptions(connectionId: string, topic: TopicRef)
|
|||
return post("/api/mq/subscriptions/list", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqEnrichSubscriptions(connectionId: string, topic: TopicRef): Promise<SubscriptionInfo[]> {
|
||||
return post("/api/mq/subscriptions/enrich", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
|
||||
return post("/api/mq/subscriptions/create", { connectionId, topic, sub, pos });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,6 +211,10 @@ export async function mqListSubscriptions(connectionId: string, topic: TopicRef)
|
|||
return invoke("mq_list_subscriptions", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqEnrichSubscriptions(connectionId: string, topic: TopicRef): Promise<SubscriptionInfo[]> {
|
||||
return invoke("mq_enrich_subscriptions", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
|
||||
return invoke("mq_create_subscription", { connectionId, topic, sub, pos });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,15 @@ describe("rocketmqConsumerGroupTypes", () => {
|
|||
it("resolves group type from consumerGroupType or subType", () => {
|
||||
expect(resolveRocketMqConsumerGroupType({ consumerGroupType: "FIFO", subType: "NORMAL" })).toBe("FIFO");
|
||||
expect(resolveRocketMqConsumerGroupType({ subType: "SYSTEM" })).toBe("SYSTEM");
|
||||
expect(resolveRocketMqConsumerGroupType({ consumerGroupType: "UNKNOWN" })).toBe("UNKNOWN");
|
||||
expect(resolveRocketMqConsumerGroupType({ subType: "CONSUME_PASSIVELY ? CLUSTERING" })).toBe("NORMAL");
|
||||
});
|
||||
|
||||
it("filters by dashboard group types", () => {
|
||||
const filters = { NORMAL: true, FIFO: true, SYSTEM: false };
|
||||
const filters = { NORMAL: true, FIFO: true, SYSTEM: false, UNKNOWN: true };
|
||||
expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "NORMAL", subType: "NORMAL" }, filters)).toBe(true);
|
||||
expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "SYSTEM", subType: "SYSTEM" }, filters)).toBe(false);
|
||||
expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "UNKNOWN", subType: "UNKNOWN" }, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves message model", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { formatRocketMqTraceError, isRocketMqTraceTopicRouteMissingError } from "@/lib/mq/rocketmqTraceUtils";
|
||||
import { formatRocketMqTraceError, isParsedRocketMqTraceRecord, isRocketMqTraceTopicRouteMissingError, parseRocketMqTracePayload, splitRocketMqTraceKeys } from "@/lib/mq/rocketmqTraceUtils";
|
||||
|
||||
const SOH = "\u0001";
|
||||
const STX = "\u0002";
|
||||
|
||||
function joinFields(...parts: string[]): string {
|
||||
return parts.join(SOH);
|
||||
}
|
||||
|
||||
describe("rocketmqTraceUtils", () => {
|
||||
it("detects RocketMQ trace topic route missing errors", () => {
|
||||
|
|
@ -11,4 +18,75 @@ describe("rocketmqTraceUtils", () => {
|
|||
const hint = "Enable traceTopicEnable on broker";
|
||||
expect(formatRocketMqTraceError(new Error("CODE: 17 DESC: No topic route info in name server for the topic: RMQ_SYS_TRACE_TOPIC"), hint)).toBe(hint);
|
||||
});
|
||||
|
||||
it("parses Pub records including optional clientHost", () => {
|
||||
const payload = joinFields(
|
||||
"Pub",
|
||||
"1785728122381",
|
||||
"DefaultRegion",
|
||||
"api-orch-server_producer",
|
||||
"SIGNATURE_OPERATE_LOG_TOPIC_DEV",
|
||||
"AC12021900073BCED0D20CC95A0D0197",
|
||||
"ADD_SIGNATURE_OPERATE_LOG_TAG",
|
||||
"AC12021900073BCED0D20CC95A0D0197 AC12021900073BCED0D20CC95BFF019F",
|
||||
"192.168.3.25:10911",
|
||||
"512",
|
||||
"4",
|
||||
"0",
|
||||
"AC12021900073BCED0D20CC95A0D0197",
|
||||
"true",
|
||||
"192.168.2.25",
|
||||
);
|
||||
|
||||
const [record] = parseRocketMqTracePayload(payload);
|
||||
expect(record.type).toBe("Pub");
|
||||
expect(record.timestamp).toBe(1785728122381);
|
||||
expect(record.success).toBe(true);
|
||||
expect(isParsedRocketMqTraceRecord(record)).toBe(true);
|
||||
expect(record.fields.find((field) => field.key === "group")?.value).toBe("api-orch-server_producer");
|
||||
expect(record.fields.find((field) => field.key === "topic")?.value).toBe("SIGNATURE_OPERATE_LOG_TOPIC_DEV");
|
||||
expect(record.fields.find((field) => field.key === "costTime")?.value).toBe("4 ms");
|
||||
expect(record.fields.find((field) => field.key === "clientHost")?.value).toBe("192.168.2.25");
|
||||
});
|
||||
|
||||
it("parses SubBefore and SubAfter with version-tolerant lengths", () => {
|
||||
const subBefore = joinFields("SubBefore", "1785728123000", "DefaultRegion", "LISTING_AGREEMENT_SYNC_TOPIC_LISTING_FULL_AGREEMENT_CONSUMER_DEV", "req-1", "AC12021900073BCED0D20CC95BFF019F", "0", "null", "192.168.2.30");
|
||||
const subAfterOld = joinFields("SubAfter", "req-1", "AC12021900073BCED0D20CC95BFF019F", "12", "true", "k1", "0");
|
||||
const subAfterNew = joinFields("SubAfter", "req-2", "AC12021900073BCED0D20CC95BFF019F", "20", "false", "k2", "1", "1785728123120", "LISTING_AGREEMENT_SYNC_TOPIC_LISTING_FULL_AGREEMENT_CONSUMER_DEV");
|
||||
|
||||
const records = parseRocketMqTracePayload(`${subBefore}${STX}${subAfterOld}${STX}${subAfterNew}`);
|
||||
expect(records).toHaveLength(3);
|
||||
expect(records[0].type).toBe("SubBefore");
|
||||
expect(records[0].fields.find((field) => field.key === "retryTimes")?.value).toBe("0");
|
||||
expect(records[0].fields.find((field) => field.key === "keys")?.value).toBe("-");
|
||||
expect(records[0].fields.find((field) => field.key === "clientHost")?.value).toBe("192.168.2.30");
|
||||
|
||||
expect(records[1].type).toBe("SubAfter");
|
||||
expect(records[1].success).toBe(true);
|
||||
expect(records[1].timestamp).toBeUndefined();
|
||||
expect(records[1].fields.find((field) => field.key === "costTime")?.value).toBe("12 ms");
|
||||
|
||||
expect(records[2].type).toBe("SubAfter");
|
||||
expect(records[2].success).toBe(false);
|
||||
expect(records[2].timestamp).toBe(1785728123120);
|
||||
expect(records[2].fields.find((field) => field.key === "group")?.value).toBe("LISTING_AGREEMENT_SYNC_TOPIC_LISTING_FULL_AGREEMENT_CONSUMER_DEV");
|
||||
});
|
||||
|
||||
it("normalizes Type@ prefix and falls back for unknown payloads", () => {
|
||||
const withAt = `Pub@1785728122381${SOH}DefaultRegion${SOH}g${SOH}t${SOH}m${SOH}tag${SOH}k${SOH}host${SOH}1${SOH}2${SOH}0${SOH}off${SOH}true`;
|
||||
const [parsed] = parseRocketMqTracePayload(withAt);
|
||||
expect(parsed.type).toBe("Pub");
|
||||
expect(parsed.fields.find((field) => field.key === "group")?.value).toBe("g");
|
||||
|
||||
const [unknown] = parseRocketMqTracePayload("not-a-trace-payload");
|
||||
expect(unknown.type).toBe("Unknown");
|
||||
expect(isParsedRocketMqTraceRecord(unknown)).toBe(false);
|
||||
expect(unknown.raw).toBe("not-a-trace-payload");
|
||||
});
|
||||
|
||||
it("splits KEYS into chips", () => {
|
||||
expect(splitRocketMqTraceKeys("a b,c")).toEqual(["a", "b", "c"]);
|
||||
expect(splitRocketMqTraceKeys("null")).toEqual([]);
|
||||
expect(splitRocketMqTraceKeys("-")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import type { SubscriptionInfo } from "@/types/mq";
|
||||
|
||||
export type RocketMqConsumerGroupType = "NORMAL" | "FIFO" | "SYSTEM";
|
||||
export type RocketMqConsumerGroupType = "NORMAL" | "FIFO" | "SYSTEM" | "UNKNOWN";
|
||||
|
||||
export const ROCKETMQ_CONSUMER_GROUP_TYPES: readonly RocketMqConsumerGroupType[] = ["NORMAL", "FIFO", "SYSTEM"];
|
||||
export const ROCKETMQ_CONSUMER_GROUP_TYPES: readonly RocketMqConsumerGroupType[] = ["NORMAL", "FIFO", "SYSTEM", "UNKNOWN"];
|
||||
|
||||
export const DEFAULT_ROCKETMQ_CONSUMER_GROUP_TYPE_FILTERS: Record<RocketMqConsumerGroupType, boolean> = {
|
||||
NORMAL: true,
|
||||
FIFO: true,
|
||||
SYSTEM: false,
|
||||
UNKNOWN: true,
|
||||
};
|
||||
|
||||
export function resolveRocketMqConsumerGroupType(sub: Pick<SubscriptionInfo, "consumerGroupType" | "subType">): RocketMqConsumerGroupType {
|
||||
const raw = (sub.consumerGroupType ?? sub.subType)?.trim().toUpperCase();
|
||||
if (raw === "FIFO" || raw === "ORDER" || raw === "ORDERLY") return "FIFO";
|
||||
if (raw === "SYSTEM") return "SYSTEM";
|
||||
if (raw === "UNKNOWN") return "UNKNOWN";
|
||||
if (raw === "NORMAL") return "NORMAL";
|
||||
return "NORMAL";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { formatRocketMqTimestamp } from "@/lib/mq/rocketmqMessageUtils";
|
||||
|
||||
/** RocketMQ CODE 17: trace topic has no route on NameServer (trace not enabled or wrong topic). */
|
||||
export function isRocketMqTraceTopicRouteMissingError(error: unknown): boolean {
|
||||
|
|
@ -12,3 +13,184 @@ export function formatRocketMqTraceError(error: unknown, traceTopicMissingHint:
|
|||
}
|
||||
return formatError(error);
|
||||
}
|
||||
|
||||
/** Content / field splitters from Apache RocketMQ TraceConstants. */
|
||||
const CONTENT_SPLITTER = "\u0001";
|
||||
const FIELD_SPLITTER = "\u0002";
|
||||
|
||||
export type RocketMqTraceType = "Pub" | "SubBefore" | "SubAfter" | "EndTransaction" | "Unknown";
|
||||
|
||||
/** Stable field keys mapped to mqTrace.field* i18n labels in the dialog. */
|
||||
export type RocketMqTraceFieldKey = "regionId" | "group" | "topic" | "msgId" | "tags" | "keys" | "storeHost" | "clientHost" | "bodyLength" | "costTime" | "msgType" | "offsetMsgId" | "requestId" | "retryTimes" | "contextCode";
|
||||
|
||||
export interface RocketMqTraceField {
|
||||
key: RocketMqTraceFieldKey;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface RocketMqTraceRecord {
|
||||
type: RocketMqTraceType;
|
||||
timestamp?: number;
|
||||
/** Parsed success flag when present (Pub / SubAfter). */
|
||||
success?: boolean;
|
||||
fields: RocketMqTraceField[];
|
||||
raw: string;
|
||||
}
|
||||
|
||||
function emptyDisplay(value: string | undefined): string {
|
||||
if (value == null) return "-";
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === "null") return "-";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseTimestamp(raw: string | undefined): number | undefined {
|
||||
if (!raw?.trim()) return undefined;
|
||||
const numeric = Number(raw);
|
||||
return Number.isFinite(numeric) ? numeric : undefined;
|
||||
}
|
||||
|
||||
function parseSuccess(raw: string | undefined): boolean | undefined {
|
||||
if (raw == null || !raw.trim()) return undefined;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === "true") return true;
|
||||
if (normalized === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatCostTime(raw: string | undefined): string {
|
||||
const value = emptyDisplay(raw);
|
||||
if (value === "-") return value;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return value;
|
||||
return `${numeric} ms`;
|
||||
}
|
||||
|
||||
function pushField(fields: RocketMqTraceField[], key: RocketMqTraceFieldKey, value: string | undefined): void {
|
||||
const display = key === "costTime" ? formatCostTime(value) : emptyDisplay(value);
|
||||
// Keep tags/keys/retry visible even when empty so Pub/Sub cards stay scannable.
|
||||
if (display === "-" && key !== "tags" && key !== "keys" && key !== "retryTimes") return;
|
||||
fields.push({ key, value: display });
|
||||
}
|
||||
|
||||
/** Normalize rare "Type@fields" prefix into official Type\\u0001fields form. */
|
||||
function normalizeTraceSegment(segment: string): string {
|
||||
return segment.replace(/^(Pub|SubBefore|SubAfter|EndTransaction)@/, `$1${CONTENT_SPLITTER}`);
|
||||
}
|
||||
|
||||
function parsePub(parts: string[], raw: string): RocketMqTraceRecord {
|
||||
// Pub: type, ts, region, group, topic, msgId, tags, keys, storeHost, bodyLength, cost, msgType, offsetMsgId, success[, clientHost]
|
||||
const timestamp = parseTimestamp(parts[1]);
|
||||
const success = parseSuccess(parts[13]);
|
||||
const fields: RocketMqTraceField[] = [];
|
||||
pushField(fields, "regionId", parts[2]);
|
||||
pushField(fields, "group", parts[3]);
|
||||
pushField(fields, "topic", parts[4]);
|
||||
pushField(fields, "msgId", parts[5]);
|
||||
pushField(fields, "tags", parts[6]);
|
||||
pushField(fields, "keys", parts[7]);
|
||||
pushField(fields, "storeHost", parts[8]);
|
||||
pushField(fields, "bodyLength", parts[9]);
|
||||
pushField(fields, "costTime", parts[10]);
|
||||
pushField(fields, "msgType", parts[11]);
|
||||
pushField(fields, "offsetMsgId", parts[12]);
|
||||
if (parts.length >= 15) pushField(fields, "clientHost", parts[14]);
|
||||
return { type: "Pub", timestamp, success, fields, raw };
|
||||
}
|
||||
|
||||
function parseSubBefore(parts: string[], raw: string): RocketMqTraceRecord {
|
||||
// SubBefore: type, ts, region, group, requestId, msgId, retryTimes, keys[, clientHost]
|
||||
// Some encoders also append storeHost before clientHost (length >= 9/10).
|
||||
const timestamp = parseTimestamp(parts[1]);
|
||||
const fields: RocketMqTraceField[] = [];
|
||||
pushField(fields, "regionId", parts[2]);
|
||||
pushField(fields, "group", parts[3]);
|
||||
pushField(fields, "requestId", parts[4]);
|
||||
pushField(fields, "msgId", parts[5]);
|
||||
pushField(fields, "retryTimes", parts[6]);
|
||||
pushField(fields, "keys", parts[7]);
|
||||
if (parts.length >= 10) {
|
||||
pushField(fields, "storeHost", parts[8]);
|
||||
pushField(fields, "clientHost", parts[9]);
|
||||
} else if (parts.length >= 9) {
|
||||
pushField(fields, "clientHost", parts[8]);
|
||||
}
|
||||
return { type: "SubBefore", timestamp, fields, raw };
|
||||
}
|
||||
|
||||
function parseSubAfter(parts: string[], raw: string): RocketMqTraceRecord {
|
||||
// SubAfter: type, requestId, msgId, cost, success, keys, contextCode[, timestamp, groupName]
|
||||
const success = parseSuccess(parts[4]);
|
||||
const timestamp = parts.length >= 8 ? parseTimestamp(parts[7]) : undefined;
|
||||
const fields: RocketMqTraceField[] = [];
|
||||
pushField(fields, "requestId", parts[1]);
|
||||
pushField(fields, "msgId", parts[2]);
|
||||
pushField(fields, "costTime", parts[3]);
|
||||
pushField(fields, "keys", parts[5]);
|
||||
pushField(fields, "contextCode", parts[6]);
|
||||
if (parts.length >= 9) pushField(fields, "group", parts[8]);
|
||||
return { type: "SubAfter", timestamp, success, fields, raw };
|
||||
}
|
||||
|
||||
function parseEndTransaction(parts: string[], raw: string): RocketMqTraceRecord {
|
||||
// EndTransaction: type, ts, region, group, topic, msgId, tags, keys, storeHost, msgType, clientHost, ...
|
||||
const timestamp = parseTimestamp(parts[1]);
|
||||
const fields: RocketMqTraceField[] = [];
|
||||
pushField(fields, "regionId", parts[2]);
|
||||
pushField(fields, "group", parts[3]);
|
||||
pushField(fields, "topic", parts[4]);
|
||||
pushField(fields, "msgId", parts[5]);
|
||||
pushField(fields, "tags", parts[6]);
|
||||
pushField(fields, "keys", parts[7]);
|
||||
pushField(fields, "storeHost", parts[8]);
|
||||
pushField(fields, "msgType", parts[9]);
|
||||
if (parts.length >= 11) pushField(fields, "clientHost", parts[10]);
|
||||
return { type: "EndTransaction", timestamp, fields, raw };
|
||||
}
|
||||
|
||||
function parseTraceSegment(segment: string): RocketMqTraceRecord {
|
||||
const raw = segment;
|
||||
const normalized = normalizeTraceSegment(segment.trim());
|
||||
if (!normalized) {
|
||||
return { type: "Unknown", fields: [], raw };
|
||||
}
|
||||
const parts = normalized.split(CONTENT_SPLITTER);
|
||||
const type = parts[0]?.trim();
|
||||
if (type === "Pub" && parts.length >= 14) return parsePub(parts, raw);
|
||||
if (type === "SubBefore" && parts.length >= 8) return parseSubBefore(parts, raw);
|
||||
if (type === "SubAfter" && parts.length >= 7) return parseSubAfter(parts, raw);
|
||||
if (type === "EndTransaction" && parts.length >= 9) return parseEndTransaction(parts, raw);
|
||||
return { type: "Unknown", fields: [], raw };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RocketMQ trace message body into structured records.
|
||||
* Official format: Type\\u0001field...\\u0002Type\\u0001...
|
||||
*/
|
||||
export function parseRocketMqTracePayload(payload: string): RocketMqTraceRecord[] {
|
||||
if (!payload?.trim()) return [];
|
||||
const segments = payload
|
||||
.split(FIELD_SPLITTER)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
// Body may be a single record without trailing STX.
|
||||
const units = segments.length ? segments : [payload.trim()];
|
||||
return units.map(parseTraceSegment);
|
||||
}
|
||||
|
||||
export function isParsedRocketMqTraceRecord(record: RocketMqTraceRecord): boolean {
|
||||
return record.type !== "Unknown" && record.fields.length > 0;
|
||||
}
|
||||
|
||||
/** Split KEYS header / keys field into display chips. */
|
||||
export function splitRocketMqTraceKeys(value: string | undefined): string[] {
|
||||
if (!value?.trim() || value.trim() === "-" || value.trim() === "null") return [];
|
||||
return value
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function rocketMqTraceRecordTimeLabel(record: RocketMqTraceRecord, fallbackTimestamp?: number): string {
|
||||
return formatRocketMqTimestamp(record.timestamp ?? fallbackTimestamp);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,8 @@ export interface SubscriptionInfo {
|
|||
consumerGroupType?: string;
|
||||
/** RocketMQ consumer group message model: CLUSTERING / BROADCASTING. */
|
||||
messageModel?: string;
|
||||
/** When true, backlog probe failed — do not render msgBacklog as healthy zero. */
|
||||
backlogUnavailable?: boolean;
|
||||
}
|
||||
|
||||
export interface RocketMqConsumerGroupConfig {
|
||||
|
|
@ -228,9 +230,25 @@ export type ResetPosition = { kind: "earliest" } | { kind: "latest" } | { kind:
|
|||
|
||||
export type SkipCount = { kind: "all" } | { kind: "count"; count: number };
|
||||
|
||||
/** Per-queue consume progress (RocketMQ Dashboard consume-detail / Kafka lag rows). */
|
||||
export interface PartitionBacklog {
|
||||
partition: number;
|
||||
/** Consumer committed offset (`consumerOffset`). */
|
||||
currentOffset: number;
|
||||
/** Broker max offset (`brokerOffset`). */
|
||||
endOffset: number;
|
||||
lag: number;
|
||||
brokerName?: string;
|
||||
/** Last consume message store timestamp (ms). `0` means unavailable. */
|
||||
lastTimestamp?: number;
|
||||
consumerClient?: string;
|
||||
}
|
||||
|
||||
export interface BacklogStats {
|
||||
msgBacklog: number;
|
||||
backlogSize: number;
|
||||
/** Optional queue-level progress; omitted or empty when adapter only exposes totals. */
|
||||
partitions?: PartitionBacklog[];
|
||||
}
|
||||
|
||||
export interface ClusterInfo {
|
||||
|
|
|
|||
|
|
@ -1292,6 +1292,12 @@ impl AppState {
|
|||
if wait_for_drain {
|
||||
self.wait_for_pool_drain(&pool_key).await;
|
||||
}
|
||||
#[cfg(feature = "mq-admin")]
|
||||
let mq_keepalive_adapter = if matches!(&pool, PoolKind::MessageQueue) {
|
||||
self.mq_registry.get_cached_adapter(&config.id).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let routing = self.pool_routing_control();
|
||||
let previous = loop {
|
||||
let mut connections = self.connections.write().await;
|
||||
|
|
@ -1309,7 +1315,13 @@ impl AppState {
|
|||
// candidate never reaches this point, so the existing route keeps its state.
|
||||
routing.stop_keepalive(&pool_key);
|
||||
activity.insert(pool_key.clone(), PoolActivity::now());
|
||||
self.start_keepalive_task(&pool_key, &pool, config);
|
||||
self.start_keepalive_task(
|
||||
&pool_key,
|
||||
&pool,
|
||||
config,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
mq_keepalive_adapter.clone(),
|
||||
);
|
||||
break Ok(connections.insert(pool_key.clone(), pool));
|
||||
};
|
||||
let previous = match previous {
|
||||
|
|
@ -1430,9 +1442,21 @@ impl AppState {
|
|||
self.pool_routing_control().close_pool_with_timeout(pool_key, pool).await;
|
||||
}
|
||||
|
||||
fn start_keepalive_task(&self, pool_key: &str, pool: &PoolKind, config: &ConnectionConfig) {
|
||||
fn start_keepalive_task(
|
||||
&self,
|
||||
pool_key: &str,
|
||||
pool: &PoolKind,
|
||||
config: &ConnectionConfig,
|
||||
#[cfg(feature = "mq-admin")] mq_adapter: Option<std::sync::Arc<dyn crate::mq::port::MessageQueueAdmin>>,
|
||||
) {
|
||||
let interval_secs = config.keepalive_interval_secs;
|
||||
let mut target = keepalive_target_from_pool(pool, config);
|
||||
#[cfg(feature = "mq-admin")]
|
||||
if target.is_none() {
|
||||
if let Some(adapter) = mq_adapter {
|
||||
target = Some(KeepaliveTarget::MessageQueue(adapter));
|
||||
}
|
||||
}
|
||||
if interval_secs == 0 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1445,10 +1469,28 @@ impl AppState {
|
|||
|
||||
let key = pool_key.to_string();
|
||||
let interval = Duration::from_secs(interval_secs.max(1));
|
||||
// MQ keepalive runs a full adapter test_connection (agent RPC); use query timeout
|
||||
// so slow clusters are not spuriously dropped by the shorter connect timeout.
|
||||
#[cfg(feature = "mq-admin")]
|
||||
let timeout = if matches!(target, Some(KeepaliveTarget::MessageQueue(_))) {
|
||||
match config.effective_query_timeout_secs() {
|
||||
0 => Duration::from_secs(300), // UI "unlimited" still needs a keepalive bound
|
||||
secs => Duration::from_secs(secs.max(1)),
|
||||
}
|
||||
} else {
|
||||
Duration::from_secs(config.effective_connect_timeout_secs().max(1))
|
||||
};
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
let timeout = Duration::from_secs(config.effective_connect_timeout_secs().max(1));
|
||||
let routing = self.pool_routing_control();
|
||||
let connections = self.connections.clone();
|
||||
let running_queries = self.running_queries.clone();
|
||||
// MQ pool markers are empty; close_pool_kind is a no-op, so keepalive must
|
||||
// drop the registry adapter or reconnect would reuse a dead agent.
|
||||
#[cfg(feature = "mq-admin")]
|
||||
let mq_registry = self.mq_registry.clone();
|
||||
#[cfg(feature = "mq-admin")]
|
||||
let mq_connection_id = config.id.clone();
|
||||
self.task_supervisor.spawn_replace(format!("keepalive:{pool_key}"), move |shutdown| async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
|
|
@ -1468,6 +1510,17 @@ impl AppState {
|
|||
log::warn!("Connection keepalive failed for '{key}': {err}; invalidating pool");
|
||||
let replace_runtime =
|
||||
err.recovery_decision().is_some_and(RecoveryDecision::replaces_runtime);
|
||||
// PoolKind::MessageQueue is a unit marker, so matches_pool cannot
|
||||
// Arc::ptr_eq. Identity-check the registry adapter before teardown.
|
||||
#[cfg(feature = "mq-admin")]
|
||||
if let KeepaliveTarget::MessageQueue(adapter) = target {
|
||||
if !mq_registry.is_current_adapter(&mq_connection_id, adapter).await {
|
||||
log::debug!("Skipping stale MQ keepalive result for replaced pool '{key}'");
|
||||
break;
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "mq-admin")]
|
||||
let drop_mq = matches!(target, KeepaliveTarget::MessageQueue(_));
|
||||
if !detach_keepalive_target_if_current(
|
||||
&routing,
|
||||
&connections,
|
||||
|
|
@ -1478,6 +1531,11 @@ impl AppState {
|
|||
.await
|
||||
{
|
||||
log::debug!("Skipping stale keepalive result for replaced pool '{key}'");
|
||||
} else {
|
||||
#[cfg(feature = "mq-admin")]
|
||||
if drop_mq {
|
||||
mq_registry.drop_connection(&mq_connection_id).await;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -1486,8 +1544,22 @@ impl AppState {
|
|||
"Connection keepalive timed out for '{key}' after {}s; invalidating pool",
|
||||
timeout.as_secs()
|
||||
);
|
||||
#[cfg(feature = "mq-admin")]
|
||||
if let KeepaliveTarget::MessageQueue(adapter) = target {
|
||||
if !mq_registry.is_current_adapter(&mq_connection_id, adapter).await {
|
||||
log::debug!("Skipping stale MQ keepalive timeout for replaced pool '{key}'");
|
||||
break;
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "mq-admin")]
|
||||
let drop_mq = matches!(target, KeepaliveTarget::MessageQueue(_));
|
||||
if !detach_keepalive_target_if_current(&routing, &connections, &key, target, false).await {
|
||||
log::debug!("Skipping stale keepalive timeout for replaced pool '{key}'");
|
||||
} else {
|
||||
#[cfg(feature = "mq-admin")]
|
||||
if drop_mq {
|
||||
mq_registry.drop_connection(&mq_connection_id).await;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -3369,7 +3441,13 @@ impl AppState {
|
|||
}
|
||||
};
|
||||
if let (Some(config), Some(client)) = (config, client) {
|
||||
self.start_keepalive_task(pool_key, &PoolKind::Agent(client), &config);
|
||||
self.start_keepalive_task(
|
||||
pool_key,
|
||||
&PoolKind::Agent(client),
|
||||
&config,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4083,7 +4161,10 @@ enum KeepaliveTarget {
|
|||
Postgres(deadpool_postgres::Pool),
|
||||
Rqlite(db::rqlite_driver::RqliteClient),
|
||||
Turso(db::turso_driver::TursoClient),
|
||||
MongoDb { client: mongodb::Client, database: Option<String> },
|
||||
MongoDb {
|
||||
client: mongodb::Client,
|
||||
database: Option<String>,
|
||||
},
|
||||
ClickHouse(db::clickhouse_driver::ChClient),
|
||||
SqlServer(Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
|
||||
Elasticsearch(db::elasticsearch_driver::EsClient),
|
||||
|
|
@ -4093,6 +4174,8 @@ enum KeepaliveTarget {
|
|||
InfluxDb(db::influxdb_driver::InfluxdbClient),
|
||||
VictoriaMetrics(db::victoriametrics_driver::VictoriaMetricsClient),
|
||||
Agent(Arc<db::agent_driver::PooledAgentClient>),
|
||||
#[cfg(feature = "mq-admin")]
|
||||
MessageQueue(Arc<dyn crate::mq::port::MessageQueueAdmin>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -4130,6 +4213,10 @@ impl KeepaliveTarget {
|
|||
match (self, pool) {
|
||||
(Self::Agent(expected), PoolKind::Agent(current)) => Arc::ptr_eq(expected, current),
|
||||
(Self::SqlServer(expected), PoolKind::SqlServer(current)) => Arc::ptr_eq(expected, current),
|
||||
#[cfg(feature = "mq-admin")]
|
||||
(Self::MessageQueue(_), PoolKind::MessageQueue) => true,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
(Self::MessageQueue(_), _) | (_, PoolKind::MessageQueue) => false,
|
||||
(Self::Agent(_), _) | (_, PoolKind::Agent(_)) | (Self::SqlServer(_), _) | (_, PoolKind::SqlServer(_)) => {
|
||||
false
|
||||
}
|
||||
|
|
@ -4245,6 +4332,10 @@ async fn ping_keepalive_target(target: &mut KeepaliveTarget, timeout: Duration)
|
|||
Err(error) => Err(KeepaliveError::Agent(error)),
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "mq-admin")]
|
||||
KeepaliveTarget::MessageQueue(adapter) => {
|
||||
adapter.test_connection().await.map(|_| ()).map_err(KeepaliveError::from)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6553,7 +6644,13 @@ for line in sys.stdin:
|
|||
.await
|
||||
.insert(pool_key.to_string(), super::PoolActivity::idle_for(std::time::Duration::from_secs(10)));
|
||||
let pool = super::clone_pool_kind(state.connections.read().await.get(pool_key).unwrap());
|
||||
state.start_keepalive_task(pool_key, &pool, &config);
|
||||
state.start_keepalive_task(
|
||||
pool_key,
|
||||
&pool,
|
||||
&config,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
None,
|
||||
);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
|
||||
|
|
|
|||
|
|
@ -63,12 +63,13 @@ impl KafkaAdmin {
|
|||
let mut client = AgentDriverClient::spawn(launch).await?;
|
||||
|
||||
// Handshake
|
||||
let _: serde_json::Value = client.call("handshake", serde_json::json!({})).await?;
|
||||
let _: serde_json::Value =
|
||||
client.call_with_timeout("handshake", serde_json::json!({}), cfg.rpc_timeout()).await?;
|
||||
|
||||
// Build the connection params from MqAdminConfig
|
||||
let conn_params = build_connection_params(&cfg);
|
||||
let connect_params = serde_json::json!({ "connection": conn_params });
|
||||
let _: serde_json::Value = client.call("connect", connect_params).await?;
|
||||
let _: serde_json::Value = client.call_with_timeout("connect", connect_params, cfg.rpc_timeout()).await?;
|
||||
|
||||
log::info!("Kafka admin connected via agent (bootstrap servers: {})", bootstrap_servers(&cfg));
|
||||
|
||||
|
|
@ -82,7 +83,7 @@ impl KafkaAdmin {
|
|||
params: serde_json::Value,
|
||||
) -> Result<T, String> {
|
||||
let mut client = self.client.lock().await;
|
||||
client.call(method, params).await
|
||||
client.call_with_timeout(method, params, self.config.rpc_timeout()).await
|
||||
}
|
||||
|
||||
/// The Kafka agent bounds message browsing with its configured request timeout.
|
||||
|
|
@ -538,8 +539,7 @@ impl MessageQueueAdmin for KafkaAdmin {
|
|||
)
|
||||
.await?;
|
||||
|
||||
let total_lag = result.get("totalLag").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
Ok(BacklogStats { msg_backlog: total_lag, backlog_size: 0 })
|
||||
Ok(backlog_stats_from_consumer_lag(&result))
|
||||
}
|
||||
|
||||
async fn get_cluster_info(&self) -> Result<ClusterInfo, String> {
|
||||
|
|
@ -655,6 +655,7 @@ fn build_connection_params(cfg: &MqAdminConfig) -> serde_json::Value {
|
|||
"skip_verify": cfg.tls_skip_verify,
|
||||
},
|
||||
"properties": properties,
|
||||
"request_timeout_ms": cfg.request_timeout_ms(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -802,6 +803,8 @@ mod tests {
|
|||
token_signing: None,
|
||||
connect_override: None,
|
||||
management_connect_override: None,
|
||||
query_timeout_secs: crate::mq::config::DEFAULT_MQ_QUERY_TIMEOUT_SECS,
|
||||
connect_timeout_secs: crate::mq::config::DEFAULT_MQ_CONNECT_TIMEOUT_SECS,
|
||||
extra,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ use crate::mq::port::MessageQueueAdmin;
|
|||
use crate::mq::types::*;
|
||||
use crate::mq::util::truncate;
|
||||
|
||||
/// How long to wait for a single admin REST call.
|
||||
const REQUEST_TIMEOUT_SECS: u64 = 30;
|
||||
/// How long to wait for the initial version-probe during construction.
|
||||
const PROBE_TIMEOUT_SECS: u64 = 8;
|
||||
const DETAIL_REQUEST_CONCURRENCY: usize = 8;
|
||||
|
|
@ -39,7 +37,12 @@ impl PulsarAdmin {
|
|||
/// the API profile. Probe failure degrades gracefully to the 3.1.x baseline.
|
||||
pub async fn new(cfg: MqAdminConfig) -> Result<Self, String> {
|
||||
let base = normalize_base(&cfg.admin_url);
|
||||
let http = build_http_client(cfg.tls_skip_verify, cfg.connect_override.as_ref(), &cfg.admin_url)?;
|
||||
// Unlimited query timeout still needs a finite HTTP client timeout; mirror
|
||||
// request_timeout_ms (1h) so Advanced "0 = unlimited" is not silently clamped to 30s.
|
||||
let request_timeout =
|
||||
cfg.rpc_timeout().unwrap_or_else(|| std::time::Duration::from_millis(cfg.request_timeout_ms()));
|
||||
let http =
|
||||
build_http_client(cfg.tls_skip_verify, cfg.connect_override.as_ref(), &cfg.admin_url, request_timeout)?;
|
||||
let auth = cfg.auth.clone();
|
||||
let token_cache = TokenCache::default();
|
||||
|
||||
|
|
@ -807,9 +810,9 @@ fn build_http_client(
|
|||
tls_skip_verify: bool,
|
||||
connect_override: Option<&MqConnectOverride>,
|
||||
admin_url: &str,
|
||||
request_timeout: std::time::Duration,
|
||||
) -> Result<reqwest::Client, String> {
|
||||
let mut builder = crate::db::http_client_builder(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
|
||||
.timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS));
|
||||
let mut builder = crate::db::http_client_builder(request_timeout).timeout(request_timeout);
|
||||
if tls_skip_verify {
|
||||
builder = builder.danger_accept_invalid_certs(true);
|
||||
}
|
||||
|
|
@ -1074,7 +1077,7 @@ fn backlog_stats_from_topic_stats(profile: &PulsarApiProfile, stats: &TopicStats
|
|||
None => subs.into_iter().fold(0_i64, |total, sub| total.saturating_add(sub.msg_backlog)),
|
||||
};
|
||||
|
||||
BacklogStats { msg_backlog, backlog_size: stats.backlog_size }
|
||||
BacklogStats { msg_backlog, backlog_size: stats.backlog_size, partitions: Vec::new() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -1151,6 +1154,8 @@ mod tests {
|
|||
token_signing: None,
|
||||
connect_override: None,
|
||||
management_connect_override: None,
|
||||
query_timeout_secs: crate::mq::config::DEFAULT_MQ_QUERY_TIMEOUT_SECS,
|
||||
connect_timeout_secs: crate::mq::config::DEFAULT_MQ_CONNECT_TIMEOUT_SECS,
|
||||
extra: serde_json::Value::Null,
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -64,12 +64,13 @@ impl RabbitMqAdmin {
|
|||
let mut client = AgentDriverClient::spawn(launch).await?;
|
||||
|
||||
// Handshake
|
||||
let _: serde_json::Value = client.call("handshake", serde_json::json!({})).await?;
|
||||
let _: serde_json::Value =
|
||||
client.call_with_timeout("handshake", serde_json::json!({}), cfg.rpc_timeout()).await?;
|
||||
|
||||
// Build the connection params from MqAdminConfig
|
||||
let conn_params = build_connection_params(&cfg)?;
|
||||
let connect_params = serde_json::json!({ "connection": conn_params });
|
||||
let _: serde_json::Value = client.call("connect", connect_params).await?;
|
||||
let _: serde_json::Value = client.call_with_timeout("connect", connect_params, cfg.rpc_timeout()).await?;
|
||||
|
||||
log::info!("RabbitMQ admin connected via agent (addresses: {})", addresses(&cfg));
|
||||
|
||||
|
|
@ -83,7 +84,7 @@ impl RabbitMqAdmin {
|
|||
params: serde_json::Value,
|
||||
) -> Result<T, String> {
|
||||
let mut client = self.client.lock().await;
|
||||
client.call(method, params).await
|
||||
client.call_with_timeout(method, params, self.config.rpc_timeout()).await
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC call that returns `{ok: true}` on success.
|
||||
|
|
@ -623,7 +624,7 @@ impl MessageQueueAdmin for RabbitMqAdmin {
|
|||
let result: serde_json::Value = self.call("mq_get_topic_stats", params).await?;
|
||||
|
||||
let total_messages = result.get("totalMessages").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
Ok(BacklogStats { msg_backlog: total_messages, backlog_size: total_messages })
|
||||
Ok(BacklogStats { msg_backlog: total_messages, backlog_size: total_messages, partitions: Vec::new() })
|
||||
}
|
||||
|
||||
async fn get_cluster_info(&self) -> Result<ClusterInfo, String> {
|
||||
|
|
@ -952,6 +953,7 @@ fn build_connection_params(cfg: &MqAdminConfig) -> Result<serde_json::Value, Str
|
|||
"virtual_host": virtual_host,
|
||||
"tls_skip_verify": cfg.tls_skip_verify,
|
||||
"properties": properties,
|
||||
"request_timeout_ms": cfg.request_timeout_ms(),
|
||||
});
|
||||
if let Some(username) = username.filter(|value| !value.trim().is_empty()) {
|
||||
params["username"] = serde_json::json!(username);
|
||||
|
|
@ -1027,6 +1029,8 @@ mod tests {
|
|||
token_signing: None,
|
||||
connect_override: None,
|
||||
management_connect_override: None,
|
||||
query_timeout_secs: crate::mq::config::DEFAULT_MQ_QUERY_TIMEOUT_SECS,
|
||||
connect_timeout_secs: crate::mq::config::DEFAULT_MQ_CONNECT_TIMEOUT_SECS,
|
||||
extra,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,15 @@
|
|||
//! 3. Delegate all `MessageQueueAdmin` trait methods to JSON-RPC calls
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::db::agent_driver::{AgentDriverClient, AgentLaunchSpec};
|
||||
use crate::mq::auth::MqAuth;
|
||||
|
|
@ -94,16 +98,20 @@ fn cluster_info_from_agent_result(result: &serde_json::Value) -> MqClusterInfo {
|
|||
|
||||
impl RocketMqAdmin {
|
||||
/// Spawn the RocketMQ Java agent, perform handshake, and connect.
|
||||
///
|
||||
/// Callers must probe NameServer reachability before invoking this so the
|
||||
/// connect-timeout wall covers only JVM spawn + handshake + connect.
|
||||
pub async fn new(cfg: MqAdminConfig, launch: AgentLaunchSpec) -> Result<Self, String> {
|
||||
let mut client = AgentDriverClient::spawn(launch).await?;
|
||||
|
||||
// Handshake
|
||||
let _: serde_json::Value = client.call("handshake", serde_json::json!({})).await?;
|
||||
let _: serde_json::Value =
|
||||
client.call_with_timeout("handshake", serde_json::json!({}), cfg.rpc_timeout()).await?;
|
||||
|
||||
// Build the connection params from MqAdminConfig
|
||||
let conn_params = build_connection_params(&cfg);
|
||||
let connect_params = serde_json::json!({ "connection": conn_params });
|
||||
let _: serde_json::Value = client.call("connect", connect_params).await?;
|
||||
let _: serde_json::Value = client.call_with_timeout("connect", connect_params, cfg.rpc_timeout()).await?;
|
||||
|
||||
log::info!("RocketMQ admin connected via agent (namesrv: {})", namesrv_addr(&cfg));
|
||||
|
||||
|
|
@ -117,7 +125,7 @@ impl RocketMqAdmin {
|
|||
params: serde_json::Value,
|
||||
) -> Result<T, String> {
|
||||
let mut client = self.client.lock().await;
|
||||
client.call(method, params).await
|
||||
client.call_with_timeout(method, params, self.config.rpc_timeout()).await
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC call that returns `{ok: true}` on success.
|
||||
|
|
@ -355,13 +363,14 @@ impl MessageQueueAdmin for RocketMqAdmin {
|
|||
|
||||
async fn list_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String> {
|
||||
if topic.topic.is_empty() {
|
||||
// Fast path: names/types only. UI enrichs online members/topics in a second pass.
|
||||
let result: serde_json::Value = self
|
||||
.call(
|
||||
"mq_list_consumer_groups",
|
||||
serde_json::json!({
|
||||
"limit": 500,
|
||||
"offset": 0,
|
||||
"enrich": true,
|
||||
"enrich": false,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -369,8 +378,7 @@ impl MessageQueueAdmin for RocketMqAdmin {
|
|||
return Ok(groups.iter().map(rocketmq_subscription_from_group).collect());
|
||||
}
|
||||
|
||||
// Agent uses queryTopicConsumeByWho (Dashboard queryTopicConsumerInfo); include all
|
||||
// returned groups even when consumers are offline and offsets are zero.
|
||||
// Topic path: skip list enrich; batch lag in one agent RPC (no N+1).
|
||||
let result: serde_json::Value = self
|
||||
.call(
|
||||
"mq_list_consumer_groups",
|
||||
|
|
@ -378,34 +386,30 @@ impl MessageQueueAdmin for RocketMqAdmin {
|
|||
"topic": topic.topic,
|
||||
"limit": 200,
|
||||
"offset": 0,
|
||||
"enrich": true,
|
||||
"enrich": false,
|
||||
"includeLag": true,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let groups = result.get("groups").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
Ok(groups.iter().map(rocketmq_subscription_from_group).collect())
|
||||
}
|
||||
|
||||
let mut subs = Vec::new();
|
||||
for group in groups {
|
||||
let group_id = group.get("groupId").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
if group_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut sub = rocketmq_subscription_from_group(&group);
|
||||
if let Ok(lag) = self
|
||||
.call::<serde_json::Value>(
|
||||
"mq_get_consumer_lag",
|
||||
serde_json::json!({
|
||||
"groupId": group_id,
|
||||
"topic": topic.topic,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
sub.msg_backlog = lag.get("totalLag").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
}
|
||||
subs.push(sub);
|
||||
async fn enrich_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String> {
|
||||
// Cluster-wide second pass fills memberCount/topics after the fast list paint.
|
||||
let mut params = serde_json::json!({
|
||||
"limit": 500,
|
||||
"offset": 0,
|
||||
"enrich": true,
|
||||
});
|
||||
if !topic.topic.is_empty() {
|
||||
params["topic"] = serde_json::json!(topic.topic);
|
||||
params["limit"] = serde_json::json!(200);
|
||||
params["includeLag"] = serde_json::json!(true);
|
||||
}
|
||||
Ok(subs)
|
||||
let result: serde_json::Value = self.call("mq_list_consumer_groups", params).await?;
|
||||
let groups = result.get("groups").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
Ok(groups.iter().map(rocketmq_subscription_from_group).collect())
|
||||
}
|
||||
|
||||
async fn create_subscription(&self, _topic: &TopicRef, _sub: &str, _pos: ResetPosition) -> Result<(), String> {
|
||||
|
|
@ -673,8 +677,12 @@ impl MessageQueueAdmin for RocketMqAdmin {
|
|||
)
|
||||
.await?;
|
||||
|
||||
let total_lag = result.get("totalLag").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
Ok(BacklogStats { msg_backlog: total_lag, backlog_size: 0 })
|
||||
// Agent omits totalLag on probe failure; never coerce that to healthy zero backlog.
|
||||
if result.get("totalLag").and_then(|v| v.as_i64()).is_none() {
|
||||
return Err(format!("RocketMQ consumer lag unavailable for group '{group_id}' on topic '{}'", topic.topic));
|
||||
}
|
||||
|
||||
Ok(backlog_stats_from_consumer_lag(&result))
|
||||
}
|
||||
|
||||
async fn get_cluster_info(&self) -> Result<ClusterInfo, String> {
|
||||
|
|
@ -831,6 +839,7 @@ fn build_connection_params(cfg: &MqAdminConfig) -> serde_json::Value {
|
|||
"access_key": access_key,
|
||||
"secret_key": secret_key,
|
||||
"tls_skip_verify": cfg.tls_skip_verify,
|
||||
"request_timeout_ms": cfg.request_timeout_ms(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -901,12 +910,14 @@ fn rocketmq_subscription_for_topic(
|
|||
online_members: None,
|
||||
consumer_group_type: None,
|
||||
message_model: None,
|
||||
backlog_unavailable: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn rocketmq_subscription_from_group(group: &serde_json::Value) -> SubscriptionInfo {
|
||||
let group_id = group.get("groupId").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let group_type = group.get("groupType").and_then(|v| v.as_str()).unwrap_or("NORMAL").to_string();
|
||||
// Match agent classify: missing dump → UNKNOWN, not silent NORMAL (FIFO hide risk).
|
||||
let group_type = group.get("groupType").and_then(|v| v.as_str()).unwrap_or("UNKNOWN").to_string();
|
||||
let message_model = group.get("messageModel").and_then(|v| v.as_str()).map(String::from);
|
||||
let online_members = group.get("memberCount").and_then(|v| v.as_u64()).map(|v| v as u32);
|
||||
let topics = group
|
||||
|
|
@ -914,10 +925,13 @@ fn rocketmq_subscription_from_group(group: &serde_json::Value) -> SubscriptionIn
|
|||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
let lag_failed = group.get("totalLagFailed").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let total_lag = group.get("totalLag").and_then(|v| v.as_i64());
|
||||
SubscriptionInfo {
|
||||
name: group_id.to_string(),
|
||||
sub_type: group_type.clone(),
|
||||
msg_backlog: 0,
|
||||
// Probe failure: keep 0 but set backlog_unavailable so topic-list UI shows "-".
|
||||
msg_backlog: total_lag.unwrap_or(0),
|
||||
msg_rate_out: 0.0,
|
||||
msg_throughput_out: 0.0,
|
||||
consumers: Vec::new(),
|
||||
|
|
@ -925,9 +939,92 @@ fn rocketmq_subscription_from_group(group: &serde_json::Value) -> SubscriptionIn
|
|||
online_members,
|
||||
consumer_group_type: Some(group_type),
|
||||
message_model,
|
||||
backlog_unavailable: if lag_failed || (group.get("totalLag").is_some() && total_lag.is_none()) {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Fail fast on unreachable NameServer before paying for JVM agent startup.
|
||||
/// Called outside the connect-timeout wall so the probe does not steal JVM budget.
|
||||
pub(crate) async fn probe_namesrv_before_connect(cfg: &MqAdminConfig, budget: Duration) -> Result<(), String> {
|
||||
probe_namesrv_tcp(&namesrv_addr(cfg), budget).await
|
||||
}
|
||||
|
||||
/// Probe NameServer addresses so connect fails before spawning the JVM agent.
|
||||
/// Tries each `;`-separated host (and each resolved IP) within the shared budget,
|
||||
/// matching RocketMQ client HA behavior instead of failing on the first dead node.
|
||||
async fn probe_namesrv_tcp(namesrv: &str, budget: Duration) -> Result<(), String> {
|
||||
let hosts: Vec<String> =
|
||||
namesrv.split(';').map(str::trim).filter(|part| !part.is_empty()).map(str::to_string).collect();
|
||||
if hosts.is_empty() {
|
||||
return Err("RocketMQ namesrv_addr is empty".to_string());
|
||||
}
|
||||
|
||||
let deadline = tokio::time::Instant::now() + budget;
|
||||
// Multi-NS HA: cap each attempt so a blackholed first node leaves budget for later hosts.
|
||||
let per_attempt = if hosts.len() <= 1 {
|
||||
budget
|
||||
} else {
|
||||
(budget / hosts.len() as u32).clamp(Duration::from_millis(500), Duration::from_secs(3))
|
||||
};
|
||||
let mut last_error = String::new();
|
||||
for host in &hosts {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
// DNS resolution is blocking; keep it off the async runtime.
|
||||
let host_for_resolve = host.clone();
|
||||
let addrs = match tokio::task::spawn_blocking(move || {
|
||||
host_for_resolve
|
||||
.to_socket_addrs()
|
||||
.map(|iter| iter.collect::<Vec<_>>())
|
||||
.map_err(|e| format!("RocketMQ NameServer address '{host_for_resolve}' is invalid: {e}"))
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(addrs)) => addrs,
|
||||
Ok(Err(e)) => {
|
||||
last_error = e;
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = format!("RocketMQ NameServer resolve task failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if addrs.is_empty() {
|
||||
last_error = format!("RocketMQ NameServer address '{host}' did not resolve");
|
||||
continue;
|
||||
}
|
||||
for addr in addrs {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
let attempt = remaining.min(per_attempt);
|
||||
match timeout(attempt, TcpStream::connect(addr)).await {
|
||||
Ok(Ok(_stream)) => return Ok(()),
|
||||
Ok(Err(e)) => {
|
||||
last_error = format!("Cannot reach RocketMQ NameServer {host}: {e}");
|
||||
}
|
||||
Err(_) => {
|
||||
last_error =
|
||||
format!("RocketMQ NameServer {host} connect timed out after {}ms", attempt.as_millis());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(if last_error.is_empty() {
|
||||
format!("RocketMQ NameServer connect timed out after {}s", budget.as_secs())
|
||||
} else {
|
||||
last_error
|
||||
})
|
||||
}
|
||||
|
||||
fn peeked_message_from_agent_json(idx: usize, message: &serde_json::Value) -> PeekedMessage {
|
||||
let mut properties = HashMap::new();
|
||||
if let Some(partition) = message.get("partition").and_then(|v| v.as_i64()) {
|
||||
|
|
@ -975,10 +1072,18 @@ mod tests {
|
|||
token_signing: None,
|
||||
connect_override: None,
|
||||
management_connect_override: None,
|
||||
query_timeout_secs: crate::mq::config::DEFAULT_MQ_QUERY_TIMEOUT_SECS,
|
||||
connect_timeout_secs: crate::mq::config::DEFAULT_MQ_CONNECT_TIMEOUT_SECS,
|
||||
extra,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_namesrv_rejects_empty_address_list() {
|
||||
let err = probe_namesrv_tcp(" ; ; ", Duration::from_millis(200)).await.expect_err("empty");
|
||||
assert!(err.contains("empty"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_params_map_namesrv_and_acl_credentials() {
|
||||
let cfg = rocketmq_config(
|
||||
|
|
@ -995,6 +1100,7 @@ mod tests {
|
|||
assert_eq!(params.get("cluster_name").and_then(|v| v.as_str()), Some("DefaultCluster"));
|
||||
assert_eq!(params.get("access_key").and_then(|v| v.as_str()), Some("rocket"));
|
||||
assert_eq!(params.get("secret_key").and_then(|v| v.as_str()), Some("secret"));
|
||||
assert_eq!(params.get("request_timeout_ms").and_then(|v| v.as_u64()), Some(30_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1031,6 +1137,26 @@ mod tests {
|
|||
assert_eq!(sub.msg_backlog, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rocketmq_subscription_from_group_marks_failed_lag_unavailable() {
|
||||
let group = serde_json::json!({
|
||||
"groupId": "lag-fail",
|
||||
"groupType": "NORMAL",
|
||||
"totalLagFailed": true
|
||||
});
|
||||
let sub = rocketmq_subscription_from_group(&group);
|
||||
assert_eq!(sub.msg_backlog, 0);
|
||||
assert_eq!(sub.backlog_unavailable, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rocketmq_subscription_from_group_defaults_missing_type_to_unknown() {
|
||||
let group = serde_json::json!({ "groupId": "no-type" });
|
||||
let sub = rocketmq_subscription_from_group(&group);
|
||||
assert_eq!(sub.sub_type, "UNKNOWN");
|
||||
assert_eq!(sub.consumer_group_type.as_deref(), Some("UNKNOWN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rocketmq_subscription_from_group_maps_offline_topic_consumer() {
|
||||
let group = serde_json::json!({
|
||||
|
|
|
|||
|
|
@ -4,12 +4,19 @@
|
|||
//! `ConnectionConfig` rather than adding top-level fields, keeping the 50+
|
||||
//! database-type connection model untouched.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::connection::ConnectionConfig;
|
||||
use crate::mq::auth::MqAuth;
|
||||
use crate::mq::types::{MqSystemKind, MqTokenSigningConfig};
|
||||
|
||||
/// Default query timeout when constructing test configs without a ConnectionConfig.
|
||||
pub const DEFAULT_MQ_QUERY_TIMEOUT_SECS: u64 = 30;
|
||||
/// Default connect timeout when constructing test configs without a ConnectionConfig.
|
||||
pub const DEFAULT_MQ_CONNECT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Runtime TCP endpoint override for an MQ transport.
|
||||
///
|
||||
/// The logical broker endpoint remains unchanged so TLS hostname verification,
|
||||
|
|
@ -53,6 +60,12 @@ pub struct MqAdminConfig {
|
|||
/// its Management HTTP API listens on an independently configured port.
|
||||
#[serde(skip)]
|
||||
pub management_connect_override: Option<MqConnectOverride>,
|
||||
/// Runtime-only: from `ConnectionConfig.query_timeout_secs` (`0` = unlimited).
|
||||
#[serde(skip)]
|
||||
pub query_timeout_secs: u64,
|
||||
/// Runtime-only: from `ConnectionConfig.effective_connect_timeout_secs()`.
|
||||
#[serde(skip)]
|
||||
pub connect_timeout_secs: u64,
|
||||
/// System-specific extension fields (e.g. Kafka bootstrap servers).
|
||||
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
|
||||
pub extra: serde_json::Value,
|
||||
|
|
@ -76,9 +89,35 @@ impl MqAdminConfig {
|
|||
{
|
||||
return Err("Message queue admin URL is empty".to_string());
|
||||
}
|
||||
// Advanced connection timeouts live on ConnectionConfig, not external_config.
|
||||
parsed.query_timeout_secs = cfg.effective_query_timeout_secs();
|
||||
parsed.connect_timeout_secs = cfg.effective_connect_timeout_secs();
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Agent / HTTP RPC wall-clock timeout. `None` disables the client-side timeout.
|
||||
pub fn rpc_timeout(&self) -> Option<Duration> {
|
||||
if self.query_timeout_secs == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(Duration::from_secs(self.query_timeout_secs.max(1)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Milliseconds for agent `request_timeout_ms`. Unlimited query timeout maps to a
|
||||
/// large but finite admin request budget so Java/Go clients still make progress.
|
||||
pub fn request_timeout_ms(&self) -> u64 {
|
||||
match self.query_timeout_secs {
|
||||
0 => 3_600_000,
|
||||
secs => secs.saturating_mul(1000).max(1_000),
|
||||
}
|
||||
}
|
||||
|
||||
/// Budget for establishing the MQ adapter (agent spawn + handshake/connect).
|
||||
pub fn connect_timeout(&self) -> Duration {
|
||||
Duration::from_secs(self.connect_timeout_secs.max(1))
|
||||
}
|
||||
|
||||
pub fn token_signing_configured(&self) -> bool {
|
||||
self.token_signing.as_ref().is_some_and(MqTokenSigningConfig::is_configured)
|
||||
}
|
||||
|
|
@ -228,6 +267,25 @@ mod tests {
|
|||
assert_eq!(mqc.system_kind, MqSystemKind::RocketMq);
|
||||
assert_eq!(mqc.admin_url, "");
|
||||
assert_eq!(mqc.extra.get("namesrvAddr").and_then(|v| v.as_str()), Some("127.0.0.1:9876"));
|
||||
assert_eq!(mqc.query_timeout_secs, 30);
|
||||
assert_eq!(mqc.connect_timeout_secs, 5);
|
||||
assert_eq!(mqc.request_timeout_ms(), 30_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_advanced_timeouts_from_connection_config() {
|
||||
let mut cfg = connection_with_external(serde_json::json!({
|
||||
"systemKind": "rocketmq",
|
||||
"adminUrl": "",
|
||||
"extra": { "namesrvAddr": "127.0.0.1:9876" }
|
||||
}));
|
||||
cfg.query_timeout_secs = 120;
|
||||
cfg.connect_timeout_secs = 15;
|
||||
let mqc = MqAdminConfig::from_connection(&cfg).expect("parse");
|
||||
assert_eq!(mqc.query_timeout_secs, 120);
|
||||
assert_eq!(mqc.connect_timeout_secs, 15);
|
||||
assert_eq!(mqc.request_timeout_ms(), 120_000);
|
||||
assert_eq!(mqc.rpc_timeout(), Some(std::time::Duration::from_secs(120)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -45,10 +45,11 @@ pub use crate::mq::types::*;
|
|||
/// Each connection has its own build-lock so concurrent first-use requests for
|
||||
/// the same connection block until the first builder finishes, rather than both
|
||||
/// racing to construct an adapter.
|
||||
#[derive(Default)]
|
||||
/// Clone shares the same cache (needed for keepalive tasks that must drop adapters).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MqAdminRegistry {
|
||||
instances: RwLock<HashMap<String, CachedMqAdmin>>,
|
||||
build_locks: RwLock<HashMap<String, Arc<Mutex<()>>>>,
|
||||
instances: Arc<RwLock<HashMap<String, CachedMqAdmin>>>,
|
||||
build_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
}
|
||||
|
||||
struct CachedMqAdmin {
|
||||
|
|
@ -65,7 +66,7 @@ pub struct MqBuildResult {
|
|||
|
||||
impl MqAdminRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self { instances: RwLock::new(HashMap::new()), build_locks: RwLock::new(HashMap::new()) }
|
||||
Self { instances: Arc::new(RwLock::new(HashMap::new())), build_locks: Arc::new(RwLock::new(HashMap::new())) }
|
||||
}
|
||||
|
||||
/// Return the cached adapter for this connection, building it from the
|
||||
|
|
@ -108,7 +109,7 @@ impl MqAdminRegistry {
|
|||
// Config changed — drop the stale adapter so its agent process is released.
|
||||
self.instances.write().await.remove(connection_id);
|
||||
|
||||
let adapter = build_adapter(mqc, agent_launch).await?;
|
||||
let adapter = build_adapter_with_connect_timeout(mqc, agent_launch).await?;
|
||||
self.instances
|
||||
.write()
|
||||
.await
|
||||
|
|
@ -116,6 +117,17 @@ impl MqAdminRegistry {
|
|||
Ok(MqBuildResult { adapter, was_cached: false })
|
||||
}
|
||||
|
||||
/// Cached adapter for keepalive / diagnostics. Returns `None` when not built yet.
|
||||
pub async fn get_cached_adapter(&self, connection_id: &str) -> Option<Arc<dyn MessageQueueAdmin>> {
|
||||
self.instances.read().await.get(connection_id).map(|entry| entry.adapter.clone())
|
||||
}
|
||||
|
||||
/// Whether `adapter` is still the live registry entry for this connection (Arc identity).
|
||||
/// Used so a stale keepalive cannot drop a replacement built after reconnect.
|
||||
pub async fn is_current_adapter(&self, connection_id: &str, adapter: &Arc<dyn MessageQueueAdmin>) -> bool {
|
||||
self.instances.read().await.get(connection_id).is_some_and(|entry| Arc::ptr_eq(&entry.adapter, adapter))
|
||||
}
|
||||
|
||||
/// Drop the cached adapter for a connection (called on disconnect).
|
||||
pub async fn drop_connection(&self, connection_id: &str) {
|
||||
self.instances.write().await.remove(connection_id);
|
||||
|
|
@ -144,7 +156,26 @@ impl MqAdminRegistry {
|
|||
mqc: MqAdminConfig,
|
||||
agent_launch: Option<AgentLaunchSpec>,
|
||||
) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
build_adapter(mqc, agent_launch).await
|
||||
build_adapter_with_connect_timeout(mqc, agent_launch).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_adapter_with_connect_timeout(
|
||||
mqc: MqAdminConfig,
|
||||
agent_launch: Option<AgentLaunchSpec>,
|
||||
) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
let budget = mqc.connect_timeout();
|
||||
// RocketMQ: TCP-probe NameServer outside the connect wall so cold JVM spawn
|
||||
// retains the full connect_timeout (probe used to steal up to half of it).
|
||||
if mqc.system_kind == MqSystemKindInternal::RocketMq {
|
||||
// Probe runs outside the connect wall; scale with connect_timeout so HA NameServer
|
||||
// lists are not starved by a hard 2s cap, but keep an upper bound for snappy UX.
|
||||
let probe_budget = (budget / 2).clamp(std::time::Duration::from_millis(500), std::time::Duration::from_secs(5));
|
||||
crate::mq::adapters::rocketmq::probe_namesrv_before_connect(&mqc, probe_budget).await?;
|
||||
}
|
||||
match tokio::time::timeout(budget, build_adapter(mqc, agent_launch)).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!("Message queue connect timed out after {}s", budget.as_secs())),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -149,6 +149,13 @@ pub trait MessageQueueAdmin: Send + Sync {
|
|||
|
||||
// ---- Subscriptions ----
|
||||
async fn list_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String>;
|
||||
|
||||
/// Second-pass enrichment for subscription rows (e.g. RocketMQ online members/topics).
|
||||
/// Default reuses [`list_subscriptions`]; RocketMQ overrides with `enrich: true`.
|
||||
async fn enrich_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String> {
|
||||
self.list_subscriptions(topic).await
|
||||
}
|
||||
|
||||
async fn create_subscription(&self, topic: &TopicRef, sub: &str, pos: ResetPosition) -> Result<(), String>;
|
||||
async fn delete_subscription(&self, topic: &TopicRef, sub: &str, force: bool) -> Result<(), String>;
|
||||
async fn skip_messages(&self, topic: &TopicRef, sub: &str, count: SkipCount) -> Result<(), String>;
|
||||
|
|
|
|||
|
|
@ -390,6 +390,15 @@ pub async fn mq_list_subscriptions_core(
|
|||
adapter.list_subscriptions(&topic).await
|
||||
}
|
||||
|
||||
pub async fn mq_enrich_subscriptions_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
topic: TopicRef,
|
||||
) -> Result<Vec<SubscriptionInfo>, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.enrich_subscriptions(&topic).await
|
||||
}
|
||||
|
||||
pub async fn mq_create_subscription_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
|
|
@ -842,6 +851,8 @@ async fn ensure_connection_writable(state: &AppState, conn_id: &str, operation:
|
|||
config.name, operation
|
||||
));
|
||||
}
|
||||
// Production protection for desktop MQ uses UI confirmation; MCP enforces
|
||||
// is_production separately. Do not hard-block confirmed desktop writes here.
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -939,6 +950,29 @@ mod tests {
|
|||
(state, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutating_rocketmq_send_blocks_read_only_connections() {
|
||||
let (state, _dir) = test_state_with(mq_connection(true)).await;
|
||||
let err = mq_send_message_core(
|
||||
&state,
|
||||
"readonly-mq",
|
||||
SendMessageRequest {
|
||||
topic: "t".into(),
|
||||
key: None,
|
||||
payload_base64: "eA==".into(),
|
||||
payload_text: Some("x".into()),
|
||||
headers: Default::default(),
|
||||
partition: None,
|
||||
exchange: None,
|
||||
routing_key: None,
|
||||
namespace: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("read-only must block send");
|
||||
assert!(err.contains("Read-only mode"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutating_service_calls_block_read_only_connections_before_adapter_build() {
|
||||
let (state, dir) = test_state_with(mq_connection(true)).await;
|
||||
|
|
|
|||
|
|
@ -415,6 +415,9 @@ pub struct SubscriptionInfo {
|
|||
/// RocketMQ: CLUSTERING / BROADCASTING.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub message_model: Option<String>,
|
||||
/// When true, backlog probe failed — UI must not treat `msg_backlog` as healthy zero.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backlog_unavailable: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
|
|
@ -465,11 +468,76 @@ pub enum SkipCount {
|
|||
Count { count: u32 },
|
||||
}
|
||||
|
||||
/// Per-queue/partition consume progress (RocketMQ Dashboard consume-detail / Kafka lag rows).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PartitionBacklog {
|
||||
pub partition: i32,
|
||||
/// Consumer committed offset (`consumerOffset` / `currentOffset`).
|
||||
pub current_offset: i64,
|
||||
/// Broker max offset (`brokerOffset` / `endOffset`).
|
||||
pub end_offset: i64,
|
||||
pub lag: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub broker_name: Option<String>,
|
||||
/// Last consume message store timestamp (ms). `0` means unavailable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_timestamp: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub consumer_client: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BacklogStats {
|
||||
pub msg_backlog: i64,
|
||||
pub backlog_size: i64,
|
||||
/// Optional queue-level progress; empty for adapters that only expose totals.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub partitions: Vec<PartitionBacklog>,
|
||||
}
|
||||
|
||||
/// Parse agent `mq_get_consumer_lag` JSON (`totalLag` + `partitions[]`) into [`BacklogStats`].
|
||||
pub fn backlog_stats_from_consumer_lag(value: &serde_json::Value) -> BacklogStats {
|
||||
let msg_backlog = value.get("totalLag").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let partitions = value
|
||||
.get("partitions")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|row| {
|
||||
let partition = row.get("partition").and_then(|v| v.as_i64()).map(|v| v as i32)?;
|
||||
let current_offset = row.get("currentOffset").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let end_offset = row.get("endOffset").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let lag =
|
||||
row.get("lag").and_then(|v| v.as_i64()).unwrap_or_else(|| (end_offset - current_offset).max(0));
|
||||
let broker_name = row
|
||||
.get("brokerName")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
let last_timestamp = row.get("lastTimestamp").and_then(|v| v.as_i64());
|
||||
let consumer_client = row
|
||||
.get("consumerClient")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
Some(PartitionBacklog {
|
||||
partition,
|
||||
current_offset,
|
||||
end_offset,
|
||||
lag,
|
||||
broker_name,
|
||||
last_timestamp,
|
||||
consumer_client,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
BacklogStats { msg_backlog, backlog_size: 0, partitions }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
|
|
@ -1250,4 +1318,52 @@ mod tests {
|
|||
assert!(!legacy.incomplete);
|
||||
assert!(legacy.messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backlog_stats_from_consumer_lag_maps_dashboard_fields() {
|
||||
let lag = serde_json::json!({
|
||||
"totalLag": 10,
|
||||
"partitions": [
|
||||
{
|
||||
"partition": 0,
|
||||
"currentOffset": 90,
|
||||
"endOffset": 100,
|
||||
"lag": 10,
|
||||
"brokerName": "broker-a",
|
||||
"lastTimestamp": 1725000000000_i64,
|
||||
"consumerClient": "172.18.2.212@7#1"
|
||||
},
|
||||
{
|
||||
"partition": 1,
|
||||
"currentOffset": 50,
|
||||
"endOffset": 50,
|
||||
"lag": 0,
|
||||
"brokerName": "",
|
||||
"lastTimestamp": 0,
|
||||
"consumerClient": ""
|
||||
}
|
||||
]
|
||||
});
|
||||
let stats = super::backlog_stats_from_consumer_lag(&lag);
|
||||
assert_eq!(stats.msg_backlog, 10);
|
||||
assert_eq!(stats.partitions.len(), 2);
|
||||
assert_eq!(stats.partitions[0].broker_name.as_deref(), Some("broker-a"));
|
||||
assert_eq!(stats.partitions[0].consumer_client.as_deref(), Some("172.18.2.212@7#1"));
|
||||
assert_eq!(stats.partitions[0].last_timestamp, Some(1725000000000));
|
||||
// Empty strings are normalized to None so UI can show "-".
|
||||
assert!(stats.partitions[1].broker_name.is_none());
|
||||
assert!(stats.partitions[1].consumer_client.is_none());
|
||||
assert_eq!(stats.partitions[1].last_timestamp, Some(0));
|
||||
|
||||
let json = serde_json::to_value(&stats).expect("serialize backlog stats");
|
||||
assert_eq!(json.get("msgBacklog").and_then(|v| v.as_i64()), Some(10));
|
||||
assert!(json.get("partitions").and_then(|v| v.as_array()).is_some());
|
||||
|
||||
// Legacy callers that only had totals still deserialize with empty partitions.
|
||||
let legacy: super::BacklogStats =
|
||||
serde_json::from_value(serde_json::json!({ "msgBacklog": 3, "backlogSize": 0 }))
|
||||
.expect("legacy backlog stats");
|
||||
assert_eq!(legacy.msg_backlog, 3);
|
||||
assert!(legacy.partitions.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
|
|||
.route("/mq/messages/query-by-topic", post(routes::mq::query_messages_by_topic))
|
||||
.route("/mq/messages/trace", post(routes::mq::query_message_trace))
|
||||
.route("/mq/subscriptions/list", post(routes::mq::list_subscriptions))
|
||||
.route("/mq/subscriptions/enrich", post(routes::mq::enrich_subscriptions))
|
||||
.route("/mq/subscriptions/create", post(routes::mq::create_subscription))
|
||||
.route("/mq/subscriptions/delete", post(routes::mq::delete_subscription))
|
||||
.route("/mq/subscriptions/skip-messages", post(routes::mq::skip_messages))
|
||||
|
|
|
|||
|
|
@ -751,6 +751,18 @@ pub async fn list_subscriptions(
|
|||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn enrich_subscriptions(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<TopicReq>,
|
||||
) -> Result<Json<Vec<dbx_core::mq::SubscriptionInfo>>, AppError> {
|
||||
super::mcp_policy::ensure_scope(&state, &headers, &req.connection_id).await?;
|
||||
let result = dbx_core::mq::service::mq_enrich_subscriptions_core(&state.app, &req.connection_id, req.topic)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn create_subscription(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
|
|
|
|||
|
|
@ -267,6 +267,15 @@ pub async fn mq_list_subscriptions(
|
|||
dbx_core::mq::service::mq_list_subscriptions_core(&state, &connection_id, topic).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_enrich_subscriptions(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
topic: dbx_core::mq::TopicRef,
|
||||
) -> Result<Vec<dbx_core::mq::SubscriptionInfo>, String> {
|
||||
dbx_core::mq::service::mq_enrich_subscriptions_core(&state, &connection_id, topic).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_create_subscription(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
|
|
|
|||
|
|
@ -1873,6 +1873,8 @@ pub fn run() {
|
|||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_list_subscriptions,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_enrich_subscriptions,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_create_subscription,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_delete_subscription,
|
||||
|
|
|
|||
Loading…
Reference in New Issue