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

* feat(rocketmq): 优化 RocketMQ 消费者组管理和性能监控

- 添加并行订阅组配置收集功能,支持超时控制和并发限制
- 实现消费者滞后监控功能,支持按主题过滤的滞后统计
- 重构消费滞后结果构建逻辑,添加 Broker 客户端映射功能
- 优化 Admin 客户端构建,确保请求超时参数正确传递
- 为 RabbitMQ 面板组件添加 MQ 写入保护确认机制
- 统一 MQ 面板样式表引入,清理重复按钮样式定义
- 增强连接对话框中的生产环境保护逻辑,支持 MQ 类型范围
- 更新测试用例以验证新的消费者滞后计算和客户端映射功能
- 为 RocketMQ 订阅面板添加过滤计数功能和测试覆盖

* feat(rocketmq): 优化 RocketMQ 客户端连接和消费者组管理

- 移除硬编码的 5 秒收集超时,改用连接配置的请求超时
- 添加从第一个可用主节点收集消费者组配置的方法以提高速度
- 重构内部方法访问客户端 API 实现和超时配置
- 在主题过滤模式下保留一个主节点转储以恢复 FIFO 类型准确性
- 改进消费延迟计算,失败时不设置总延迟而是保持未定义
- 为消费者运行信息获取添加 Docker 地址映射支持
- 更新命名服务器探测逻辑以更好地处理高可用配置
- 增加消息队列保活机制的查询超时支持
- 在前端界面中显示部分积压加载失败的错误信息
- 添加卸载主题前的操作确认保护
- 改进空结果集的用户界面提示信息

* fix(rocketmq): 解决消费组延迟统计失败时显示为健康零延迟的问题

- 在 examineConsumeStatsRemapped 中添加成功计数和最后错误记录
- 添加 ensureConsumeStatsProbeSucceeded 方法确保没有主节点响应时抛出异常
- 修改 TopicConsumeDetail 接口使 delay 字段可选以表示探针失败
- 更新 UI 显示逻辑在延迟不可用时显示破折号而不是零
- 添加测试用例验证所有代理失败时的异常处理
- 在 Rust 代码中检查 totalLag 是否存在以避免将缺失值视为零
- 添加消息队列适配器的标识检查防止过期的保活连接被错误清理

* fix(rocketmq): 修复RocketMQ消费组详情加载和统计查询问题

- 为examineConsumeStats调用添加超时参数以支持Docker地址映射
- 更新ensureConsumeStatsProbeSucceeded方法以处理部分失败情况下的空合并结果
- 在消费者组对话框中添加序列号防止陈旧详情/配置响应覆盖新数据
- 修复消费延迟显示逻辑,将null延迟值显示为"-"
- 改进错误处理,确保仅在所有代理成功时才接受空偏移表作为有效结果

* fix(rocketmq): 修复消费者组配置收集和消息积压显示问题

- 修改 collectConsumerGroupConfigsForTopicFilter 方法以合并多个主节点的订阅配置
- 优化 Topic-filtered 列表的配置收集逻辑,确保 FIFO 组类型准确性
- 添加 totalLagFailed 标志以正确处理消息积压探测失败情况
- 更新前端组件以在探测失败时显示 "-" 而非 0
- 修复 Docker 环境下多代理地址映射冲突问题
- 添加 RFC1918 私有 IP 验证方法并改进主机可达性判断
- 为积压不可用状态添加数据模型支持

* fix(rocketmq): 修复RocketMQ消费者组配置收集中的FIFO组识别问题

- 重构订阅组配置合并逻辑,确保FIFO组不会被误识别为NORMAL组
- 添加mergeSubscriptionGroupConfigs方法处理重复和冲突的订阅组配置
- 修改collectConsumerGroupConfigsForTopicFilter方法使用新的合并策略
- 添加测试用例验证FIFO组优先级和后续组保持功能
- 修复中间代理返回重复转储时丢失FIFO-only组的问题

* refactor(rocketmq): 优化消费组统计和名称服务器连接逻辑

- 引入 MasterBrokerAddrPlan 类来管理重映射和冲突回退地址
- 将 examineConsumeStatsRemapped 重构为 examineConsumeStatsOnMasters 方法
- 实现对 Docker 环境中重映射地址的更好支持和故障转移处理
- 在连接前预先探测 NameServer 可达性以避免 JVM 启动超时
- 优化消费者组在线成员数显示,未知状态时显示为 '-'
- 添加相关单元测试验证地址解析和冲突处理逻辑

* fix(rocketmq): 修复Docker重映射碰撞和消费者组配置问题

- 修复订阅组配置查找逻辑,支持FIFO合并避免顺序消息被隐藏
- 添加超时参数到订阅组查询方法
- 实现Docker重映射碰撞检测和失败关闭机制
- 添加测试验证碰撞空合并失败关闭逻辑
- 修复RocketMQ跟踪详情对话框加载序列控制问题
- 更新消费延迟标签为积压(backlog)
- 添加连接缺失和只读写拒绝的国际化消息提示
- 修复CSS样式中按钮主题颜色应用问题
- 优化NameServer探测预算计算逻辑

* fix(rocketmq): 修复消费者组类型分类和成员计数显示问题

- 当订阅配置缺失时返回 UNKNOWN 类型而不是默认 NORMAL
- 在删除和更新消费者组时对所有主节点执行操作确保一致性
- 成员计数查询失败时保持字段为空避免显示错误的离线状态
- 添加 UNKNOWN 类型的国际化支持和UI样式适配
- 修改测试用例验证缺失配置时的类型分类逻辑

* style(mq): 移除MQ面板中的主按钮样式覆盖

- 删除了 .mq-btn-primary 和 .btn-primary 的填充颜色样式定义
- 保留了悬停效果样式以维持用户体验一致性
- 更新注释说明MQ Chrome在浅色主题中保持页面表面样式
- 避免与工具栏和表格操作按钮产生视觉冲突

* test(composables): 为 useMqMutationGuard 添加测试并更新实现

- 添加了测试用例验证MQ面板使用的getter连接ID解析
- 将 unref 替换为 toValue 以正确处理getter参数
- 更新类型导入使用 MaybeRefOrGetter 替代 MaybeRef
- 添加注释说明使用 toValue 的原因

* fix(rocketmq): 修复消费者连接检查和订阅组变更的容错机制

- 重构 examineConsumerConnectionInfoRemapped 方法以正确处理无主节点响应的情况
- 添加 requireConsumerConnectionProbeResult 方法确保连接探针结果的准确性
- 修改 mutateSubscriptionGroupOnMasters 方法实现全地址覆盖要求防止部分更新
- 新增 ensureSubscriptionGroupMutationSucceeded 和 shouldFailClosedOnCollisionPartialMutation
  方法增强订阅组变更的错误处理
- 更新 rocketmq_subscription_from_group 函数默认类型为 UNKNOWN 而非 NORMAL
- 添加多个单元测试验证连接探针和订阅组变更的各种异常场景

* test(rocketmq): 为消费者连接探测测试方法添加异常声明

- 在requireConsumerConnectionProbeResultFailsClosedWhenNoMasterAnswers测试方法中添加throws Exception声明
- 确保测试方法能够正确处理可能抛出的异常情况

* fix(i18n): MQ 生产确认 source 本地化并清理 fieldStatus 死键

---------

Co-authored-by: AiLi <aili@aili.com>
Co-authored-by: t8y2 <t8y2@users.noreply.github.com>
Co-authored-by: skyler <1156263951@qq.com>
This commit is contained in:
authored and GitHub committed 2026-08-06 01:12:31 +08:00
1 parent 5c281b1653
commit 2f9ca313cb
61 files changed
+3400 -1099

No files matched your search

@@ -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 = [];
}
+2 -34
View File
@@ -205,6 +205,8 @@ onUnmounted(() => {
</template>
<style scoped>
@import "./shared/mqPanel.css";
.broker-panel {
height: 100%;
display: flex;
@@ -253,26 +255,6 @@ onUnmounted(() => {
cursor: not-allowed;
}
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-sm:hover:not(:disabled) {
background: var(--color-hover);
}
.btn-sm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.panel-error,
.panel-loading,
.empty-state {
@@ -408,20 +390,6 @@ onUnmounted(() => {
color: var(--color-text-secondary);
}
.btn-secondary {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -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;
+8 -25
View File
@@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
import type { MqRawResponse, TopicInfo } from "@/types/mq";
import { mqRawRequest } from "@/lib/backend/api";
import { safeJsonFormat } from "@/lib/common/safeJsonFormat";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
interface Props {
connectionId: string;
@@ -16,6 +17,7 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const methods = ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"];
type RawApiPreset = {
@@ -184,6 +186,10 @@ async function executeRequest() {
error.value = readOnlyMessage.value;
return;
}
// Mutating raw admin calls need the same production confirmation as console writes.
if (!isReadMethod.value && !(await confirmMqWrite(t("mqRaw.sendRequest")))) {
return;
}
if (!path.value.trim()) {
error.value = t("mqRaw.pathRequired");
return;
@@ -277,6 +283,8 @@ async function executeRequest() {
</template>
<style scoped>
@import "./shared/mqPanel.css";
.raw-api-panel {
height: 100%;
display: flex;
@@ -514,31 +522,6 @@ pre {
color: var(--color-warning);
}
.btn-primary {
padding: 6px 12px;
border: 1px solid var(--color-primary);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-primary);
color: white;
cursor: pointer;
font-size: 13px;
}
.btn-secondary {
padding: 5px 10px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 12px;
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-secondary.compact {
padding: 3px 8px;
}
@@ -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;
+17 -56
View File
@@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
import type { TenantInfo, TenantConfig } from "@/types/mq";
import { mqListTenants, mqCreateTenant, mqUpdateTenant, mqDeleteTenant } from "@/lib/backend/api";
import { defaultTenantConfig, normalizeClusterOptions, validateTenantForm } from "@/lib/mq/mqTenantForm";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
interface Props {
connectionId: string;
@@ -19,6 +20,7 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const tenants = ref<TenantInfo[]>([]);
const loading = ref(false);
@@ -46,12 +48,12 @@ const selectedAllowedClusters = computed(() => normalizeClusterOptions(formData.
const displayedSelectedClusters = computed(() => [...normalizedClusterOptions.value.filter((cluster) => selectedAllowedClusters.value.includes(cluster)), ...selectedAllowedClusters.value.filter((cluster) => !clusterOptionSet.value.has(cluster))]);
const canSubmitTenant = computed(() => Boolean(formData.value.name.trim()) && selectedAllowedClusters.value.length > 0);
function guardWritable() {
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = t("mqTenants.readOnly");
return false;
}
return true;
return confirmMqWrite(operation);
}
async function loadTenants() {
@@ -67,7 +69,10 @@ async function loadTenants() {
}
function openCreateDialog() {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqTenants.readOnly");
return;
}
formData.value = {
name: "",
config: defaultTenantConfig(normalizedClusterOptions.value),
@@ -79,7 +84,10 @@ function openCreateDialog() {
}
function openEditDialog(tenant: TenantInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqTenants.readOnly");
return;
}
editingTenant.value = tenant;
formData.value = {
name: tenant.name,
@@ -95,7 +103,7 @@ function openEditDialog(tenant: TenantInfo) {
}
async function handleCreate() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqTenants.create")))) return;
const validationError = validateTenantForm(formData.value.name, formData.value.config);
if (validationError) {
error.value = t(validationError);
@@ -116,7 +124,7 @@ async function handleCreate() {
}
async function handleUpdate() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqTenants.edit")))) return;
if (!editingTenant.value) return;
const validationError = validateTenantForm(formData.value.name, formData.value.config);
if (validationError) {
@@ -138,7 +146,7 @@ async function handleUpdate() {
}
async function handleDelete(tenant: TenantInfo) {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqTenants.delete")))) return;
if (!confirm(t("mqTenants.confirmDelete", { name: tenant.name }))) return;
loading.value = true;
error.value = undefined;
@@ -392,6 +400,8 @@ watch(normalizedClusterOptions, (clusters) => {
</template>
<style scoped>
@import "./shared/mqPanel.css";
.tenants-panel {
height: 100%;
display: flex;
@@ -509,45 +519,6 @@ td {
gap: 8px;
}
.btn-primary,
.btn-secondary,
.btn-sm,
.btn-danger {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -588,16 +559,6 @@ button:disabled {
font-size: 18px;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
max-height: 60vh;
+22 -56
View File
@@ -14,6 +14,7 @@ import type { MqTab } from "@/lib/mq/mqConsoleDefaults";
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
import { formatError } from "@/lib/backend/errorUtils";
import { DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS, isProtectedRocketMqTopic, isRocketMqBusinessMessageType, matchesRocketMqTypeFilters, resolveRocketMqMessageType, ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES, ROCKETMQ_TOPIC_MESSAGE_TYPES } from "@/lib/mq/rocketmqTopicTypes";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
const TOPIC_ROW_HEIGHT = 44;
@@ -193,12 +194,14 @@ const canSubmitPartitionUpdate = computed(() => {
return !props.readOnly && current > 0 && Number.isFinite(newPartitions.value) && newPartitions.value > current;
});
function guardWritable() {
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return false;
}
return true;
return confirmMqWrite(operation);
}
async function loadTopics() {
@@ -234,7 +237,10 @@ async function loadClusterInfo() {
}
function openCreateDialog() {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return;
}
dialogError.value = undefined;
const defaultBroker = rocketMqMasterBrokers.value[0]?.brokerName ?? rocketMqBrokerOptions.value[0]?.brokerName ?? "";
formData.value = {
@@ -307,7 +313,10 @@ function isDlqTopic(topic: TopicInfo): boolean {
}
function openPartitionsDialog(topic: TopicInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return;
}
dialogError.value = undefined;
if (!topic.partitions || topic.partitions < 1) {
error.value = t("mqTopics.currentPartitionsUnknown");
@@ -319,7 +328,7 @@ function openPartitionsDialog(topic: TopicInfo) {
}
async function handleCreate() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqTopics.create")))) return;
if (!formData.value.topicName.trim() || !props.tenant || !props.namespace) {
dialogError.value = t("mqTopics.topicNameRequired");
return;
@@ -353,12 +362,16 @@ async function handleCreate() {
}
function handleDelete(topic: TopicInfo) {
if (!guardWritable()) return;
if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return;
}
deleteTarget.value = topic;
showDeleteDialog.value = true;
}
async function confirmDelete() {
if (!(await guardWritable(t("mqTopics.delete")))) return;
const topic = deleteTarget.value;
if (!topic || !props.tenant || !props.namespace) return;
const namespace = resolveMqRowNamespace(topic, props.namespace);
@@ -390,7 +403,7 @@ async function confirmDelete() {
}
async function handleUpdatePartitions() {
if (!guardWritable()) return;
if (!(await guardWritable(t("mqTopics.adjustPartitions")))) return;
if (!editingTopic.value || !props.tenant || !props.namespace) return;
const currentPartitions = editingTopic.value.partitions;
if (!currentPartitions || currentPartitions < 1) {
@@ -719,6 +732,8 @@ watch(newPartitions, () => {
</template>
<style scoped>
@import "./shared/mqPanel.css";
.topics-panel {
--topics-surface: var(--card, var(--color-background, #ffffff));
--topics-header-bg: color-mix(in srgb, var(--secondary, #f5f5f5) 86%, var(--card, #ffffff));
@@ -1010,45 +1025,6 @@ watch(newPartitions, () => {
color: var(--color-text);
}
.btn-primary,
.btn-secondary,
.btn-sm,
.btn-danger {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-error);
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -1089,16 +1065,6 @@ button:disabled {
font-size: 18px;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0;
line-height: 1;
}
.dialog-body {
padding: 20px;
max-height: 60vh;
@@ -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>
+110 -36
View File
@@ -1,3 +1,6 @@
/* Shared MQ panel chrome + button system.
* Prefer .mq-btn-* in new code; .btn-* aliases keep existing panels consistent. */
.mq-panel-toolbar {
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 };
}
+43
View File
@@ -3991,6 +3991,7 @@ export default {
sourceDataCompare: "Data compare",
sourceExtension: "Extension manager",
sourceSidebar: "Object tree",
sourceMq: "Message queue",
sourceObjectSource: "Object source editor",
sourceDataGenerate: "Data generator",
sourceQueryHistory: "Query history rollback",
@@ -6061,6 +6062,8 @@ export default {
viewTenant: "View tenant",
viewNamespace: "View namespace",
viewTopic: "View topic",
connectionMissing: "Connection not found. Reconnect and try again.",
writeDeniedReadOnly: "This connection is read-only and cannot perform write operations.",
readOnly: "Read-only",
allNamespaces: "All namespaces",
newNamespace: "New namespace",
@@ -6352,8 +6355,13 @@ export default {
loading: "Loading...",
refresh: "Refresh",
refreshing: "Refreshing...",
enriching: "Loading online details...",
truncatedHint: "Showing the first {count} consumer groups (list capped). Narrow the search to find others.",
enrichFailedHint: "Online member/topic details could not be loaded ({error}). Showing the fast list only.",
noSubscriptions: "No subscriptions for this topic",
noConsumerGroups: "No consumer groups",
noMatches: "No matching consumer groups",
backlogPartialFailed: "Failed to load backlog for {count} topic(s): {error}",
viewDetail: "Details",
editConfig: "Config",
save: "Save",
@@ -6364,7 +6372,18 @@ export default {
consumerConsumeDetail: "Consumption details",
noOnlineConsumers: "No online consumers",
noConsumeDetail: "No consumption details",
noQueueConsumeProgress: "No queue consumption progress",
operationTopic: "Topic",
consumeDelay: "Backlog",
invalidTopicScope: "Invalid topic scope",
lastConsumeTime: "Last consumption time",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "Consume enabled",
consumeBroadcastEnable: "Broadcast consume enabled",
consumeFromMinEnable: "Consume from min offset",
@@ -6381,6 +6400,7 @@ export default {
normal: "Normal",
fifo: "Ordered",
system: "System",
unknown: "Unknown",
},
rocketmqGroupMode: {
clustering: "Cluster",
@@ -6624,6 +6644,29 @@ export default {
searchHint: "Search for messages first, then click View trace",
viewTrace: "View trace",
detailTitle: "Message trace details",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "Unknown",
statusSuccess: "Success",
statusFail: "Failed",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store host",
fieldClientHost: "Client host",
fieldBodyLength: "Body length",
fieldCostTime: "Cost",
fieldMsgType: "Msg type",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "Retry",
fieldContextCode: "Context code",
rawPayload: "Raw trace",
traceTopicRouteMissing:
"The trace topic has no route on NameServer (CODE: 17).\n\nCommon causes:\n1. Broker trace is disabled: set traceTopicEnable=true in broker.conf and restart the broker\n2. Producer/Consumer trace is disabled: call setEnableTrace(true)\n3. A custom trace topic is used: select the correct trace topic above",
},
+43
View File
@@ -5917,6 +5917,8 @@ export default withEnglishFallback({
viewTenant: "Ver tenant",
viewNamespace: "Ver namespace",
viewTopic: "Ver tema",
connectionMissing: "No se encontró la conexión. Vuelve a conectar e inténtalo de nuevo.",
writeDeniedReadOnly: "Esta conexión es de solo lectura y no puede realizar operaciones de escritura.",
readOnly: "Solo lectura",
allNamespaces: "Todos los namespaces",
newNamespace: "Nuevo namespace",
@@ -6258,7 +6260,12 @@ export default withEnglishFallback({
searchPlaceholder: "Buscar nombre de suscripción",
subscribedTopics: "Topics suscritos",
refreshing: "Actualizando...",
enriching: "Cargando detalles en línea...",
truncatedHint: "Mostrando los primeros {count} grupos de consumidores (límite). Restrinja la búsqueda para encontrar otros.",
enrichFailedHint: "No se pudieron cargar los detalles en línea ({error}). Solo se muestra la lista rápida.",
noConsumerGroups: "No hay grupos de consumidores",
noMatches: "No hay grupos de consumidores coincidentes",
backlogPartialFailed: "Error al cargar el backlog de {count} topic(s): {error}",
viewDetail: "Detalles",
editConfig: "Configurar",
save: "Guardar",
@@ -6269,7 +6276,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "Detalles de consumo",
noOnlineConsumers: "No hay consumidores en línea actualmente",
noConsumeDetail: "Sin detalles de consumo",
noQueueConsumeProgress: "Sin progreso de consumo por cola",
operationTopic: "Topic",
consumeDelay: "Backlog",
invalidTopicScope: "Ámbito de tema no válido",
lastConsumeTime: "Último consumo",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "Permitir consumo",
consumeBroadcastEnable: "Permitir consumo en difusión",
consumeFromMinEnable: "Comenzar desde el offset mínimo",
@@ -6284,6 +6302,7 @@ export default withEnglishFallback({
normal: "Normal",
fifo: "FIFO",
system: "Sistema",
unknown: "Desconocido",
},
rocketmqGroupMode: {
clustering: "Cluster",
@@ -6522,6 +6541,7 @@ export default withEnglishFallback({
sourceDataCompare: "Comparación de datos",
sourceExtension: "Gestor de extensiones",
sourceSidebar: "Árbol de objetos",
sourceMq: "Cola de mensajes",
sourceObjectSource: "Editor de código fuente de objetos",
sourceDataGenerate: "Generador de datos",
sourceQueryHistory: "Reversión del historial de consultas",
@@ -6916,5 +6936,28 @@ export default withEnglishFallback({
detailTitle: "Detalles de la traza de consumo",
traceTopicRouteMissing:
"El Topic de trazas no tiene información de ruta en NameServer (CODE: 17).\n\nCausas comunes:\n1. Broker no tiene habilitada la traza de mensajes: configure traceTopicEnable=true en broker.conf y reinicie el Broker\n2. Producer/Consumer no tiene habilitada la traza: setEnableTrace(true)\n3. Se utilizó un Topic de trazas personalizado, seleccione el Topic de trazas correcto arriba.",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "Desconocido",
statusSuccess: "Correcto",
statusFail: "Fallido",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store host",
fieldClientHost: "Client host",
fieldBodyLength: "Body length",
fieldCostTime: "Coste",
fieldMsgType: "Msg type",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "Reintentos",
fieldContextCode: "Context code",
rawPayload: "Traza en bruto",
},
});
+43
View File
@@ -5917,6 +5917,8 @@ export default withEnglishFallback({
viewTenant: "Visualizza tenant",
viewNamespace: "Visualizza namespace",
viewTopic: "Visualizza topic",
connectionMissing: "Connessione non trovata. Riconnetti e riprova.",
writeDeniedReadOnly: "La connessione corrente è in modalità sola lettura, impossibile eseguire operazioni di scrittura.",
readOnly: "Sola lettura",
allNamespaces: "Tutti i namespace",
newNamespace: "Nuovo namespace",
@@ -6258,7 +6260,12 @@ export default withEnglishFallback({
searchPlaceholder: "Cerca nome sottoscrizione",
subscribedTopics: "Topic sottoscritti",
refreshing: "Aggiornamento in corso...",
enriching: "Caricamento dettagli online...",
truncatedHint: "Vengono mostrati i primi {count} consumer group (limite). Restringi la ricerca per trovarne altri.",
enrichFailedHint: "Impossibile caricare i dettagli online ({error}). Viene mostrata solo l'elenco rapido.",
noConsumerGroups: "Nessun Consumer Group",
noMatches: "Nessun consumer group corrispondente",
backlogPartialFailed: "Caricamento backlog non riuscito per {count} topic: {error}",
viewDetail: "Dettagli",
editConfig: "Configurazione",
save: "Salva",
@@ -6269,7 +6276,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "Dettagli consumo",
noOnlineConsumers: "Nessun consumatore online",
noConsumeDetail: "Nessun dettaglio consumo",
noQueueConsumeProgress: "Nessun progresso di consumo per coda",
operationTopic: "Topic",
consumeDelay: "Backlog",
invalidTopicScope: "Ambito topic non valido",
lastConsumeTime: "Ultimo consumo",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "Abilita consumo",
consumeBroadcastEnable: "Abilita consumo broadcast",
consumeFromMinEnable: "Inizia dall'offset minimo",
@@ -6284,6 +6302,7 @@ export default withEnglishFallback({
normal: "Normale",
fifo: "FIFO",
system: "Sistema",
unknown: "Sconosciuto",
},
rocketmqGroupMode: {
clustering: "Cluster",
@@ -6522,6 +6541,7 @@ export default withEnglishFallback({
sourceDataCompare: "Confronto dati",
sourceExtension: "Gestione estensioni",
sourceSidebar: "Albero oggetti",
sourceMq: "Coda di messaggi",
sourceObjectSource: "Editor sorgente oggetto",
sourceDataGenerate: "Generatore dati",
sourceQueryHistory: "Rollback cronologia query",
@@ -6916,5 +6936,28 @@ export default withEnglishFallback({
detailTitle: "Dettagli traccia consumo",
traceTopicRouteMissing:
"Il Topic di traccia non ha informazioni di instradamento sul NameServer (CODE: 17).\n\nCause comuni:\n1. Il Broker non ha abilitato la traccia messaggi: imposta traceTopicEnable=true in broker.conf e riavvia il Broker\n2. Producer/Consumer non hanno abilitato la traccia: setEnableTrace(true)\n3. È stato utilizzato un Topic di traccia personalizzato, seleziona il Topic di traccia corretto sopra.",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "Sconosciuto",
statusSuccess: "Riuscito",
statusFail: "Non riuscito",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store host",
fieldClientHost: "Client host",
fieldBodyLength: "Body length",
fieldCostTime: "Costo",
fieldMsgType: "Msg type",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "Tentativi",
fieldContextCode: "Context code",
rawPayload: "Traccia grezza",
},
});
+43
View File
@@ -5972,6 +5972,8 @@ export default withEnglishFallback({
viewTenant: "テナントを表示",
viewNamespace: "名前空間を表示",
viewTopic: "トピックを表示",
connectionMissing: "接続が見つかりません。再接続してから再試行してください。",
writeDeniedReadOnly: "現在の接続は読み取り専用モードです。書き込み操作は実行できません。",
readOnly: "読み取り専用",
allNamespaces: "すべての名前空間",
newNamespace: "新規名前空間",
@@ -6313,7 +6315,12 @@ export default withEnglishFallback({
searchPlaceholder: "サブスクリプション名を検索",
subscribedTopics: "サブスクライブされた Topic",
refreshing: "更新中...",
enriching: "オンライン詳細を読み込み中...",
truncatedHint: "最初の {count} 件のコンシューマグループのみ表示しています(上限)。検索条件を絞ってください。",
enrichFailedHint: "オンラインメンバー/購読 Topic の読み込みに失敗しました({error})。高速一覧のみ表示しています。",
noConsumerGroups: "Consumer Group がありません",
noMatches: "一致するコンシューマグループがありません",
backlogPartialFailed: "{count} 件の Topic のバックログ読み込みに失敗しました: {error}",
viewDetail: "詳細",
editConfig: "設定",
save: "保存",
@@ -6324,7 +6331,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "消費詳細",
noOnlineConsumers: "現在オンラインのコンシューマはいません",
noConsumeDetail: "消費詳細がありません",
noQueueConsumeProgress: "キューの消費進捗がありません",
operationTopic: "Topic",
consumeDelay: "バックログ",
invalidTopicScope: "トピックのスコープが無効です",
lastConsumeTime: "最終消費時間",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "消費を許可",
consumeBroadcastEnable: "ブロードキャスト消費を許可",
consumeFromMinEnable: "最小 offset から開始",
@@ -6339,6 +6357,7 @@ export default withEnglishFallback({
normal: "通常",
fifo: "FIFO",
system: "システム",
unknown: "不明",
},
rocketmqGroupMode: {
clustering: "クラスタリング",
@@ -6577,6 +6596,7 @@ export default withEnglishFallback({
sourceDataCompare: "データ比較",
sourceExtension: "拡張機能マネージャー",
sourceSidebar: "オブジェクトツリー",
sourceMq: "メッセージキュー",
sourceObjectSource: "オブジェクトソースエディター",
sourceDataGenerate: "データジェネレーター",
sourceQueryHistory: "クエリ履歴のロールバック",
@@ -6971,5 +6991,28 @@ export default withEnglishFallback({
detailTitle: "消費トレース詳細",
traceTopicRouteMissing:
"トレース Topic が NameServer にルート情報がありません (CODE: 17)。\n\n一般的な原因:\n1. Broker でメッセージトレースが有効になっていない: broker.conf で traceTopicEnable=true を設定し Broker を再起動\n2. Producer/Consumer でトレースが有効になっていない: setEnableTrace(true)\n3. カスタムトレース Topic を使用している場合は、上で正しいトレース Topic を選択してください",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "不明",
statusSuccess: "成功",
statusFail: "失敗",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store Host",
fieldClientHost: "Client Host",
fieldBodyLength: "Body Length",
fieldCostTime: "所要時間",
fieldMsgType: "メッセージ種別",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "リトライ回数",
fieldContextCode: "Context Code",
rawPayload: "生トレース",
},
});
+43
View File
@@ -3538,6 +3538,7 @@ export default withEnglishFallback({
sourceDataCompare: "데이터 비교",
sourceExtension: "확장 관리자",
sourceSidebar: "객체 트리",
sourceMq: "메시지 큐",
sourceObjectSource: "객체 소스 편집기",
sourceDataGenerate: "데이터 생성기",
sourceQueryHistory: "쿼리 기록 롤백",
@@ -5585,6 +5586,8 @@ export default withEnglishFallback({
viewTenant: "테넌트 보기",
viewNamespace: "네임스페이스 보기",
viewTopic: "토픽 보기",
connectionMissing: "연결을 찾을 수 없습니다. 다시 연결한 후 시도하세요.",
writeDeniedReadOnly: "이 연결은 읽기 전용이며 쓰기 작업을 수행할 수 없습니다.",
readOnly: "읽기 전용",
allNamespaces: "모든 네임스페이스",
newNamespace: "새 네임스페이스",
@@ -5876,8 +5879,13 @@ export default withEnglishFallback({
loading: "불러오는 중...",
refresh: "새로고침",
refreshing: "새로고침 중...",
enriching: "온라인 상세 불러오는 중...",
truncatedHint: "처음 {count}개의 컨슈머 그룹만 표시합니다(상한). 검색 범위를 좁혀 주세요.",
enrichFailedHint: "온라인 멤버/구독 토픽 정보를 불러오지 못했습니다({error}). 빠른 목록만 표시합니다.",
noSubscriptions: "이 토픽에 구독이 없습니다",
noConsumerGroups: "컨슈머 그룹이 없습니다",
noMatches: "일치하는 컨슈머 그룹이 없습니다",
backlogPartialFailed: "{count}개 토픽의 백로그를 불러오지 못했습니다: {error}",
viewDetail: "상세",
editConfig: "구성",
save: "저장",
@@ -5888,7 +5896,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "소비 상세",
noOnlineConsumers: "온라인 컨슈머가 없습니다",
noConsumeDetail: "소비 상세가 없습니다",
noQueueConsumeProgress: "큐 소비 진행 정보가 없습니다",
operationTopic: "토픽",
consumeDelay: "적체",
invalidTopicScope: "잘못된 토픽 범위",
lastConsumeTime: "마지막 소비 시간",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "소비 활성화",
consumeBroadcastEnable: "브로드캐스트 소비 활성화",
consumeFromMinEnable: "최소 오프셋부터 소비",
@@ -5905,6 +5924,7 @@ export default withEnglishFallback({
normal: "일반",
fifo: "순서 보장",
system: "시스템",
unknown: "알 수 없음",
},
rocketmqGroupMode: {
clustering: "클러스터",
@@ -6149,6 +6169,29 @@ export default withEnglishFallback({
detailTitle: "메시지 추적 상세",
traceTopicRouteMissing:
"추적 토픽이 NameServer에 라우트 정보가 없습니다 (CODE: 17).\n\n일반적인 원인:\n1. Broker 추적이 비활성화됨: broker.conf에서 traceTopicEnable=true를 설정하고 Broker를 다시 시작\n2. Producer/Consumer 추적이 비활성화됨: setEnableTrace(true) 호출\n3. 사용자 지정 추적 토픽 사용: 위에서 올바른 추적 토픽을 선택하세요",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "알 수 없음",
statusSuccess: "성공",
statusFail: "실패",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store Host",
fieldClientHost: "Client Host",
fieldBodyLength: "Body Length",
fieldCostTime: "소요 시간",
fieldMsgType: "메시지 유형",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "재시도",
fieldContextCode: "Context Code",
rawPayload: "원본 추적",
},
mqRaw: {
title: "원시 API",
+43
View File
@@ -5919,6 +5919,8 @@ export default withEnglishFallback({
viewTenant: "Ver locatário",
viewNamespace: "Ver namespace",
viewTopic: "Ver tópico",
connectionMissing: "Conexão não encontrada. Reconecte e tente novamente.",
writeDeniedReadOnly: "Esta conexão é somente leitura e não pode executar operações de gravação.",
readOnly: "Somente leitura",
allNamespaces: "Todos os namespaces",
newNamespace: "Novo namespace",
@@ -6260,7 +6262,12 @@ export default withEnglishFallback({
searchPlaceholder: "Pesquisar nome da assinatura",
subscribedTopics: "Tópicos inscritos",
refreshing: "Atualizando...",
enriching: "Carregando detalhes online...",
truncatedHint: "Mostrando os primeiros {count} grupos de consumidores (limite). Restrinja a busca para encontrar outros.",
enrichFailedHint: "Não foi possível carregar os detalhes online ({error}). Mostrando apenas a lista rápida.",
noConsumerGroups: "Nenhum Consumer Group",
noMatches: "Nenhum grupo de consumidores correspondente",
backlogPartialFailed: "Falha ao carregar backlog de {count} tópico(s): {error}",
viewDetail: "Detalhes",
editConfig: "Configuração",
save: "Salvar",
@@ -6271,7 +6278,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "Detalhes do consumo",
noOnlineConsumers: "Nenhum consumidor online no momento",
noConsumeDetail: "Nenhum detalhe de consumo",
noQueueConsumeProgress: "Sem progresso de consumo por fila",
operationTopic: "Topic",
consumeDelay: "Backlog",
invalidTopicScope: "Escopo de tópico inválido",
lastConsumeTime: "Último consumo",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "Permitir consumo",
consumeBroadcastEnable: "Permitir consumo em broadcast",
consumeFromMinEnable: "Iniciar do offset mínimo",
@@ -6286,6 +6304,7 @@ export default withEnglishFallback({
normal: "Normal",
fifo: "Ordenado",
system: "Sistema",
unknown: "Desconhecido",
},
rocketmqGroupMode: {
clustering: "Cluster",
@@ -6524,6 +6543,7 @@ export default withEnglishFallback({
sourceDataCompare: "Comparação de dados",
sourceExtension: "Gerenciador de extensões",
sourceSidebar: "Árvore de objetos",
sourceMq: "Fila de mensagens",
sourceObjectSource: "Editor de código-fonte de objetos",
sourceDataGenerate: "Gerador de dados",
sourceQueryHistory: "Reversão do histórico de consultas",
@@ -6918,5 +6938,28 @@ export default withEnglishFallback({
detailTitle: "Detalhes do rastreamento de consumo",
traceTopicRouteMissing:
"O tópico de rastreamento não possui informações de rota no NameServer (CODE: 17).\n\nCausas comuns:\n1. Broker não habilitou rastreamento de mensagens: defina traceTopicEnable=true no broker.conf e reinicie o Broker\n2. Producer/Consumer não habilitou rastreamento: setEnableTrace(true)\n3. Usou um tópico de rastreamento personalizado, selecione o tópico correto acima.",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "Desconhecido",
statusSuccess: "Sucesso",
statusFail: "Falhou",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store host",
fieldClientHost: "Client host",
fieldBodyLength: "Body length",
fieldCostTime: "Custo",
fieldMsgType: "Msg type",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "Tentativas",
fieldContextCode: "Context code",
rawPayload: "Rastreamento bruto",
},
});
+43
View File
@@ -3990,6 +3990,7 @@ export default withEnglishFallback({
sourceDataCompare: "数据对比",
sourceExtension: "扩展管理",
sourceSidebar: "对象树",
sourceMq: "消息队列",
sourceObjectSource: "对象源码编辑器",
sourceDataGenerate: "数据生成器",
sourceQueryHistory: "查询历史回滚",
@@ -6058,6 +6059,8 @@ export default withEnglishFallback({
viewTenant: "查看租户",
viewNamespace: "查看命名空间",
viewTopic: "查看主题",
connectionMissing: "未找到连接,请重新连接后再试。",
writeDeniedReadOnly: "当前连接为只读模式,不能执行写操作。",
readOnly: "只读",
allNamespaces: "全部命名空间",
newNamespace: "新建命名空间",
@@ -6344,6 +6347,9 @@ export default withEnglishFallback({
createSubscription: "创建订阅",
selectTopicFirst: "请先选择一个主题",
searchPlaceholder: "搜索订阅名称",
enriching: "正在补全在线信息...",
truncatedHint: "当前仅显示前 {count} 个消费组(已达上限),请缩小搜索范围查找其他组",
enrichFailedHint: "在线成员/订阅 Topic 信息加载失败({error}),当前仅显示快速列表",
subscribedTopics: "订阅 Topic",
readOnly: "当前连接为只读模式,不能执行写操作",
loading: "加载中...",
@@ -6351,6 +6357,8 @@ export default withEnglishFallback({
refreshing: "刷新中...",
noSubscriptions: "该主题暂无订阅",
noConsumerGroups: "暂无 Consumer Group",
noMatches: "没有匹配的 Consumer Group",
backlogPartialFailed: "{count} 个 Topic 的堆积信息加载失败:{error}",
viewDetail: "详情",
editConfig: "配置",
save: "保存",
@@ -6361,7 +6369,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "消费详情",
noOnlineConsumers: "当前没有在线消费者",
noConsumeDetail: "暂无消费详情",
noQueueConsumeProgress: "暂无队列消费进度",
operationTopic: "Topic",
consumeDelay: "积压",
invalidTopicScope: "无效的 Topic 作用域",
lastConsumeTime: "最后消费时间",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "允许消费",
consumeBroadcastEnable: "允许广播消费",
consumeFromMinEnable: "从最小 offset 开始",
@@ -6378,6 +6397,7 @@ export default withEnglishFallback({
normal: "普通",
fifo: "顺序",
system: "系统",
unknown: "未知",
},
rocketmqGroupMode: {
clustering: "集群",
@@ -6620,6 +6640,29 @@ export default withEnglishFallback({
searchHint: "请先搜索消息,再点击「查看消费轨迹」",
viewTrace: "查看消费轨迹",
detailTitle: "消费轨迹详情",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "未知",
statusSuccess: "成功",
statusFail: "失败",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store Host",
fieldClientHost: "Client Host",
fieldBodyLength: "Body Length",
fieldCostTime: "耗时",
fieldMsgType: "消息类型",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "重试次数",
fieldContextCode: "Context Code",
rawPayload: "原始轨迹",
traceTopicRouteMissing: "轨迹 Topic 在 NameServer 上没有路由信息(CODE: 17)。\n\n常见原因:\n1. Broker 未开启消息轨迹:在 broker.conf 中设置 traceTopicEnable=true 并重启 Broker\n2. Producer/Consumer 未开启轨迹:setEnableTrace(true)\n3. 使用了自定义轨迹 Topic,请在上方选择正确的轨迹 Topic",
},
mqRaw: {
+43
View File
@@ -5912,6 +5912,8 @@ export default withEnglishFallback({
viewTenant: "檢視租用戶",
viewNamespace: "檢視命名空間",
viewTopic: "檢視主題",
connectionMissing: "找不到連線,請重新連線後再試。",
writeDeniedReadOnly: "目前連線為唯讀模式,無法執行寫入操作。",
readOnly: "唯讀",
allNamespaces: "全部命名空間",
newNamespace: "新建命名空間",
@@ -6253,7 +6255,12 @@ export default withEnglishFallback({
searchPlaceholder: "搜尋訂閱名稱",
subscribedTopics: "訂閱 Topic",
refreshing: "重新整理中...",
enriching: "正在載入線上詳情...",
truncatedHint: "目前僅顯示前 {count} 個消費組(已達上限),請縮小搜尋範圍查找其他組",
enrichFailedHint: "線上成員/訂閱 Topic 資訊載入失敗({error}),目前僅顯示快速列表",
noConsumerGroups: "暫無 Consumer Group",
noMatches: "沒有符合的消費組",
backlogPartialFailed: "{count} 個 Topic 的堆積資訊載入失敗:{error}",
viewDetail: "詳情",
editConfig: "配置",
save: "儲存",
@@ -6264,7 +6271,18 @@ export default withEnglishFallback({
consumerConsumeDetail: "消費詳情",
noOnlineConsumers: "當前沒有線上消費者",
noConsumeDetail: "暫無消費詳情",
noQueueConsumeProgress: "暫無佇列消費進度",
operationTopic: "Topic",
consumeDelay: "積壓",
invalidTopicScope: "無效的主題作用域",
lastConsumeTime: "最後消費時間",
broker: "broker",
queue: "queue",
consumerClient: "consumerClient",
brokerOffset: "brokerOffset",
consumerOffset: "consumerOffset",
diffTotal: "diffTotal",
lastTimestamp: "lastTimestamp",
consumeEnable: "允許消費",
consumeBroadcastEnable: "允許廣播消費",
consumeFromMinEnable: "從最小 offset 開始",
@@ -6279,6 +6297,7 @@ export default withEnglishFallback({
normal: "普通",
fifo: "順序",
system: "系統",
unknown: "未知",
},
rocketmqGroupMode: {
clustering: "集群",
@@ -6517,6 +6536,7 @@ export default withEnglishFallback({
sourceDataCompare: "資料比對",
sourceExtension: "擴充功能管理",
sourceSidebar: "物件樹",
sourceMq: "訊息佇列",
sourceObjectSource: "物件原始碼編輯器",
sourceDataGenerate: "資料產生器",
sourceQueryHistory: "查詢歷史回復",
@@ -6909,6 +6929,29 @@ export default withEnglishFallback({
searchHint: "請先搜尋消息,再點擊「檢視消費軌跡」",
viewTrace: "檢視消費軌跡",
detailTitle: "消費軌跡詳情",
typePub: "Pub",
typeSubBefore: "SubBefore",
typeSubAfter: "SubAfter",
typeEndTransaction: "EndTransaction",
typeUnknown: "未知",
statusSuccess: "成功",
statusFail: "失敗",
fieldRegionId: "Region",
fieldGroup: "Group",
fieldTopic: "Topic",
fieldMsgId: "MsgId",
fieldTags: "Tags",
fieldKeys: "Keys",
fieldStoreHost: "Store Host",
fieldClientHost: "Client Host",
fieldBodyLength: "Body Length",
fieldCostTime: "耗時",
fieldMsgType: "訊息類型",
fieldOffsetMsgId: "Offset MsgId",
fieldRequestId: "Request ID",
fieldRetryTimes: "重試次數",
fieldContextCode: "Context Code",
rawPayload: "原始軌跡",
traceTopicRouteMissing: "軌跡 Topic 在 NameServer 上沒有路由資訊(CODE: 17)。\n\n常見原因:\n1. Broker 未開啟消息軌跡:在 broker.conf 中設置 traceTopicEnable=true 並重新啟動 Broker\n2. Producer/Consumer 未開啟軌跡:setEnableTrace(true)\n3. 使用了自定義軌跡 Topic,請在上方選擇正確的軌跡 Topic",
},
});
+1
View File
@@ -489,6 +489,7 @@ export const mqListClientConnections = forward("mqListClientConnections");
export const mqListClientChannels = forward("mqListClientChannels");
export const mqCloseClientConnection = forward("mqCloseClientConnection");
export const mqListSubscriptions = forward("mqListSubscriptions");
export const mqEnrichSubscriptions = forward("mqEnrichSubscriptions");
export const mqCreateSubscription = forward("mqCreateSubscription");
export const mqDeleteSubscription = forward("mqDeleteSubscription");
export const mqSkipMessages = forward("mqSkipMessages");
+4
View File
@@ -217,6 +217,10 @@ export async function mqListSubscriptions(connectionId: string, topic: TopicRef)
return post("/api/mq/subscriptions/list", { connectionId, topic });
}
export async function mqEnrichSubscriptions(connectionId: string, topic: TopicRef): Promise<SubscriptionInfo[]> {
return post("/api/mq/subscriptions/enrich", { connectionId, topic });
}
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
return post("/api/mq/subscriptions/create", { connectionId, topic, sub, pos });
}
+4
View File
@@ -211,6 +211,10 @@ export async function mqListSubscriptions(connectionId: string, topic: TopicRef)
return invoke("mq_list_subscriptions", { connectionId, topic });
}
export async function mqEnrichSubscriptions(connectionId: string, topic: TopicRef): Promise<SubscriptionInfo[]> {
return invoke("mq_enrich_subscriptions", { connectionId, topic });
}
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
return invoke("mq_create_subscription", { connectionId, topic, sub, pos });
}
@@ -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);
}
+18
View File
@@ -192,6 +192,8 @@ export interface SubscriptionInfo {
consumerGroupType?: string;
/** RocketMQ consumer group message model: CLUSTERING / BROADCASTING. */
messageModel?: string;
/** When true, backlog probe failed — do not render msgBacklog as healthy zero. */
backlogUnavailable?: boolean;
}
export interface RocketMqConsumerGroupConfig {
@@ -228,9 +230,25 @@ export type ResetPosition = { kind: "earliest" } | { kind: "latest" } | { kind:
export type SkipCount = { kind: "all" } | { kind: "count"; count: number };
/** Per-queue consume progress (RocketMQ Dashboard consume-detail / Kafka lag rows). */
export interface PartitionBacklog {
partition: number;
/** Consumer committed offset (`consumerOffset`). */
currentOffset: number;
/** Broker max offset (`brokerOffset`). */
endOffset: number;
lag: number;
brokerName?: string;
/** Last consume message store timestamp (ms). `0` means unavailable. */
lastTimestamp?: number;
consumerClient?: string;
}
export interface BacklogStats {
msgBacklog: number;
backlogSize: number;
/** Optional queue-level progress; omitted or empty when adapter only exposes totals. */
partitions?: PartitionBacklog[];
}
export interface ClusterInfo {