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:
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 = [];
|
||||
}
|
||||
|
||||
@@ -205,6 +205,8 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.broker-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -253,26 +255,6 @@ onUnmounted(() => {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-sm:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-sm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panel-error,
|
||||
.panel-loading,
|
||||
.empty-state {
|
||||
@@ -408,20 +390,6 @@ onUnmounted(() => {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { mqBind, mqCreateExchange, mqDeleteExchange, mqListBindings, mqListExcha
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { isBuiltinRabbitMqExchange, rabbitMqExchangeDisplayName, RABBITMQ_EXCHANGE_TYPES } from "@/lib/mq/rabbitmqExchanges";
|
||||
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
@@ -17,6 +18,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const exchanges = ref<MqExchangeInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
@@ -79,12 +81,12 @@ function nsRefFor(row: { namespace?: string } | undefined): NamespaceRef | null
|
||||
return { tenant: props.tenant, namespace };
|
||||
}
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqExchanges.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadExchanges() {
|
||||
@@ -143,14 +145,17 @@ function selectExchange(exchange: MqExchangeInfo) {
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqExchanges.readOnly");
|
||||
return;
|
||||
}
|
||||
dialogError.value = undefined;
|
||||
createForm.value = { name: "", type: "direct", durable: true, autoDelete: false };
|
||||
showCreateDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqExchanges.create")))) return;
|
||||
const ns = nsRef();
|
||||
if (!createForm.value.name.trim() || !ns) {
|
||||
dialogError.value = t("mqExchanges.nameRequired");
|
||||
@@ -176,13 +181,17 @@ async function handleCreate() {
|
||||
}
|
||||
|
||||
function openDeleteDialog(exchange: MqExchangeInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqExchanges.readOnly");
|
||||
return;
|
||||
}
|
||||
if (isBuiltinRabbitMqExchange(exchange)) return;
|
||||
deleteTarget.value = exchange;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!(await guardWritable(t("mqExchanges.delete")))) return;
|
||||
const target = deleteTarget.value;
|
||||
if (!target) return;
|
||||
const ns = nsRefFor(target);
|
||||
@@ -208,7 +217,10 @@ async function confirmDelete() {
|
||||
}
|
||||
|
||||
function openBindDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqExchanges.readOnly");
|
||||
return;
|
||||
}
|
||||
if (!selectedExchange.value) return;
|
||||
dialogError.value = undefined;
|
||||
bindForm.value = { destinationType: "queue", destination: "", routingKey: "", argumentsText: "" };
|
||||
@@ -227,7 +239,7 @@ function parseBindingArguments(): Record<string, unknown> | undefined {
|
||||
}
|
||||
|
||||
async function handleBind() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqExchanges.bind")))) return;
|
||||
const exchange = selectedExchange.value;
|
||||
if (!exchange) return;
|
||||
const ns = nsRefFor(exchange);
|
||||
@@ -260,12 +272,16 @@ async function handleBind() {
|
||||
}
|
||||
|
||||
function openUnbindDialog(bindingRow: MqBindingInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqExchanges.readOnly");
|
||||
return;
|
||||
}
|
||||
unbindTarget.value = bindingRow;
|
||||
showUnbindDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmUnbind() {
|
||||
if (!(await guardWritable(t("mqExchanges.unbind")))) return;
|
||||
const target = unbindTarget.value;
|
||||
if (!target) return;
|
||||
const ns = nsRefFor(target ?? selectedExchange.value);
|
||||
@@ -522,6 +538,8 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.exchanges-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -700,45 +718,6 @@ td {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -778,16 +757,6 @@ button:disabled {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
|
||||
@@ -225,6 +225,8 @@ watch(kafkaStartPosition, () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.message-browser {
|
||||
margin-top: 4px;
|
||||
padding: 14px;
|
||||
@@ -257,20 +259,6 @@ watch(kafkaStartPosition, () => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-sm:hover:not(:disabled) {
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.btn-sm:disabled,
|
||||
.peek-controls input:disabled,
|
||||
.peek-controls :deep(.message-browser-start-position[data-disabled]) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formatRocketMqMessagePayload, formatRocketMqTimestamp, parseRocketMqMes
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { buildRocketMqTraceTopicOptions, DEFAULT_ROCKETMQ_TRACE_TOPIC, resolveRocketMqMessageType } from "@/lib/mq/rocketmqTopicTypes";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import RocketMqTraceDetailDialog from "./RocketMqTraceDetailDialog.vue";
|
||||
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
|
||||
import RocketMqMessageDetailDialog from "./shared/RocketMqMessageDetailDialog.vue";
|
||||
@@ -25,6 +26,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const topicSelectRef = ref<InstanceType<typeof RocketMqTopicSelect>>();
|
||||
const availableTopics = ref<TopicInfo[]>([]);
|
||||
@@ -258,6 +260,7 @@ async function resendMessage() {
|
||||
resendError.value = t("mqMessages.readOnlyCannotSend");
|
||||
return;
|
||||
}
|
||||
if (!(await confirmMqWrite(t("mqMessages.resendMessage")))) return;
|
||||
const topic = resendTopic.value;
|
||||
const payloadText = detailPayload.value.trim();
|
||||
if (!topic) {
|
||||
@@ -493,6 +496,8 @@ watch(topicName, () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.message-query-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -587,32 +592,6 @@ watch(topicName, () => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-icon:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.spin {
|
||||
display: inline-block;
|
||||
animation: spin-anim 0.8s linear infinite;
|
||||
@@ -711,39 +690,12 @@ watch(topicName, () => {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-sm {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.operation-cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-sm:hover:not(:disabled) {
|
||||
background: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 7px 16px;
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -791,15 +743,6 @@ watch(topicName, () => {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
@@ -843,16 +786,6 @@ watch(topicName, () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.detail-payload {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
|
||||
import type { MqAdminConfig, MqClusterInfo, MqSystemKind, NamespaceRef, TopicInfo } from "@/types/mq";
|
||||
import { mqCreateNamespace, mqListNamespaces, mqTestConnection } from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import { mqClusterOptionsFromExtra } from "@/lib/mq/mqTenantForm";
|
||||
import {
|
||||
defaultMqCapabilitiesForSystemKind,
|
||||
@@ -50,6 +51,8 @@ interface Props {
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
const isProductionConnection = computed(() => !!connectionStore.getConfig(props.connectionId)?.is_production);
|
||||
const configuredSystemKind = computed(() => resolveMqSystemKindFromConnection(connectionStore.getConfig(props.connectionId)));
|
||||
const FLAT_MQ_CONTEXT = "_flat_mq";
|
||||
|
||||
@@ -225,6 +228,7 @@ function handleNamespaceSelect(value: AcceptableValue) {
|
||||
|
||||
async function handleCreateNamespace() {
|
||||
if (props.readOnly) return;
|
||||
if (!(await confirmMqWrite(t("mqNamespaces.create")))) return;
|
||||
const name = createNamespaceName.value.trim();
|
||||
if (!name) {
|
||||
createNamespaceError.value = t("mqNamespaces.namespaceNameRequired");
|
||||
@@ -398,6 +402,7 @@ onMounted(async () => {
|
||||
<button v-if="selectedTopic" class="breadcrumb-button" @click="goToTopicLevel" :title="t('mqAdmin.viewTopic')">{{ selectedTopic.shortName }}</button>
|
||||
</div>
|
||||
<div class="toolbar-status">
|
||||
<span v-if="isProductionConnection" class="prod-badge">PROD</span>
|
||||
<span v-if="readOnly" class="readonly-badge">{{ t("mqAdmin.readOnly") }}</span>
|
||||
<span v-if="error" class="toolbar-error">{{ error }}</span>
|
||||
</div>
|
||||
@@ -560,6 +565,8 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.mq-admin-console {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -635,6 +642,15 @@ onMounted(async () => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prod-badge {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgb(220 38 38 / 0.55);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
color: rgb(185 28 28);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mq-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -735,16 +751,6 @@ onMounted(async () => {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
}
|
||||
@@ -784,28 +790,6 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { NamespaceRef, NamespaceInfo, NamespaceConfig } from "@/types/mq";
|
||||
import { mqListNamespaces, mqCreateNamespace, mqDeleteNamespace } from "@/lib/backend/api";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
@@ -23,6 +24,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const namespaces = ref<NamespaceInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
@@ -37,12 +39,12 @@ const formData = ref({
|
||||
namespace: "",
|
||||
});
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqNamespaces.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadNamespaces() {
|
||||
@@ -62,7 +64,10 @@ async function loadNamespaces() {
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqNamespaces.readOnly");
|
||||
return;
|
||||
}
|
||||
formData.value = {
|
||||
namespace: "",
|
||||
};
|
||||
@@ -70,7 +75,7 @@ function openCreateDialog() {
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqNamespaces.create")))) return;
|
||||
if (!formData.value.namespace.trim() || !props.tenant) {
|
||||
error.value = t("mqNamespaces.namespaceNameRequired");
|
||||
return;
|
||||
@@ -94,12 +99,16 @@ async function handleCreate() {
|
||||
}
|
||||
|
||||
function handleDelete(ns: NamespaceInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqNamespaces.readOnly");
|
||||
return;
|
||||
}
|
||||
deleteTarget.value = ns;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!(await guardWritable(t("mqNamespaces.delete")))) return;
|
||||
const ns = deleteTarget.value;
|
||||
if (!ns || !props.tenant) return;
|
||||
deleting.value = true;
|
||||
@@ -217,6 +226,8 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.namespaces-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -307,45 +318,6 @@ td {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -414,16 +386,6 @@ button:disabled {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
|
||||
import type { AuthAction, MqIssuedToken, MqSystemKind, MqTokenRecord, PermissionMap, PolicyScope, TopicInfo } from "@/types/mq";
|
||||
import { mqGrantPermission, mqIssueToken, mqListPermissions, mqListTokenRecords, mqRevokePermission } from "@/lib/backend/api";
|
||||
import { formatMqTokenIssueError, type MqTokenIssueErrorView } from "@/lib/mq/mqTokenErrors";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
@@ -17,6 +18,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const actionOptions: AuthAction[] = ["produce", "consume", "functions", "sources", "sinks", "packages"];
|
||||
|
||||
@@ -78,13 +80,13 @@ const permissionRows = computed(() => {
|
||||
return Object.entries(permissions.value).map(([role, actions]) => ({ role, actions }));
|
||||
});
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage.value;
|
||||
notice.value = undefined;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
function formatActionLabel(action: AuthAction): string {
|
||||
@@ -120,7 +122,7 @@ async function loadPermissions() {
|
||||
}
|
||||
|
||||
async function grantPermission() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqPermissions.grant")))) return;
|
||||
const current = scope.value;
|
||||
const role = roleName.value.trim();
|
||||
roleNameError.value = "";
|
||||
@@ -163,7 +165,7 @@ async function grantPermission() {
|
||||
}
|
||||
|
||||
async function revokePermission(role: string) {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqPermissions.revoke")))) return;
|
||||
const current = scope.value;
|
||||
if (!current) {
|
||||
error.value = t("mqPermissions.selectNamespaceOrTopic");
|
||||
@@ -228,6 +230,10 @@ async function issueToken() {
|
||||
tokenIssueError.value = undefined;
|
||||
return;
|
||||
}
|
||||
if (!(await confirmMqWrite(t("mqPermissions.generateToken")))) {
|
||||
tokenIssueError.value = undefined;
|
||||
return;
|
||||
}
|
||||
const current = scope.value;
|
||||
if (!current) {
|
||||
tokenError.value = t("mqPermissions.selectNamespaceOrTopic");
|
||||
@@ -475,6 +481,8 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.permissions-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -677,30 +685,6 @@ th {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -736,15 +720,6 @@ th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 18px 20px;
|
||||
overflow: auto;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
|
||||
import type { BacklogQuota, DispatchRate, PolicyScope, PublishRate, RetentionPolicy, SubscribeRate, TopicInfo } from "@/types/mq";
|
||||
import { mqGetEffectivePolicies, mqSetBacklogQuota, mqSetDispatchRate, mqSetPublishRate, mqSetRetention, mqSetSubscribeRate } from "@/lib/backend/api";
|
||||
import { defaultMqPolicyForms, policyFormsFromEffectivePolicies } from "@/lib/mq/mqPolicyForms";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
@@ -20,6 +21,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const policies = ref<unknown>();
|
||||
const loading = ref(false);
|
||||
@@ -70,13 +72,13 @@ const scopeLabel = computed(() => {
|
||||
|
||||
const formattedPolicies = computed(() => JSON.stringify(policies.value ?? {}, null, 2));
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage.value;
|
||||
notice.value = undefined;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadPolicies() {
|
||||
@@ -100,7 +102,7 @@ async function loadPolicies() {
|
||||
}
|
||||
|
||||
async function applyPolicy(kind: string, action: (current: PolicyScope) => Promise<void>) {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(kind))) return;
|
||||
const current = scope.value;
|
||||
if (!current) {
|
||||
error.value = scopePlaceholderMessage.value;
|
||||
@@ -279,6 +281,8 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.policies-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -404,23 +408,6 @@ pre {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsumerInfo, ProducerInfo, SubscriptionInfo, TopicInfo, TopicRef, TopicStats, MqSystemKind } from "@/types/mq";
|
||||
import { mqGetTopicStats, mqListConsumers, mqListProducers, mqListSubscriptions, mqUnloadTopic } from "@/lib/backend/api";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
|
||||
|
||||
interface Props {
|
||||
@@ -26,6 +27,7 @@ const emit = defineEmits<{
|
||||
topicSelected: [topic: TopicInfo | undefined];
|
||||
}>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
interface PartitionClientRow {
|
||||
name: string;
|
||||
@@ -273,6 +275,7 @@ async function loadRuntimeClients() {
|
||||
async function unloadTopic() {
|
||||
const current = topicRef.value;
|
||||
if (!current || props.readOnly || unloading.value) return;
|
||||
if (!(await confirmMqWrite(t("mqClients.unloadTopic")))) return;
|
||||
if (!confirm(t("mqClients.confirmUnload"))) return;
|
||||
|
||||
unloading.value = true;
|
||||
@@ -762,6 +765,8 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.producer-consumer-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -828,44 +833,6 @@ watch(
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-sm:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-sm.danger {
|
||||
border-color: var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-sm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.runtime-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
|
||||
import type { MqRawResponse, TopicInfo } from "@/types/mq";
|
||||
import { mqRawRequest } from "@/lib/backend/api";
|
||||
import { safeJsonFormat } from "@/lib/common/safeJsonFormat";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
@@ -16,6 +17,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const methods = ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"];
|
||||
type RawApiPreset = {
|
||||
@@ -184,6 +186,10 @@ async function executeRequest() {
|
||||
error.value = readOnlyMessage.value;
|
||||
return;
|
||||
}
|
||||
// Mutating raw admin calls need the same production confirmation as console writes.
|
||||
if (!isReadMethod.value && !(await confirmMqWrite(t("mqRaw.sendRequest")))) {
|
||||
return;
|
||||
}
|
||||
if (!path.value.trim()) {
|
||||
error.value = t("mqRaw.pathRequired");
|
||||
return;
|
||||
@@ -277,6 +283,8 @@ async function executeRequest() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.raw-api-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -514,31 +522,6 @@ pre {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-secondary.compact {
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { mqQueryMessageTrace } from "@/lib/backend/api";
|
||||
import { formatRocketMqTraceError } from "@/lib/mq/rocketmqTraceUtils";
|
||||
import { formatRocketMqTimestamp, parseRocketMqMessagesFromResult, rocketMqMessagePayload, type RocketMqDisplayMessage } from "@/lib/mq/rocketmqMessageUtils";
|
||||
import { formatRocketMqTraceError, isParsedRocketMqTraceRecord, parseRocketMqTracePayload, rocketMqTraceRecordTimeLabel, splitRocketMqTraceKeys, type RocketMqTraceFieldKey, type RocketMqTraceRecord, type RocketMqTraceType } from "@/lib/mq/rocketmqTraceUtils";
|
||||
import { parseRocketMqMessagesFromResult, rocketMqMessagePayload, type RocketMqDisplayMessage } from "@/lib/mq/rocketmqMessageUtils";
|
||||
import { DEFAULT_ROCKETMQ_TRACE_TOPIC } from "@/lib/mq/rocketmqTopicTypes";
|
||||
|
||||
interface Props {
|
||||
@@ -13,6 +13,15 @@ interface Props {
|
||||
traceTopic?: string;
|
||||
}
|
||||
|
||||
interface TraceCardModel {
|
||||
index: number;
|
||||
message: RocketMqDisplayMessage;
|
||||
record: RocketMqTraceRecord;
|
||||
parsed: boolean;
|
||||
rawPayload: string;
|
||||
headerEntries: Array<[string, string]>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
traceTopic: DEFAULT_ROCKETMQ_TRACE_TOPIC,
|
||||
});
|
||||
@@ -26,6 +35,71 @@ const { t } = useI18n();
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const messages = ref<RocketMqDisplayMessage[]>([]);
|
||||
let loadSeq = 0;
|
||||
|
||||
const FIELD_I18N: Record<RocketMqTraceFieldKey, string> = {
|
||||
regionId: "mqTrace.fieldRegionId",
|
||||
group: "mqTrace.fieldGroup",
|
||||
topic: "mqTrace.fieldTopic",
|
||||
msgId: "mqTrace.fieldMsgId",
|
||||
tags: "mqTrace.fieldTags",
|
||||
keys: "mqTrace.fieldKeys",
|
||||
storeHost: "mqTrace.fieldStoreHost",
|
||||
clientHost: "mqTrace.fieldClientHost",
|
||||
bodyLength: "mqTrace.fieldBodyLength",
|
||||
costTime: "mqTrace.fieldCostTime",
|
||||
msgType: "mqTrace.fieldMsgType",
|
||||
offsetMsgId: "mqTrace.fieldOffsetMsgId",
|
||||
requestId: "mqTrace.fieldRequestId",
|
||||
retryTimes: "mqTrace.fieldRetryTimes",
|
||||
contextCode: "mqTrace.fieldContextCode",
|
||||
};
|
||||
|
||||
function typeLabel(type: RocketMqTraceType): string {
|
||||
switch (type) {
|
||||
case "Pub":
|
||||
return t("mqTrace.typePub");
|
||||
case "SubBefore":
|
||||
return t("mqTrace.typeSubBefore");
|
||||
case "SubAfter":
|
||||
return t("mqTrace.typeSubAfter");
|
||||
case "EndTransaction":
|
||||
return t("mqTrace.typeEndTransaction");
|
||||
default:
|
||||
return t("mqTrace.typeUnknown");
|
||||
}
|
||||
}
|
||||
|
||||
function fieldLabel(key: RocketMqTraceFieldKey): string {
|
||||
return t(FIELD_I18N[key]);
|
||||
}
|
||||
|
||||
function headerEntries(message: RocketMqDisplayMessage): Array<[string, string]> {
|
||||
if (!message.headers) return [];
|
||||
return Object.entries(message.headers);
|
||||
}
|
||||
|
||||
const cards = computed<TraceCardModel[]>(() => {
|
||||
const result: TraceCardModel[] = [];
|
||||
let index = 0;
|
||||
for (const message of messages.value) {
|
||||
const rawPayload = rocketMqMessagePayload(message);
|
||||
const records = parseRocketMqTracePayload(rawPayload);
|
||||
const units = records.length ? records : [{ type: "Unknown" as const, fields: [], raw: rawPayload }];
|
||||
for (const record of units) {
|
||||
index += 1;
|
||||
result.push({
|
||||
index,
|
||||
message,
|
||||
record,
|
||||
parsed: isParsedRocketMqTraceRecord(record),
|
||||
rawPayload: record.raw || rawPayload,
|
||||
headerEntries: headerEntries(message),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
async function loadTrace() {
|
||||
const msgId = props.msgId.trim();
|
||||
@@ -35,17 +109,20 @@ async function loadTrace() {
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++loadSeq;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
messages.value = [];
|
||||
try {
|
||||
const traceTopic = props.traceTopic.trim() || DEFAULT_ROCKETMQ_TRACE_TOPIC;
|
||||
const result = await mqQueryMessageTrace(props.connectionId, msgId, traceTopic);
|
||||
if (seq !== loadSeq) return;
|
||||
messages.value = parseRocketMqMessagesFromResult(result);
|
||||
} catch (e: unknown) {
|
||||
if (seq !== loadSeq) return;
|
||||
error.value = formatRocketMqTraceError(e, t("mqTrace.traceTopicRouteMissing"));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (seq === loadSeq) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +132,7 @@ watch(
|
||||
if (open) {
|
||||
void loadTrace();
|
||||
} else {
|
||||
loadSeq += 1;
|
||||
loading.value = false;
|
||||
error.value = undefined;
|
||||
messages.value = [];
|
||||
@@ -80,19 +158,49 @@ watch(
|
||||
|
||||
<div v-if="loading" class="panel-placeholder">{{ t("mqTrace.querying") }}</div>
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
<div v-else-if="!messages.length" class="panel-placeholder">{{ t("mqTrace.noTrace") }}</div>
|
||||
<div v-else-if="!cards.length" class="panel-placeholder">{{ t("mqTrace.noTrace") }}</div>
|
||||
<div v-else class="message-list">
|
||||
<article v-for="(message, index) in messages" :key="`${message.messageId ?? index}-${message.timestamp ?? 0}`" class="message-row">
|
||||
<article v-for="card in cards" :key="`${card.index}-${card.record.type}-${card.record.timestamp ?? 0}`" class="message-row">
|
||||
<div class="message-meta">
|
||||
<span>#{{ index + 1 }}</span>
|
||||
<span v-if="message.topic">{{ message.topic }}</span>
|
||||
<span v-if="message.partition != null">{{ t("mqMessages.metaPartition", { partition: message.partition }) }}</span>
|
||||
<span>{{ formatRocketMqTimestamp(message.timestamp) }}</span>
|
||||
<span>#{{ card.index }}</span>
|
||||
<span class="type-badge" :data-type="card.record.type">{{ typeLabel(card.record.type) }}</span>
|
||||
<span>{{ rocketMqTraceRecordTimeLabel(card.record, card.message.timestamp) }}</span>
|
||||
<span v-if="card.message.partition != null">{{ t("mqMessages.metaPartition", { partition: card.message.partition }) }}</span>
|
||||
<span v-if="card.record.success != null" class="status-badge" :class="card.record.success ? 'status-success' : 'status-fail'">
|
||||
{{ card.record.success ? t("mqTrace.statusSuccess") : t("mqTrace.statusFail") }}
|
||||
</span>
|
||||
</div>
|
||||
<pre class="message-payload">{{ rocketMqMessagePayload(message) }}</pre>
|
||||
<div v-if="message.headers && Object.keys(message.headers).length" class="message-headers">
|
||||
<span v-for="(value, key) in message.headers" :key="key">{{ key }}: {{ value }}</span>
|
||||
|
||||
<div v-if="card.parsed" class="detail-grid trace-fields">
|
||||
<template v-for="field in card.record.fields" :key="field.key">
|
||||
<span>{{ fieldLabel(field.key) }}</span>
|
||||
<span v-if="field.key === 'keys'" class="keys-chips">
|
||||
<template v-if="splitRocketMqTraceKeys(field.value).length">
|
||||
<span v-for="key in splitRocketMqTraceKeys(field.value)" :key="key" class="key-chip mono">{{ key }}</span>
|
||||
</template>
|
||||
<span v-else class="mono">{{ field.value }}</span>
|
||||
</span>
|
||||
<span v-else class="mono">{{ field.value }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<pre v-else class="message-payload">{{ card.rawPayload || "-" }}</pre>
|
||||
|
||||
<div v-if="card.headerEntries.length" class="message-headers">
|
||||
<template v-for="[key, value] in card.headerEntries" :key="key">
|
||||
<div v-if="key === 'KEYS'" class="header-keys">
|
||||
<span class="header-keys-label">{{ key }}:</span>
|
||||
<span class="keys-chips">
|
||||
<span v-for="chip in splitRocketMqTraceKeys(value)" :key="chip" class="key-chip mono">{{ chip }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>{{ key }}: {{ value }}</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<details v-if="card.parsed && card.rawPayload" class="raw-payload">
|
||||
<summary>{{ t("mqTrace.rawPayload") }}</summary>
|
||||
<pre class="message-payload">{{ card.rawPayload }}</pre>
|
||||
</details>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,6 +284,10 @@ watch(
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.trace-fields {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
@@ -213,11 +325,54 @@ watch(
|
||||
.message-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.type-badge,
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.type-badge[data-type="Pub"] {
|
||||
border-color: color-mix(in srgb, var(--color-primary, #3b82f6) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--color-primary, #3b82f6) 12%, transparent);
|
||||
color: var(--color-primary, #2563eb);
|
||||
}
|
||||
|
||||
.type-badge[data-type="SubBefore"],
|
||||
.type-badge[data-type="SubAfter"] {
|
||||
border-color: color-mix(in srgb, var(--color-info, #0ea5e9) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--color-info, #0ea5e9) 12%, transparent);
|
||||
color: var(--color-info, #0284c7);
|
||||
}
|
||||
|
||||
.status-success {
|
||||
border: 1px solid color-mix(in srgb, var(--color-success, #22c55e) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--color-success, #22c55e) 12%, transparent);
|
||||
color: var(--color-success, #16a34a);
|
||||
}
|
||||
|
||||
.status-fail {
|
||||
border: 1px solid color-mix(in srgb, var(--color-error, #ef4444) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--color-error, #ef4444) 12%, transparent);
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.message-payload {
|
||||
margin: 8px 0 0;
|
||||
padding: 10px;
|
||||
@@ -238,12 +393,62 @@ watch(
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.message-headers span {
|
||||
.message-headers > span {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.header-keys {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.header-keys-label {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.keys-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.key-chip {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.raw-payload {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.raw-payload summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.raw-payload .message-payload {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MqSystemKind, TopicInfo, TopicRef, SendMessageRequest, SendMessage
|
||||
import { mqSendMessage, mqListTopics } from "@/lib/backend/api";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { resolveRabbitMqSendNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import MessageBrowser from "./MessageBrowser.vue";
|
||||
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
|
||||
|
||||
@@ -23,6 +24,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const topicName = ref("");
|
||||
const messageKey = ref("");
|
||||
@@ -82,12 +84,12 @@ onUnmounted(() => {
|
||||
if (successTimer) clearTimeout(successTimer);
|
||||
});
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage.value;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(t("mqMessages.sendMessage"));
|
||||
}
|
||||
|
||||
async function loadTopics() {
|
||||
@@ -138,7 +140,7 @@ function parseHeaders(): Record<string, string> {
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable())) return;
|
||||
error.value = undefined;
|
||||
success.value = undefined;
|
||||
|
||||
@@ -327,6 +329,8 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.send-message-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -463,27 +467,6 @@ watch(
|
||||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.spin {
|
||||
display: inline-block;
|
||||
animation: spin-anim 0.8s linear infinite;
|
||||
@@ -587,35 +570,11 @@ input[type="number"]:focus {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-sm {
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
/* Keep send CTA visually balanced next to optional secondary actions */
|
||||
.form-actions .btn-primary {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
textarea:disabled,
|
||||
|
||||
@@ -3,11 +3,12 @@ import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { TopicRef, TopicInfo, SubscriptionInfo, ResetPosition, SkipCount, PeekedMessage, MqSystemKind } from "@/types/mq";
|
||||
import { mqListSubscriptions, mqCreateSubscription, mqDeleteSubscription, mqResetCursor, mqSkipMessages, mqClearBacklog, mqPeekMessages, mqExpireMessages } from "@/lib/backend/api";
|
||||
import { mqListSubscriptions, mqEnrichSubscriptions, mqCreateSubscription, mqDeleteSubscription, mqResetCursor, mqSkipMessages, mqClearBacklog, mqPeekMessages, mqExpireMessages } from "@/lib/backend/api";
|
||||
import RocketMqConsumerGroupDialogs, { type RocketMqConsumerGroupDialogKind } from "./rocketmq/RocketMqConsumerGroupDialogs.vue";
|
||||
import MqTypeFilterBar from "./shared/MqTypeFilterBar.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import { DEFAULT_ROCKETMQ_CONSUMER_GROUP_TYPE_FILTERS, matchesRocketMqConsumerGroupTypeFilters, resolveRocketMqConsumerGroupMessageModel, resolveRocketMqConsumerGroupType, ROCKETMQ_CONSUMER_GROUP_TYPES, type RocketMqConsumerGroupType } from "@/lib/mq/rocketmqConsumerGroupTypes";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
@@ -31,10 +32,15 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const subscriptions = ref<SubscriptionInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const enriching = ref(false);
|
||||
const truncatedHint = ref<string>();
|
||||
const enrichFailedHint = ref<string>();
|
||||
const error = ref<string>();
|
||||
let loadSeq = 0;
|
||||
const showCreateDialog = ref(false);
|
||||
const showResetDialog = ref(false);
|
||||
const showSkipDialog = ref(false);
|
||||
@@ -82,14 +88,23 @@ const canShowPanel = computed(() => isClusterWideMode.value || !!props.topic);
|
||||
const rocketMqConsumerGroupTypeOptions = ROCKETMQ_CONSUMER_GROUP_TYPES;
|
||||
const panelTitle = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.consumerGroupTitle") : t("mqSubscriptions.title")));
|
||||
const searchPlaceholder = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.searchConsumerGroup") : t("mqSubscriptions.searchPlaceholder")));
|
||||
const filteredSubscriptions = computed(() => {
|
||||
// Match TopicsPanel: denominator tracks type filters; numerator also applies search.
|
||||
const typeFilteredSubscriptions = computed(() => {
|
||||
let rows = subscriptions.value;
|
||||
if (isRocketMqCluster.value) {
|
||||
rows = rows.filter((sub) => matchesRocketMqConsumerGroupTypeFilters(sub, consumerGroupTypeFilters.value));
|
||||
const filters = consumerGroupTypeFilters.value;
|
||||
// Read each flag so checkbox toggles always invalidate this computed.
|
||||
for (const type of ROCKETMQ_CONSUMER_GROUP_TYPES) {
|
||||
void filters[type];
|
||||
}
|
||||
rows = rows.filter((sub) => matchesRocketMqConsumerGroupTypeFilters(sub, filters));
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
const filteredSubscriptions = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase();
|
||||
if (!keyword) return rows;
|
||||
return rows.filter((sub) => sub.name.toLowerCase().includes(keyword));
|
||||
if (!keyword) return typeFilteredSubscriptions.value;
|
||||
return typeFilteredSubscriptions.value.filter((sub) => sub.name.toLowerCase().includes(keyword));
|
||||
});
|
||||
|
||||
function consumerGroupTypeLabel(sub: SubscriptionInfo): string {
|
||||
@@ -100,7 +115,7 @@ function consumerGroupTypeLabel(sub: SubscriptionInfo): string {
|
||||
function consumerGroupTypeBadgeClass(sub: SubscriptionInfo): string {
|
||||
const type = resolveRocketMqConsumerGroupType(sub);
|
||||
if (type === "FIFO") return "badge badge-info";
|
||||
if (type === "SYSTEM") return "badge badge-muted";
|
||||
if (type === "SYSTEM" || type === "UNKNOWN") return "badge badge-muted";
|
||||
return "badge";
|
||||
}
|
||||
|
||||
@@ -109,12 +124,12 @@ function consumerGroupModeLabel(sub: SubscriptionInfo): string {
|
||||
return t(`mqSubscriptions.rocketmqGroupMode.${mode.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
function getListTopicRef(): TopicRef | null {
|
||||
@@ -153,21 +168,57 @@ async function loadSubscriptions() {
|
||||
const topicRef = getListTopicRef();
|
||||
if (!topicRef) {
|
||||
subscriptions.value = [];
|
||||
truncatedHint.value = undefined;
|
||||
enrichFailedHint.value = undefined;
|
||||
return;
|
||||
}
|
||||
const seq = ++loadSeq;
|
||||
loading.value = true;
|
||||
enriching.value = false;
|
||||
error.value = undefined;
|
||||
truncatedHint.value = undefined;
|
||||
enrichFailedHint.value = undefined;
|
||||
try {
|
||||
subscriptions.value = await mqListSubscriptions(props.connectionId, topicRef);
|
||||
// Fast list first (no enrich / online members) so large clusters paint quickly.
|
||||
const page = await mqListSubscriptions(props.connectionId, topicRef);
|
||||
if (seq !== loadSeq) return;
|
||||
subscriptions.value = page;
|
||||
syncSelectedSubscription(page);
|
||||
if (isClusterWideMode.value && page.length >= 500) {
|
||||
truncatedHint.value = t("mqSubscriptions.truncatedHint", { count: page.length });
|
||||
}
|
||||
if (isClusterWideMode.value) {
|
||||
enriching.value = true;
|
||||
try {
|
||||
const enriched = await mqEnrichSubscriptions(props.connectionId, topicRef);
|
||||
if (seq !== loadSeq) return;
|
||||
subscriptions.value = enriched;
|
||||
// Detail dialog holds a snapshot; refresh so topics/members arrive after enrich.
|
||||
syncSelectedSubscription(enriched);
|
||||
if (enriched.length >= 500) {
|
||||
truncatedHint.value = t("mqSubscriptions.truncatedHint", { count: enriched.length });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
// Keep the fast list if enrichment times out; surface why online columns stayed empty.
|
||||
if (seq === loadSeq) {
|
||||
enrichFailedHint.value = t("mqSubscriptions.enrichFailedHint", { error: formatError(e) });
|
||||
}
|
||||
} finally {
|
||||
if (seq === loadSeq) enriching.value = false;
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
if (seq === loadSeq) error.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (seq === loadSeq) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
formData.value = {
|
||||
subName: "",
|
||||
startFrom: "latest",
|
||||
@@ -175,13 +226,24 @@ function openCreateDialog() {
|
||||
showCreateDialog.value = true;
|
||||
}
|
||||
|
||||
/** Keep open dialog/selection pointing at the latest list row for the same group name. */
|
||||
function syncSelectedSubscription(rows: SubscriptionInfo[]) {
|
||||
const current = selectedSub.value;
|
||||
if (!current) return;
|
||||
const updated = rows.find((row) => row.name === current.name);
|
||||
if (updated) selectedSub.value = updated;
|
||||
}
|
||||
|
||||
function openRocketMqDetail(sub: SubscriptionInfo) {
|
||||
selectedSub.value = sub;
|
||||
activeRocketMqDialog.value = "detail";
|
||||
}
|
||||
|
||||
function openRocketMqConfig(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
selectedSub.value = sub;
|
||||
activeRocketMqDialog.value = "config";
|
||||
}
|
||||
@@ -191,7 +253,10 @@ function closeRocketMqDialog() {
|
||||
}
|
||||
|
||||
function openResetDialog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
selectedSub.value = sub;
|
||||
resetFormData.value = {
|
||||
position: "latest",
|
||||
@@ -201,7 +266,10 @@ function openResetDialog(sub: SubscriptionInfo) {
|
||||
}
|
||||
|
||||
function openSkipDialog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
selectedSub.value = sub;
|
||||
skipFormData.value = {
|
||||
mode: "count",
|
||||
@@ -220,7 +288,10 @@ function openPeekDialog(sub: SubscriptionInfo) {
|
||||
}
|
||||
|
||||
function openExpireDialog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
selectedSub.value = sub;
|
||||
expireSeconds.value = 3600;
|
||||
showExpireDialog.value = true;
|
||||
@@ -236,7 +307,7 @@ function selectSubscription(sub: SubscriptionInfo) {
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqSubscriptions.create")))) return;
|
||||
const topicRef = getPulsarTopicRef();
|
||||
if (!formData.value.subName.trim() || !topicRef) {
|
||||
error.value = t("mqSubscriptions.subscriptionNameRequired");
|
||||
@@ -257,12 +328,16 @@ async function handleCreate() {
|
||||
}
|
||||
|
||||
function handleDelete(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
deleteTarget.value = sub;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!(await guardWritable(t("mqSubscriptions.delete")))) return;
|
||||
const sub = deleteTarget.value;
|
||||
if (!sub) return;
|
||||
const topicRef = getListTopicRef();
|
||||
@@ -281,7 +356,7 @@ async function confirmDelete() {
|
||||
}
|
||||
|
||||
async function handleResetCursor() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqSubscriptions.reset")))) return;
|
||||
const topicRef = getPulsarTopicRef();
|
||||
if (!selectedSub.value || !topicRef) return;
|
||||
loading.value = true;
|
||||
@@ -304,7 +379,7 @@ async function handleResetCursor() {
|
||||
}
|
||||
|
||||
async function handleSkipMessages() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqSubscriptions.skip")))) return;
|
||||
const topicRef = getPulsarTopicRef();
|
||||
if (!selectedSub.value || !topicRef) return;
|
||||
loading.value = true;
|
||||
@@ -322,12 +397,16 @@ async function handleSkipMessages() {
|
||||
}
|
||||
|
||||
function handleClearBacklog(sub: SubscriptionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqSubscriptions.readOnly");
|
||||
return;
|
||||
}
|
||||
clearBacklogTarget.value = sub;
|
||||
showClearBacklogDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmClearBacklog() {
|
||||
if (!(await guardWritable(t("mqSubscriptions.clearBacklog")))) return;
|
||||
const sub = clearBacklogTarget.value;
|
||||
if (!sub) return;
|
||||
const topicRef = getPulsarTopicRef();
|
||||
@@ -381,7 +460,7 @@ function invalidatePeekRequest() {
|
||||
}
|
||||
|
||||
async function handleExpireMessages() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqSubscriptions.expire")))) return;
|
||||
const topicRef = getPulsarTopicRef();
|
||||
if (!selectedSub.value || !topicRef) return;
|
||||
loading.value = true;
|
||||
@@ -414,10 +493,11 @@ watch(
|
||||
<div class="toolbar-left">
|
||||
<h3>{{ panelTitle }}</h3>
|
||||
<input v-if="isClusterWideMode" v-model="searchKeyword" type="search" class="topic-search" :placeholder="searchPlaceholder" />
|
||||
<span v-if="isClusterWideMode && subscriptions.length" class="topic-count"> {{ filteredSubscriptions.length }} / {{ subscriptions.length }} </span>
|
||||
<span v-if="isClusterWideMode && subscriptions.length" class="topic-count" data-testid="subscription-count"> {{ filteredSubscriptions.length }} / {{ typeFilteredSubscriptions.length }} </span>
|
||||
<span v-if="enriching" class="topic-count">{{ t("mqSubscriptions.enriching") }}</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button v-if="isClusterWideMode" class="btn-secondary" :disabled="loading" @click="loadSubscriptions">
|
||||
<button v-if="isClusterWideMode" class="btn-secondary" :disabled="loading || enriching" @click="loadSubscriptions">
|
||||
{{ loading ? t("mqSubscriptions.refreshing") : t("mqSubscriptions.refresh") }}
|
||||
</button>
|
||||
<button v-if="supportsCreateSubscription !== false && !isRocketMqCluster" @click="openCreateDialog" :disabled="loading || readOnly || !topic" class="btn-primary">+ {{ t("mqSubscriptions.createSubscription") }}</button>
|
||||
@@ -435,14 +515,16 @@ watch(
|
||||
|
||||
<template v-else>
|
||||
<div v-if="error" class="panel-error">{{ error }}</div>
|
||||
<div v-if="truncatedHint && !error" class="panel-hint">{{ truncatedHint }}</div>
|
||||
<div v-if="enrichFailedHint && !error" class="panel-hint">{{ enrichFailedHint }}</div>
|
||||
|
||||
<div v-else-if="loading && !subscriptions.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
|
||||
<div v-if="!error && loading && !subscriptions.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
|
||||
|
||||
<div v-else-if="!filteredSubscriptions.length" class="panel-placeholder">
|
||||
{{ isClusterWideMode ? t("mqSubscriptions.noConsumerGroups") : t("mqSubscriptions.noSubscriptions") }}
|
||||
<div v-else-if="!error && !filteredSubscriptions.length" class="panel-placeholder">
|
||||
{{ isClusterWideMode ? (subscriptions.length ? t("mqSubscriptions.noMatches") : t("mqSubscriptions.noConsumerGroups")) : t("mqSubscriptions.noSubscriptions") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="subscriptions-table">
|
||||
<div v-else-if="!error && filteredSubscriptions.length" class="subscriptions-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -471,12 +553,19 @@ watch(
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td v-if="!isClusterWideMode">
|
||||
<span :class="{ 'text-warning': sub.msgBacklog > 1000 }">
|
||||
<span v-if="sub.backlogUnavailable" class="text-muted">-</span>
|
||||
<span v-else :class="{ 'text-warning': sub.msgBacklog > 1000 }">
|
||||
{{ sub.msgBacklog.toLocaleString() }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="!isClusterWideMode">{{ t("mqSubscriptions.msgRate", { rate: sub.msgRateOut.toFixed(2) }) }}</td>
|
||||
<td>{{ isClusterWideMode ? (sub.onlineMembers ?? 0) : t("mqSubscriptions.consumerCount", { count: sub.consumers.length }) }}</td>
|
||||
<td data-testid="online-members">
|
||||
<template v-if="isClusterWideMode">
|
||||
<span v-if="sub.onlineMembers == null" class="text-muted">-</span>
|
||||
<span v-else>{{ sub.onlineMembers }}</span>
|
||||
</template>
|
||||
<template v-else>{{ t("mqSubscriptions.consumerCount", { count: sub.consumers.length }) }}</template>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<template v-if="isRocketMqCluster">
|
||||
<button @click.stop="openRocketMqDetail(sub)" class="btn-sm">{{ t("mqSubscriptions.viewDetail") }}</button>
|
||||
@@ -692,6 +781,8 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.subscriptions-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -744,20 +835,6 @@ watch(
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.subscription-type-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -829,6 +906,7 @@ watch(
|
||||
|
||||
.panel-placeholder,
|
||||
.panel-error,
|
||||
.panel-hint,
|
||||
.panel-loading {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
@@ -839,6 +917,12 @@ watch(
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.panel-hint {
|
||||
padding: 8px 16px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.subscriptions-table {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
@@ -902,52 +986,16 @@ td {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -992,16 +1040,6 @@ button:disabled {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n";
|
||||
import type { TenantInfo, TenantConfig } from "@/types/mq";
|
||||
import { mqListTenants, mqCreateTenant, mqUpdateTenant, mqDeleteTenant } from "@/lib/backend/api";
|
||||
import { defaultTenantConfig, normalizeClusterOptions, validateTenantForm } from "@/lib/mq/mqTenantForm";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
@@ -19,6 +20,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const tenants = ref<TenantInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
@@ -46,12 +48,12 @@ const selectedAllowedClusters = computed(() => normalizeClusterOptions(formData.
|
||||
const displayedSelectedClusters = computed(() => [...normalizedClusterOptions.value.filter((cluster) => selectedAllowedClusters.value.includes(cluster)), ...selectedAllowedClusters.value.filter((cluster) => !clusterOptionSet.value.has(cluster))]);
|
||||
const canSubmitTenant = computed(() => Boolean(formData.value.name.trim()) && selectedAllowedClusters.value.length > 0);
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTenants.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
@@ -67,7 +69,10 @@ async function loadTenants() {
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTenants.readOnly");
|
||||
return;
|
||||
}
|
||||
formData.value = {
|
||||
name: "",
|
||||
config: defaultTenantConfig(normalizedClusterOptions.value),
|
||||
@@ -79,7 +84,10 @@ function openCreateDialog() {
|
||||
}
|
||||
|
||||
function openEditDialog(tenant: TenantInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTenants.readOnly");
|
||||
return;
|
||||
}
|
||||
editingTenant.value = tenant;
|
||||
formData.value = {
|
||||
name: tenant.name,
|
||||
@@ -95,7 +103,7 @@ function openEditDialog(tenant: TenantInfo) {
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqTenants.create")))) return;
|
||||
const validationError = validateTenantForm(formData.value.name, formData.value.config);
|
||||
if (validationError) {
|
||||
error.value = t(validationError);
|
||||
@@ -116,7 +124,7 @@ async function handleCreate() {
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqTenants.edit")))) return;
|
||||
if (!editingTenant.value) return;
|
||||
const validationError = validateTenantForm(formData.value.name, formData.value.config);
|
||||
if (validationError) {
|
||||
@@ -138,7 +146,7 @@ async function handleUpdate() {
|
||||
}
|
||||
|
||||
async function handleDelete(tenant: TenantInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqTenants.delete")))) return;
|
||||
if (!confirm(t("mqTenants.confirmDelete", { name: tenant.name }))) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
@@ -392,6 +400,8 @@ watch(normalizedClusterOptions, (clusters) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.tenants-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -509,45 +519,6 @@ td {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -588,16 +559,6 @@ button:disabled {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { MqTab } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS, isProtectedRocketMqTopic, isRocketMqBusinessMessageType, matchesRocketMqTypeFilters, resolveRocketMqMessageType, ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES, ROCKETMQ_TOPIC_MESSAGE_TYPES } from "@/lib/mq/rocketmqTopicTypes";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
const TOPIC_ROW_HEIGHT = 44;
|
||||
@@ -193,12 +194,14 @@ const canSubmitPartitionUpdate = computed(() => {
|
||||
return !props.readOnly && current > 0 && Number.isFinite(newPartitions.value) && newPartitions.value > current;
|
||||
});
|
||||
|
||||
function guardWritable() {
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTopics.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadTopics() {
|
||||
@@ -234,7 +237,10 @@ async function loadClusterInfo() {
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTopics.readOnly");
|
||||
return;
|
||||
}
|
||||
dialogError.value = undefined;
|
||||
const defaultBroker = rocketMqMasterBrokers.value[0]?.brokerName ?? rocketMqBrokerOptions.value[0]?.brokerName ?? "";
|
||||
formData.value = {
|
||||
@@ -307,7 +313,10 @@ function isDlqTopic(topic: TopicInfo): boolean {
|
||||
}
|
||||
|
||||
function openPartitionsDialog(topic: TopicInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTopics.readOnly");
|
||||
return;
|
||||
}
|
||||
dialogError.value = undefined;
|
||||
if (!topic.partitions || topic.partitions < 1) {
|
||||
error.value = t("mqTopics.currentPartitionsUnknown");
|
||||
@@ -319,7 +328,7 @@ function openPartitionsDialog(topic: TopicInfo) {
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqTopics.create")))) return;
|
||||
if (!formData.value.topicName.trim() || !props.tenant || !props.namespace) {
|
||||
dialogError.value = t("mqTopics.topicNameRequired");
|
||||
return;
|
||||
@@ -353,12 +362,16 @@ async function handleCreate() {
|
||||
}
|
||||
|
||||
function handleDelete(topic: TopicInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqTopics.readOnly");
|
||||
return;
|
||||
}
|
||||
deleteTarget.value = topic;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!(await guardWritable(t("mqTopics.delete")))) return;
|
||||
const topic = deleteTarget.value;
|
||||
if (!topic || !props.tenant || !props.namespace) return;
|
||||
const namespace = resolveMqRowNamespace(topic, props.namespace);
|
||||
@@ -390,7 +403,7 @@ async function confirmDelete() {
|
||||
}
|
||||
|
||||
async function handleUpdatePartitions() {
|
||||
if (!guardWritable()) return;
|
||||
if (!(await guardWritable(t("mqTopics.adjustPartitions")))) return;
|
||||
if (!editingTopic.value || !props.tenant || !props.namespace) return;
|
||||
const currentPartitions = editingTopic.value.partitions;
|
||||
if (!currentPartitions || currentPartitions < 1) {
|
||||
@@ -719,6 +732,8 @@ watch(newPartitions, () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./shared/mqPanel.css";
|
||||
|
||||
.topics-panel {
|
||||
--topics-surface: var(--card, var(--color-background, #ffffff));
|
||||
--topics-header-bg: color-mix(in srgb, var(--secondary, #f5f5f5) 86%, var(--card, #ffffff));
|
||||
@@ -1010,45 +1025,6 @@ watch(newPartitions, () => {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -1089,16 +1065,6 @@ button:disabled {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
|
||||
@@ -25,6 +25,10 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
mqSendMessage: backend.mqSendMessage,
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useMqMutationGuard", () => ({
|
||||
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
|
||||
}));
|
||||
|
||||
import MessageQueryPanel from "@/components/mq/MessageQueryPanel.vue";
|
||||
|
||||
const TOPIC = {
|
||||
|
||||
@@ -19,6 +19,10 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
mqSendMessage: backend.mqSendMessage,
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useMqMutationGuard", () => ({
|
||||
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/select", async () => (await import("./selectStub")).createSelectStub());
|
||||
|
||||
import SendMessagePanel from "@/components/mq/SendMessagePanel.vue";
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { PeekedMessage } from "@/types/mq";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
mqListSubscriptions: vi.fn(),
|
||||
mqEnrichSubscriptions: vi.fn(),
|
||||
mqCreateSubscription: vi.fn(),
|
||||
mqDeleteSubscription: vi.fn(),
|
||||
mqResetCursor: vi.fn(),
|
||||
@@ -21,6 +22,10 @@ vi.mock("vue-i18n", () => ({
|
||||
|
||||
vi.mock("@/lib/backend/api", () => backend);
|
||||
|
||||
vi.mock("@/composables/useMqMutationGuard", () => ({
|
||||
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
@@ -61,7 +66,7 @@ function deferred<T>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function mountPanel() {
|
||||
async function mountPanel(overrides: Record<string, unknown> = {}) {
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
app = createApp(SubscriptionsPanel, {
|
||||
@@ -71,15 +76,29 @@ async function mountPanel() {
|
||||
namespace: "default",
|
||||
mqSystemKind: "kafka",
|
||||
supportsPeekMessages: true,
|
||||
...overrides,
|
||||
});
|
||||
app.mount(root);
|
||||
await flushUi();
|
||||
return root;
|
||||
}
|
||||
|
||||
function subscription(name: string, consumerGroupType: string) {
|
||||
return {
|
||||
name,
|
||||
subType: consumerGroupType,
|
||||
consumerGroupType,
|
||||
messageModel: "CLUSTERING",
|
||||
msgBacklog: 0,
|
||||
msgRateOut: 0,
|
||||
msgThroughputOut: 0,
|
||||
consumers: [],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.values(backend).forEach((mock) => mock.mockReset());
|
||||
backend.mqListSubscriptions.mockResolvedValue([
|
||||
const rows = [
|
||||
{
|
||||
name: "orders-consumer",
|
||||
subType: "shared",
|
||||
@@ -88,7 +107,9 @@ beforeEach(() => {
|
||||
msgThroughputOut: 0,
|
||||
consumers: [],
|
||||
},
|
||||
]);
|
||||
];
|
||||
backend.mqListSubscriptions.mockResolvedValue(rows);
|
||||
backend.mqEnrichSubscriptions.mockResolvedValue(rows);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -176,3 +197,63 @@ describe("SubscriptionsPanel message peek", () => {
|
||||
expect(panel.textContent).not.toContain("orders message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubscriptionsPanel RocketMQ online members", () => {
|
||||
it("shows '-' when onlineMembers is unknown before enrich completes", async () => {
|
||||
const enrich = deferred<ReturnType<typeof subscription>[]>();
|
||||
const rows = [subscription("orders-group", "NORMAL")];
|
||||
backend.mqListSubscriptions.mockResolvedValue(rows);
|
||||
backend.mqEnrichSubscriptions.mockReturnValueOnce(enrich.promise);
|
||||
|
||||
const panel = await mountPanel({
|
||||
topic: undefined,
|
||||
tenant: "_rocketmq",
|
||||
namespace: "default",
|
||||
mqSystemKind: "rocketmq",
|
||||
supportsPeekMessages: false,
|
||||
});
|
||||
|
||||
// Fast list paints before enrich; unknown member count must not look like healthy zero.
|
||||
const membersCell = () => panel.querySelector('[data-testid="online-members"]')?.textContent?.trim();
|
||||
expect(membersCell()).toBe("-");
|
||||
|
||||
enrich.resolve([{ ...rows[0], onlineMembers: 2, topics: ["orders"] }]);
|
||||
await flushUi();
|
||||
expect(membersCell()).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubscriptionsPanel RocketMQ filter count", () => {
|
||||
it("updates the visible/total count when type filters or search change", async () => {
|
||||
const rows = [subscription("orders-group", "NORMAL"), subscription("payments-fifo", "FIFO"), subscription("CID_SYS_GROUP", "SYSTEM"), subscription("orders-retry", "NORMAL")];
|
||||
backend.mqListSubscriptions.mockResolvedValue(rows);
|
||||
backend.mqEnrichSubscriptions.mockResolvedValue(rows);
|
||||
|
||||
const panel = await mountPanel({
|
||||
topic: undefined,
|
||||
tenant: "_rocketmq",
|
||||
namespace: "default",
|
||||
mqSystemKind: "rocketmq",
|
||||
supportsPeekMessages: false,
|
||||
});
|
||||
|
||||
const count = () => panel.querySelector('[data-testid="subscription-count"]')?.textContent?.replace(/\s+/g, " ").trim();
|
||||
// Default: SYSTEM off → 3 type-filtered groups.
|
||||
expect(count()).toBe("3 / 3");
|
||||
|
||||
const systemCheckbox = [...panel.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find((input) => input.closest("label")?.textContent?.toLowerCase().includes("system"));
|
||||
expect(systemCheckbox).toBeTruthy();
|
||||
expect(systemCheckbox!.checked).toBe(false);
|
||||
systemCheckbox!.click();
|
||||
await flushUi();
|
||||
expect(count()).toBe("4 / 4");
|
||||
|
||||
const search = panel.querySelector<HTMLInputElement>("input.topic-search");
|
||||
expect(search).toBeTruthy();
|
||||
search!.focus();
|
||||
search!.value = "orders";
|
||||
search!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flushUi();
|
||||
expect(count()).toBe("2 / 4");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { MqChannelInfo, MqClientConnectionInfo, NamespaceRef } from "@/type
|
||||
import { mqCloseClientConnection, mqListClientChannels, mqListClientConnections } from "@/lib/backend/api";
|
||||
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
@@ -16,6 +17,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const connections = ref<MqClientConnectionInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
@@ -48,12 +50,12 @@ function nsRef(namespace?: string): NamespaceRef {
|
||||
return { tenant: RABBITMQ_MQ_TENANT, namespace: namespace ?? "" };
|
||||
}
|
||||
|
||||
function guardWritable() {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqClientConnections.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadConnections() {
|
||||
@@ -112,12 +114,16 @@ function toggleExpanded(connection: MqClientConnectionInfo) {
|
||||
}
|
||||
|
||||
function openCloseDialog(connection: MqClientConnectionInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqClientConnections.readOnly");
|
||||
return;
|
||||
}
|
||||
closeTarget.value = connection;
|
||||
showCloseDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmClose() {
|
||||
if (!(await guardWritable(t("mqClientConnections.closeConnection")))) return;
|
||||
const target = closeTarget.value;
|
||||
if (!target) return;
|
||||
const namespace = resolveMqRowNamespace(target, props.namespace);
|
||||
@@ -274,6 +280,8 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../shared/mqPanel.css";
|
||||
|
||||
.clients-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -442,37 +450,6 @@ td {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -253,6 +253,8 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../shared/mqPanel.css";
|
||||
|
||||
.rabbitmq-monitoring-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -415,21 +417,6 @@ td {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MqUserInfo, MqVhostPermission } from "@/types/mq";
|
||||
import { mqCreateUser, mqDeleteUser, mqGrantUserPermission, mqListNamespaces, mqListUserPermissions, mqListUsers, mqRevokeUserPermission } from "@/lib/backend/api";
|
||||
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
@@ -16,6 +17,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const users = ref<MqUserInfo[]>([]);
|
||||
const usersLoading = ref(false);
|
||||
@@ -58,12 +60,12 @@ const filteredPermissions = computed(() => {
|
||||
return permissions.value.filter((permission) => permission.user === selectedUser.value);
|
||||
});
|
||||
|
||||
function guardWritable(): boolean {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqUserPermissions.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
@@ -114,7 +116,10 @@ function toggleUserFilter(user: MqUserInfo) {
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqUserPermissions.readOnly");
|
||||
return;
|
||||
}
|
||||
createForm.value = { name: "", password: "", tagsText: "" };
|
||||
dialogError.value = undefined;
|
||||
showCreateDialog.value = true;
|
||||
@@ -129,6 +134,7 @@ function parseTags(text: string): string[] | undefined {
|
||||
}
|
||||
|
||||
async function handleCreateUser() {
|
||||
if (!(await guardWritable(t("mqUserPermissions.createUser")))) return;
|
||||
const name = createForm.value.name.trim();
|
||||
const password = createForm.value.password;
|
||||
if (!name) {
|
||||
@@ -153,12 +159,16 @@ async function handleCreateUser() {
|
||||
}
|
||||
|
||||
function openDeleteDialog(user: MqUserInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqUserPermissions.readOnly");
|
||||
return;
|
||||
}
|
||||
deleteTarget.value = user;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!(await guardWritable(t("mqUserPermissions.delete")))) return;
|
||||
const target = deleteTarget.value;
|
||||
if (!target) return;
|
||||
deleting.value = true;
|
||||
@@ -175,7 +185,10 @@ async function confirmDelete() {
|
||||
}
|
||||
|
||||
function openGrantDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqUserPermissions.readOnly");
|
||||
return;
|
||||
}
|
||||
const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : "";
|
||||
grantForm.value = {
|
||||
user: selectedUser.value ?? users.value[0]?.name ?? "",
|
||||
@@ -194,6 +207,7 @@ function patternOrDefault(value: string): string | undefined {
|
||||
}
|
||||
|
||||
async function handleGrant() {
|
||||
if (!(await guardWritable(t("mqUserPermissions.grant")))) return;
|
||||
const user = grantForm.value.user.trim();
|
||||
const virtualHost = grantForm.value.virtualHost.trim();
|
||||
if (!user) {
|
||||
@@ -222,12 +236,16 @@ async function handleGrant() {
|
||||
}
|
||||
|
||||
function openRevokeDialog(permission: MqVhostPermission) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqUserPermissions.readOnly");
|
||||
return;
|
||||
}
|
||||
revokeTarget.value = permission;
|
||||
showRevokeDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmRevoke() {
|
||||
if (!(await guardWritable(t("mqUserPermissions.revoke")))) return;
|
||||
const target = revokeTarget.value;
|
||||
if (!target) return;
|
||||
revoking.value = true;
|
||||
@@ -457,6 +475,8 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../shared/mqPanel.css";
|
||||
|
||||
.user-permissions-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -639,48 +659,6 @@ td {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -721,16 +699,6 @@ button:disabled {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MqPolicyInfo, MqPolicyUpsertRequest } from "@/types/mq";
|
||||
import { mqDeletePolicy, mqListNamespaces, mqListPolicies, mqSetPolicy } from "@/lib/backend/api";
|
||||
import { isAllVhostsNamespace, RABBITMQ_MQ_TENANT } from "@/lib/mq/mqConsoleDefaults";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
|
||||
interface Props {
|
||||
@@ -21,6 +22,7 @@ interface DefinitionEntry {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
/** Well-known policy definition keys offered in the editor dropdown. */
|
||||
const COMMON_DEFINITION_KEYS = ["message-ttl", "expires", "max-length", "max-length-bytes", "dead-letter-exchange", "dead-letter-routing-key", "max-age"];
|
||||
@@ -47,12 +49,12 @@ const showDeleteDialog = ref(false);
|
||||
const deleteTarget = ref<MqPolicyInfo>();
|
||||
const deleting = ref(false);
|
||||
|
||||
function guardWritable(): boolean {
|
||||
async function guardWritable(operation: string): Promise<boolean> {
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqRabbitMqPolicies.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return confirmMqWrite(operation);
|
||||
}
|
||||
|
||||
async function loadPolicies() {
|
||||
@@ -88,7 +90,10 @@ function definitionEntriesFrom(policy: MqPolicyInfo): DefinitionEntry[] {
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqRabbitMqPolicies.readOnly");
|
||||
return;
|
||||
}
|
||||
const currentVhost = props.namespace && !isAllVhostsNamespace(props.namespace) ? props.namespace : "";
|
||||
editingPolicy.value = undefined;
|
||||
editForm.value = { name: "", virtualHost: currentVhost || vhosts.value[0] || "", pattern: "", applyTo: "queues", priority: 0 };
|
||||
@@ -98,7 +103,10 @@ function openCreateDialog() {
|
||||
}
|
||||
|
||||
function openEditDialog(policy: MqPolicyInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqRabbitMqPolicies.readOnly");
|
||||
return;
|
||||
}
|
||||
editingPolicy.value = policy;
|
||||
editForm.value = { name: policy.name, virtualHost: policy.vhost, pattern: policy.pattern, applyTo: policy.applyTo || "queues", priority: policy.priority };
|
||||
definitionEntries.value = definitionEntriesFrom(policy);
|
||||
@@ -130,6 +138,7 @@ function buildDefinition(): Record<string, unknown> {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!(await guardWritable(t("mqRabbitMqPolicies.save")))) return;
|
||||
const name = editForm.value.name.trim();
|
||||
const virtualHost = editForm.value.virtualHost.trim();
|
||||
const pattern = editForm.value.pattern.trim();
|
||||
@@ -167,12 +176,16 @@ async function handleSave() {
|
||||
}
|
||||
|
||||
function openDeleteDialog(policy: MqPolicyInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (props.readOnly) {
|
||||
error.value = t("mqRabbitMqPolicies.readOnly");
|
||||
return;
|
||||
}
|
||||
deleteTarget.value = policy;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!(await guardWritable(t("mqRabbitMqPolicies.delete")))) return;
|
||||
const target = deleteTarget.value;
|
||||
if (!target) return;
|
||||
deleting.value = true;
|
||||
@@ -331,6 +344,8 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../shared/mqPanel.css";
|
||||
|
||||
.policies-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -467,48 +482,6 @@ td {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -549,16 +522,6 @@ button:disabled {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
max-height: 70vh;
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ConsumerInfo, RocketMqConsumerGroupConfig, SubscriptionInfo, TopicRef } from "@/types/mq";
|
||||
import type { ConsumerInfo, PartitionBacklog, RocketMqConsumerGroupConfig, SubscriptionInfo, TopicRef } from "@/types/mq";
|
||||
import { mqAlterConsumerGroupConfig, mqGetBacklog, mqGetConsumerGroupConfig, mqListConsumers } from "@/lib/backend/api";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { resolveRocketMqConsumerGroupMessageModel, resolveRocketMqConsumerGroupType } from "@/lib/mq/rocketmqConsumerGroupTypes";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
export type RocketMqConsumerGroupDialogKind = "detail" | "config";
|
||||
|
||||
interface TopicConsumeDetail {
|
||||
topic: string;
|
||||
/** Undefined when backlog probe failed — do not render as healthy zero. */
|
||||
delay?: number;
|
||||
lastTimestamp?: number;
|
||||
partitions: PartitionBacklog[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
tenant?: string;
|
||||
@@ -24,11 +34,15 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const loading = ref(false);
|
||||
const dialogError = ref<string>();
|
||||
const terminals = ref<ConsumerInfo[]>([]);
|
||||
const topicBacklogs = ref<Array<{ topic: string; backlog: number }>>([]);
|
||||
const topicConsumeDetails = ref<TopicConsumeDetail[]>([]);
|
||||
/** Drops stale detail/config responses when enrich reloads the open dialog. */
|
||||
let detailLoadSeq = 0;
|
||||
let configLoadSeq = 0;
|
||||
const configForm = ref<RocketMqConsumerGroupConfig>({
|
||||
groupName: "",
|
||||
consumeEnable: true,
|
||||
@@ -67,35 +81,75 @@ function closeDialog() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
/** Format consume timestamp; treat missing/zero as unavailable (avoid epoch display). */
|
||||
function formatConsumeTimestamp(ms?: number | null): string {
|
||||
if (ms == null || ms <= 0) return "-";
|
||||
const date = new Date(ms);
|
||||
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString();
|
||||
}
|
||||
|
||||
function partitionRowKey(topic: string, partition: PartitionBacklog, index: number): string {
|
||||
return `${topic}:${partition.brokerName ?? ""}:${partition.partition}:${index}`;
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.group || !props.tenant || !props.namespace) return;
|
||||
const seq = ++detailLoadSeq;
|
||||
loading.value = true;
|
||||
dialogError.value = undefined;
|
||||
terminals.value = [];
|
||||
topicBacklogs.value = [];
|
||||
topicConsumeDetails.value = [];
|
||||
try {
|
||||
const topicRef = buildTopicRef("");
|
||||
if (topicRef) {
|
||||
terminals.value = await mqListConsumers(props.connectionId, topicRef, props.group.name);
|
||||
const online = await mqListConsumers(props.connectionId, topicRef, props.group.name);
|
||||
if (seq !== detailLoadSeq) return;
|
||||
terminals.value = online;
|
||||
}
|
||||
const topics = subscribedTopics.value;
|
||||
const backlogRows = await Promise.all(
|
||||
const detailRows = await Promise.all(
|
||||
topics.map(async (topic) => {
|
||||
const ref = buildTopicRef(topic);
|
||||
if (!ref) return { topic, backlog: 0 };
|
||||
if (!ref) {
|
||||
return { topic, partitions: [] as PartitionBacklog[], error: t("mqSubscriptions.invalidTopicScope") };
|
||||
}
|
||||
try {
|
||||
const stats = await mqGetBacklog(props.connectionId, ref, props.group!.name);
|
||||
return { topic, backlog: stats.msgBacklog };
|
||||
} catch {
|
||||
return { topic, backlog: 0 };
|
||||
const partitions = stats.partitions ?? [];
|
||||
// Topic-level last consume time = max of queue lastTimestamp values (> 0).
|
||||
const lastTimestamp = partitions
|
||||
.map((p) => p.lastTimestamp ?? 0)
|
||||
.filter((ts) => ts > 0)
|
||||
.reduce<number | undefined>((max, ts) => (max == null || ts > max ? ts : max), undefined);
|
||||
return {
|
||||
topic,
|
||||
delay: stats.msgBacklog,
|
||||
lastTimestamp,
|
||||
partitions,
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
// Keep offsets empty; omit delay so the row does not look like lag=0.
|
||||
return {
|
||||
topic,
|
||||
partitions: [] as PartitionBacklog[],
|
||||
error: formatError(e) || String(e),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
topicBacklogs.value = backlogRows;
|
||||
if (seq !== detailLoadSeq) return;
|
||||
topicConsumeDetails.value = detailRows;
|
||||
const backlogFailures = detailRows.filter((row) => row.error);
|
||||
if (backlogFailures.length) {
|
||||
dialogError.value = t("mqSubscriptions.backlogPartialFailed", {
|
||||
count: backlogFailures.length,
|
||||
error: backlogFailures[0]?.error ?? "",
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
dialogError.value = formatError(e);
|
||||
if (seq === detailLoadSeq) dialogError.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (seq === detailLoadSeq) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,11 +169,13 @@ function resetConfigForm(groupName: string) {
|
||||
|
||||
async function loadConfig() {
|
||||
if (!props.group) return;
|
||||
const seq = ++configLoadSeq;
|
||||
resetConfigForm(props.group.name);
|
||||
loading.value = true;
|
||||
dialogError.value = undefined;
|
||||
try {
|
||||
const config = await mqGetConsumerGroupConfig(props.connectionId, props.group.name);
|
||||
if (seq !== configLoadSeq) return;
|
||||
configForm.value = {
|
||||
groupName: config.groupName || props.group.name,
|
||||
consumeEnable: config.consumeEnable ?? true,
|
||||
@@ -132,14 +188,15 @@ async function loadConfig() {
|
||||
whichBrokerWhenConsumeSlowly: config.whichBrokerWhenConsumeSlowly ?? 0,
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
dialogError.value = formatError(e);
|
||||
if (seq === configLoadSeq) dialogError.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (seq === configLoadSeq) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!props.group || props.readOnly) return;
|
||||
if (!(await confirmMqWrite(t("mqSubscriptions.editConfig")))) return;
|
||||
loading.value = true;
|
||||
dialogError.value = undefined;
|
||||
try {
|
||||
@@ -163,11 +220,20 @@ async function saveConfig() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.dialog, props.group?.name],
|
||||
// Detail reloads when enrich fills topics; config must not reset the form on topics-only updates.
|
||||
() => [props.dialog, props.group?.name, (props.group?.topics ?? []).join("\0")] as const,
|
||||
() => {
|
||||
if (props.dialog === "detail") {
|
||||
void loadDetail();
|
||||
} else if (props.dialog === "config") {
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.dialog, props.group?.name] as const,
|
||||
() => {
|
||||
if (props.dialog === "config") {
|
||||
void loadConfig();
|
||||
}
|
||||
},
|
||||
@@ -193,7 +259,7 @@ watch(
|
||||
<span>{{ t("mqSubscriptions.type") }}</span
|
||||
><span>{{ groupTypeLabel }}</span> <span>{{ t("mqSubscriptions.mode") }}</span
|
||||
><span>{{ groupModeLabel }}</span> <span>{{ t("mqSubscriptions.consumers") }}</span
|
||||
><span>{{ group.onlineMembers ?? 0 }}</span>
|
||||
><span>{{ group.onlineMembers == null ? "-" : group.onlineMembers }}</span>
|
||||
<span>{{ t("mqSubscriptions.subscribedTopics") }}</span>
|
||||
<span>{{ subscribedTopics.length ? subscribedTopics.join(", ") : "-" }}</span>
|
||||
</div>
|
||||
@@ -226,21 +292,55 @@ watch(
|
||||
|
||||
<section class="detail-section">
|
||||
<h4>{{ t("mqSubscriptions.consumerConsumeDetail") }}</h4>
|
||||
<div v-if="!topicBacklogs.length" class="panel-placeholder">{{ t("mqSubscriptions.noConsumeDetail") }}</div>
|
||||
<table v-else class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqSubscriptions.operationTopic") }}</th>
|
||||
<th>{{ t("mqSubscriptions.backlog") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in topicBacklogs" :key="row.topic">
|
||||
<td>{{ row.topic }}</td>
|
||||
<td>{{ row.backlog.toLocaleString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="loading && !topicConsumeDetails.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
|
||||
<div v-else-if="!topicConsumeDetails.length" class="panel-placeholder">{{ t("mqSubscriptions.noConsumeDetail") }}</div>
|
||||
<div v-else class="consume-detail-list">
|
||||
<div v-for="detail in topicConsumeDetails" :key="detail.topic" class="consume-topic-block">
|
||||
<div class="consume-topic-header">
|
||||
<span
|
||||
><strong>{{ t("mqSubscriptions.operationTopic") }}:</strong> {{ detail.topic }}</span
|
||||
>
|
||||
<span
|
||||
><strong>{{ t("mqSubscriptions.consumeDelay") }}:</strong> {{ detail.delay == null ? "-" : detail.delay.toLocaleString() }}</span
|
||||
>
|
||||
<span
|
||||
><strong>{{ t("mqSubscriptions.lastConsumeTime") }}:</strong> {{ formatConsumeTimestamp(detail.lastTimestamp) }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="detail.error" class="panel-placeholder panel-placeholder-compact form-error">
|
||||
{{ detail.error }}
|
||||
</div>
|
||||
<div v-else-if="!detail.partitions.length" class="panel-placeholder panel-placeholder-compact">
|
||||
{{ t("mqSubscriptions.noQueueConsumeProgress") }}
|
||||
</div>
|
||||
<div v-else class="detail-table-scroll">
|
||||
<table class="detail-table detail-table-wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("mqSubscriptions.broker") }}</th>
|
||||
<th>{{ t("mqSubscriptions.queue") }}</th>
|
||||
<th>{{ t("mqSubscriptions.consumerClient") }}</th>
|
||||
<th>{{ t("mqSubscriptions.brokerOffset") }}</th>
|
||||
<th>{{ t("mqSubscriptions.consumerOffset") }}</th>
|
||||
<th>{{ t("mqSubscriptions.diffTotal") }}</th>
|
||||
<th>{{ t("mqSubscriptions.lastTimestamp") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(partition, index) in detail.partitions" :key="partitionRowKey(detail.topic, partition, index)">
|
||||
<td>{{ partition.brokerName || "-" }}</td>
|
||||
<td>{{ partition.partition }}</td>
|
||||
<td>{{ partition.consumerClient || "-" }}</td>
|
||||
<td>{{ partition.endOffset.toLocaleString() }}</td>
|
||||
<td>{{ partition.currentOffset.toLocaleString() }}</td>
|
||||
<td>{{ partition.lag.toLocaleString() }}</td>
|
||||
<td>{{ formatConsumeTimestamp(partition.lastTimestamp) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -303,6 +403,8 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../shared/mqPanel.css";
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -325,7 +427,7 @@ watch(
|
||||
}
|
||||
|
||||
.dialog-wide {
|
||||
max-width: 860px;
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.dialog-header,
|
||||
@@ -354,14 +456,6 @@ watch(
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.detail-sections {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -388,17 +482,44 @@ watch(
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.consume-detail-list {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.consume-topic-block {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.consume-topic-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.detail-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.detail-table-wide {
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.detail-table th,
|
||||
.detail-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-table th {
|
||||
@@ -454,6 +575,10 @@ watch(
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-placeholder-compact {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
@@ -463,26 +588,8 @@ watch(
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +2,9 @@
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BrokerNode, ResetPosition, SubscriptionInfo, TopicInfo, TopicRef } from "@/types/mq";
|
||||
import { mqAlterTopicConfig, mqGetTopicInternalStats, mqGetTopicRoute, mqGetTopicStats, mqListSubscriptions, mqResetCursor, mqSkipTopicAccumulation } from "@/lib/backend/api";
|
||||
import { mqAlterTopicConfig, mqEnrichSubscriptions, mqGetTopicInternalStats, mqGetTopicRoute, mqGetTopicStats, mqListSubscriptions, mqResetCursor, mqSkipTopicAccumulation } from "@/lib/backend/api";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
export type RocketMqTopicDialogKind = "status" | "route" | "consumers" | "config" | "reset" | "skip";
|
||||
|
||||
@@ -46,6 +47,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
|
||||
const loading = ref(false);
|
||||
const dialogError = ref<string>();
|
||||
@@ -194,8 +196,13 @@ async function loadResetSubscriptions(topic: TopicRef): Promise<SubscriptionInfo
|
||||
return sortResetSubscriptions(topicSpecific, topic.topic);
|
||||
}
|
||||
// Reset offset uses force=true; fall back to all cluster groups when the topic has no committed offsets yet.
|
||||
// Prefer enrich so subscribed-topic sorting still works after the fast-list path dropped enrich.
|
||||
const clusterWide: TopicRef = { ...topic, topic: "" };
|
||||
return sortResetSubscriptions(await mqListSubscriptions(props.connectionId, clusterWide), topic.topic);
|
||||
try {
|
||||
return sortResetSubscriptions(await mqEnrichSubscriptions(props.connectionId, clusterWide), topic.topic);
|
||||
} catch {
|
||||
return sortResetSubscriptions(await mqListSubscriptions(props.connectionId, clusterWide), topic.topic);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTimeLocal(date: Date): string {
|
||||
@@ -215,7 +222,12 @@ async function loadDialogData() {
|
||||
} else if (props.dialog === "route") {
|
||||
routeRaw.value = await mqGetTopicRoute(props.connectionId, topic);
|
||||
} else if (props.dialog === "consumers") {
|
||||
subscriptions.value = await mqListSubscriptions(props.connectionId, topic);
|
||||
// Topic consumer dialog shows lag; includeLag is on the list path. Enrich fills members when cheap.
|
||||
try {
|
||||
subscriptions.value = await mqEnrichSubscriptions(props.connectionId, topic);
|
||||
} catch {
|
||||
subscriptions.value = await mqListSubscriptions(props.connectionId, topic);
|
||||
}
|
||||
selectedSubscription.value = subscriptions.value[0]?.name ?? "";
|
||||
} else if (props.dialog === "reset") {
|
||||
subscriptions.value = await loadResetSubscriptions(topic);
|
||||
@@ -237,6 +249,7 @@ async function loadDialogData() {
|
||||
async function saveConfig() {
|
||||
const topic = topicRef.value;
|
||||
if (!topic || props.readOnly) return;
|
||||
if (!(await confirmMqWrite(t("mqTopics.actionConfig")))) return;
|
||||
loading.value = true;
|
||||
dialogError.value = undefined;
|
||||
try {
|
||||
@@ -258,6 +271,7 @@ async function saveConfig() {
|
||||
async function confirmSkip() {
|
||||
const topic = topicRef.value;
|
||||
if (!topic || props.readOnly) return;
|
||||
if (!(await confirmMqWrite(t("mqTopics.actionSkip")))) return;
|
||||
if (!confirm(t("mqTopics.confirmSkip", { name: topic.topic }))) return;
|
||||
loading.value = true;
|
||||
dialogError.value = undefined;
|
||||
@@ -275,6 +289,7 @@ async function confirmSkip() {
|
||||
async function confirmReset() {
|
||||
const topic = topicRef.value;
|
||||
if (!topic || props.readOnly || !selectedSubscription.value) return;
|
||||
if (!(await confirmMqWrite(t("mqTopics.actionReset")))) return;
|
||||
loading.value = true;
|
||||
dialogError.value = undefined;
|
||||
try {
|
||||
@@ -496,6 +511,8 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../shared/mqPanel.css";
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -534,14 +551,6 @@ watch(
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
@@ -674,28 +683,6 @@ watch(
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
background: var(--color-background);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
select:disabled {
|
||||
|
||||
@@ -233,13 +233,4 @@ const { t } = useI18n();
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,3 +1,6 @@
|
||||
/* Shared MQ panel chrome + button system.
|
||||
* Prefer .mq-btn-* in new code; .btn-* aliases keep existing panels consistent. */
|
||||
|
||||
.mq-panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -51,9 +54,20 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---- Buttons (canonical + legacy aliases) ---- */
|
||||
|
||||
.mq-btn-primary,
|
||||
.mq-btn-secondary,
|
||||
.mq-btn-sm {
|
||||
.mq-btn-sm,
|
||||
.mq-btn-danger,
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-sm,
|
||||
.btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
@@ -61,35 +75,115 @@
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mq-btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
filter 0.15s ease;
|
||||
}
|
||||
|
||||
/* MQ chrome stays on the page surface in light theme — avoid solid --color-primary
|
||||
* fills (often near-black) that clash with toolbar/refresh and table action buttons. */
|
||||
.mq-btn-primary:hover:not(:disabled),
|
||||
.mq-btn-secondary:hover:not(:disabled),
|
||||
.mq-btn-sm:hover:not(:disabled) {
|
||||
.mq-btn-sm:hover:not(:disabled),
|
||||
.btn-primary:hover:not(:disabled),
|
||||
.btn-secondary:hover:not(:disabled),
|
||||
.btn-sm:hover:not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.mq-btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
.mq-btn-danger,
|
||||
.mq-btn-sm.mq-btn-danger,
|
||||
.mq-btn-sm.btn-danger,
|
||||
.btn-danger,
|
||||
.btn-sm.btn-danger,
|
||||
.btn-sm.danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.mq-btn-danger:hover:not(:disabled),
|
||||
.mq-btn-sm.mq-btn-danger:hover:not(:disabled),
|
||||
.mq-btn-sm.btn-danger:hover:not(:disabled),
|
||||
.btn-danger:hover:not(:disabled),
|
||||
.btn-sm.btn-danger:hover:not(:disabled),
|
||||
.btn-sm.danger:hover:not(:disabled) {
|
||||
background: var(--color-error);
|
||||
color: #fff;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.mq-btn-sm,
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mq-btn-primary:disabled,
|
||||
.mq-btn-secondary:disabled,
|
||||
.mq-btn-sm:disabled {
|
||||
.mq-btn-sm:disabled,
|
||||
.mq-btn-danger:disabled,
|
||||
.btn-primary:disabled,
|
||||
.btn-secondary:disabled,
|
||||
.btn-sm:disabled,
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Icon buttons only — do not style .btn-icon on nested SVG/icons */
|
||||
.mq-btn-icon,
|
||||
button.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.mq-btn-icon:hover:not(:disabled),
|
||||
button.btn-icon:hover:not(:disabled) {
|
||||
background: var(--color-background-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mq-btn-icon:disabled,
|
||||
button.btn-icon:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mq-btn-sm.btn-danger,
|
||||
.mq-btn-sm.mq-btn-danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
.mq-btn-close,
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mq-btn-close:hover,
|
||||
.btn-close:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mq-query-mode-tabs {
|
||||
@@ -154,26 +248,6 @@
|
||||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.mq-btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mq-btn-icon:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mq-spin {
|
||||
display: inline-block;
|
||||
animation: mq-spin-anim 0.8s linear infinite;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
|
||||
const requestConfirmation = vi.fn();
|
||||
const toast = vi.fn();
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
getConfig: (id: string) => {
|
||||
if (id === "missing") return undefined;
|
||||
if (id === "readonly") return { name: "ro", read_only: true, is_production: false };
|
||||
if (id === "prod") return { name: "prod-mq", read_only: false, is_production: true };
|
||||
return { name: "dev-mq", read_only: false, is_production: false };
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/productionSafetyStore", () => ({
|
||||
useProductionSafetyStore: () => ({ requestConfirmation }),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast }),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||||
|
||||
describe("useMqMutationGuard", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
requestConfirmation.mockReset();
|
||||
requestConfirmation.mockResolvedValue(true);
|
||||
toast.mockReset();
|
||||
});
|
||||
|
||||
it("denies missing and read-only connections without prompting", async () => {
|
||||
const missing = useMqMutationGuard("missing");
|
||||
const readonly = useMqMutationGuard("readonly");
|
||||
await expect(missing.confirmMqWrite("send")).resolves.toBe(false);
|
||||
await expect(readonly.confirmMqWrite("send")).resolves.toBe(false);
|
||||
expect(requestConfirmation).not.toHaveBeenCalled();
|
||||
expect(toast).toHaveBeenCalledWith("mqAdmin.connectionMissing");
|
||||
expect(toast).toHaveBeenCalledWith("mqAdmin.writeDeniedReadOnly");
|
||||
});
|
||||
|
||||
it("allows non-production writes immediately", async () => {
|
||||
const guard = useMqMutationGuard("dev");
|
||||
await expect(guard.confirmMqWrite("send")).resolves.toBe(true);
|
||||
expect(requestConfirmation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves getter connection ids used by MQ panels", async () => {
|
||||
const guard = useMqMutationGuard(() => "dev");
|
||||
await expect(guard.confirmMqWrite("send")).resolves.toBe(true);
|
||||
expect(toast).not.toHaveBeenCalledWith("mqAdmin.connectionMissing");
|
||||
});
|
||||
|
||||
it("prompts for production writes", async () => {
|
||||
const guard = useMqMutationGuard("prod");
|
||||
await expect(guard.confirmMqWrite("send")).resolves.toBe(true);
|
||||
expect(requestConfirmation).toHaveBeenCalledWith({
|
||||
sql: "send",
|
||||
connectionName: "prod-mq",
|
||||
source: "production.sourceMq",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { toValue, type MaybeRefOrGetter } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
|
||||
/**
|
||||
* Shared write guard for MQ console mutations.
|
||||
* Read-only is enforced by the backend; production connections require an
|
||||
* explicit confirmation (same dialog as SQL production writes).
|
||||
*/
|
||||
export function useMqMutationGuard(connectionId: MaybeRefOrGetter<string>) {
|
||||
const connectionStore = useConnectionStore();
|
||||
const productionSafetyStore = useProductionSafetyStore();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
async function confirmMqWrite(operation: string): Promise<boolean> {
|
||||
// Call sites pass () => props.connectionId — must use toValue (not unref)
|
||||
// so getters resolve to the real connection id.
|
||||
const id = toValue(connectionId);
|
||||
const config = connectionStore.getConfig(id);
|
||||
if (!config) {
|
||||
toast(t("mqAdmin.connectionMissing"));
|
||||
return false;
|
||||
}
|
||||
if (config.read_only) {
|
||||
toast(t("mqAdmin.writeDeniedReadOnly"));
|
||||
return false;
|
||||
}
|
||||
if (!config.is_production) return true;
|
||||
return productionSafetyStore.requestConfirmation({
|
||||
sql: operation,
|
||||
connectionName: config.name,
|
||||
source: t("production.sourceMq"),
|
||||
});
|
||||
}
|
||||
|
||||
return { confirmMqWrite };
|
||||
}
|
||||
@@ -3991,6 +3991,7 @@ export default {
|
||||
sourceDataCompare: "Data compare",
|
||||
sourceExtension: "Extension manager",
|
||||
sourceSidebar: "Object tree",
|
||||
sourceMq: "Message queue",
|
||||
sourceObjectSource: "Object source editor",
|
||||
sourceDataGenerate: "Data generator",
|
||||
sourceQueryHistory: "Query history rollback",
|
||||
@@ -6061,6 +6062,8 @@ export default {
|
||||
viewTenant: "View tenant",
|
||||
viewNamespace: "View namespace",
|
||||
viewTopic: "View topic",
|
||||
connectionMissing: "Connection not found. Reconnect and try again.",
|
||||
writeDeniedReadOnly: "This connection is read-only and cannot perform write operations.",
|
||||
readOnly: "Read-only",
|
||||
allNamespaces: "All namespaces",
|
||||
newNamespace: "New namespace",
|
||||
@@ -6352,8 +6355,13 @@ export default {
|
||||
loading: "Loading...",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing...",
|
||||
enriching: "Loading online details...",
|
||||
truncatedHint: "Showing the first {count} consumer groups (list capped). Narrow the search to find others.",
|
||||
enrichFailedHint: "Online member/topic details could not be loaded ({error}). Showing the fast list only.",
|
||||
noSubscriptions: "No subscriptions for this topic",
|
||||
noConsumerGroups: "No consumer groups",
|
||||
noMatches: "No matching consumer groups",
|
||||
backlogPartialFailed: "Failed to load backlog for {count} topic(s): {error}",
|
||||
viewDetail: "Details",
|
||||
editConfig: "Config",
|
||||
save: "Save",
|
||||
@@ -6364,7 +6372,18 @@ export default {
|
||||
consumerConsumeDetail: "Consumption details",
|
||||
noOnlineConsumers: "No online consumers",
|
||||
noConsumeDetail: "No consumption details",
|
||||
noQueueConsumeProgress: "No queue consumption progress",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "Backlog",
|
||||
invalidTopicScope: "Invalid topic scope",
|
||||
lastConsumeTime: "Last consumption time",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "Consume enabled",
|
||||
consumeBroadcastEnable: "Broadcast consume enabled",
|
||||
consumeFromMinEnable: "Consume from min offset",
|
||||
@@ -6381,6 +6400,7 @@ export default {
|
||||
normal: "Normal",
|
||||
fifo: "Ordered",
|
||||
system: "System",
|
||||
unknown: "Unknown",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "Cluster",
|
||||
@@ -6624,6 +6644,29 @@ export default {
|
||||
searchHint: "Search for messages first, then click View trace",
|
||||
viewTrace: "View trace",
|
||||
detailTitle: "Message trace details",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "Unknown",
|
||||
statusSuccess: "Success",
|
||||
statusFail: "Failed",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store host",
|
||||
fieldClientHost: "Client host",
|
||||
fieldBodyLength: "Body length",
|
||||
fieldCostTime: "Cost",
|
||||
fieldMsgType: "Msg type",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "Retry",
|
||||
fieldContextCode: "Context code",
|
||||
rawPayload: "Raw trace",
|
||||
traceTopicRouteMissing:
|
||||
"The trace topic has no route on NameServer (CODE: 17).\n\nCommon causes:\n1. Broker trace is disabled: set traceTopicEnable=true in broker.conf and restart the broker\n2. Producer/Consumer trace is disabled: call setEnableTrace(true)\n3. A custom trace topic is used: select the correct trace topic above",
|
||||
},
|
||||
|
||||
@@ -5917,6 +5917,8 @@ export default withEnglishFallback({
|
||||
viewTenant: "Ver tenant",
|
||||
viewNamespace: "Ver namespace",
|
||||
viewTopic: "Ver tema",
|
||||
connectionMissing: "No se encontró la conexión. Vuelve a conectar e inténtalo de nuevo.",
|
||||
writeDeniedReadOnly: "Esta conexión es de solo lectura y no puede realizar operaciones de escritura.",
|
||||
readOnly: "Solo lectura",
|
||||
allNamespaces: "Todos los namespaces",
|
||||
newNamespace: "Nuevo namespace",
|
||||
@@ -6258,7 +6260,12 @@ export default withEnglishFallback({
|
||||
searchPlaceholder: "Buscar nombre de suscripción",
|
||||
subscribedTopics: "Topics suscritos",
|
||||
refreshing: "Actualizando...",
|
||||
enriching: "Cargando detalles en línea...",
|
||||
truncatedHint: "Mostrando los primeros {count} grupos de consumidores (límite). Restrinja la búsqueda para encontrar otros.",
|
||||
enrichFailedHint: "No se pudieron cargar los detalles en línea ({error}). Solo se muestra la lista rápida.",
|
||||
noConsumerGroups: "No hay grupos de consumidores",
|
||||
noMatches: "No hay grupos de consumidores coincidentes",
|
||||
backlogPartialFailed: "Error al cargar el backlog de {count} topic(s): {error}",
|
||||
viewDetail: "Detalles",
|
||||
editConfig: "Configurar",
|
||||
save: "Guardar",
|
||||
@@ -6269,7 +6276,18 @@ export default withEnglishFallback({
|
||||
consumerConsumeDetail: "Detalles de consumo",
|
||||
noOnlineConsumers: "No hay consumidores en línea actualmente",
|
||||
noConsumeDetail: "Sin detalles de consumo",
|
||||
noQueueConsumeProgress: "Sin progreso de consumo por cola",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "Backlog",
|
||||
invalidTopicScope: "Ámbito de tema no válido",
|
||||
lastConsumeTime: "Último consumo",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "Permitir consumo",
|
||||
consumeBroadcastEnable: "Permitir consumo en difusión",
|
||||
consumeFromMinEnable: "Comenzar desde el offset mínimo",
|
||||
@@ -6284,6 +6302,7 @@ export default withEnglishFallback({
|
||||
normal: "Normal",
|
||||
fifo: "FIFO",
|
||||
system: "Sistema",
|
||||
unknown: "Desconocido",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "Cluster",
|
||||
@@ -6522,6 +6541,7 @@ export default withEnglishFallback({
|
||||
sourceDataCompare: "Comparación de datos",
|
||||
sourceExtension: "Gestor de extensiones",
|
||||
sourceSidebar: "Árbol de objetos",
|
||||
sourceMq: "Cola de mensajes",
|
||||
sourceObjectSource: "Editor de código fuente de objetos",
|
||||
sourceDataGenerate: "Generador de datos",
|
||||
sourceQueryHistory: "Reversión del historial de consultas",
|
||||
@@ -6916,5 +6936,28 @@ export default withEnglishFallback({
|
||||
detailTitle: "Detalles de la traza de consumo",
|
||||
traceTopicRouteMissing:
|
||||
"El Topic de trazas no tiene información de ruta en NameServer (CODE: 17).\n\nCausas comunes:\n1. Broker no tiene habilitada la traza de mensajes: configure traceTopicEnable=true en broker.conf y reinicie el Broker\n2. Producer/Consumer no tiene habilitada la traza: setEnableTrace(true)\n3. Se utilizó un Topic de trazas personalizado, seleccione el Topic de trazas correcto arriba.",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "Desconocido",
|
||||
statusSuccess: "Correcto",
|
||||
statusFail: "Fallido",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store host",
|
||||
fieldClientHost: "Client host",
|
||||
fieldBodyLength: "Body length",
|
||||
fieldCostTime: "Coste",
|
||||
fieldMsgType: "Msg type",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "Reintentos",
|
||||
fieldContextCode: "Context code",
|
||||
rawPayload: "Traza en bruto",
|
||||
},
|
||||
});
|
||||
@@ -5917,6 +5917,8 @@ export default withEnglishFallback({
|
||||
viewTenant: "Visualizza tenant",
|
||||
viewNamespace: "Visualizza namespace",
|
||||
viewTopic: "Visualizza topic",
|
||||
connectionMissing: "Connessione non trovata. Riconnetti e riprova.",
|
||||
writeDeniedReadOnly: "La connessione corrente è in modalità sola lettura, impossibile eseguire operazioni di scrittura.",
|
||||
readOnly: "Sola lettura",
|
||||
allNamespaces: "Tutti i namespace",
|
||||
newNamespace: "Nuovo namespace",
|
||||
@@ -6258,7 +6260,12 @@ export default withEnglishFallback({
|
||||
searchPlaceholder: "Cerca nome sottoscrizione",
|
||||
subscribedTopics: "Topic sottoscritti",
|
||||
refreshing: "Aggiornamento in corso...",
|
||||
enriching: "Caricamento dettagli online...",
|
||||
truncatedHint: "Vengono mostrati i primi {count} consumer group (limite). Restringi la ricerca per trovarne altri.",
|
||||
enrichFailedHint: "Impossibile caricare i dettagli online ({error}). Viene mostrata solo l'elenco rapido.",
|
||||
noConsumerGroups: "Nessun Consumer Group",
|
||||
noMatches: "Nessun consumer group corrispondente",
|
||||
backlogPartialFailed: "Caricamento backlog non riuscito per {count} topic: {error}",
|
||||
viewDetail: "Dettagli",
|
||||
editConfig: "Configurazione",
|
||||
save: "Salva",
|
||||
@@ -6269,7 +6276,18 @@ export default withEnglishFallback({
|
||||
consumerConsumeDetail: "Dettagli consumo",
|
||||
noOnlineConsumers: "Nessun consumatore online",
|
||||
noConsumeDetail: "Nessun dettaglio consumo",
|
||||
noQueueConsumeProgress: "Nessun progresso di consumo per coda",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "Backlog",
|
||||
invalidTopicScope: "Ambito topic non valido",
|
||||
lastConsumeTime: "Ultimo consumo",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "Abilita consumo",
|
||||
consumeBroadcastEnable: "Abilita consumo broadcast",
|
||||
consumeFromMinEnable: "Inizia dall'offset minimo",
|
||||
@@ -6284,6 +6302,7 @@ export default withEnglishFallback({
|
||||
normal: "Normale",
|
||||
fifo: "FIFO",
|
||||
system: "Sistema",
|
||||
unknown: "Sconosciuto",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "Cluster",
|
||||
@@ -6522,6 +6541,7 @@ export default withEnglishFallback({
|
||||
sourceDataCompare: "Confronto dati",
|
||||
sourceExtension: "Gestione estensioni",
|
||||
sourceSidebar: "Albero oggetti",
|
||||
sourceMq: "Coda di messaggi",
|
||||
sourceObjectSource: "Editor sorgente oggetto",
|
||||
sourceDataGenerate: "Generatore dati",
|
||||
sourceQueryHistory: "Rollback cronologia query",
|
||||
@@ -6916,5 +6936,28 @@ export default withEnglishFallback({
|
||||
detailTitle: "Dettagli traccia consumo",
|
||||
traceTopicRouteMissing:
|
||||
"Il Topic di traccia non ha informazioni di instradamento sul NameServer (CODE: 17).\n\nCause comuni:\n1. Il Broker non ha abilitato la traccia messaggi: imposta traceTopicEnable=true in broker.conf e riavvia il Broker\n2. Producer/Consumer non hanno abilitato la traccia: setEnableTrace(true)\n3. È stato utilizzato un Topic di traccia personalizzato, seleziona il Topic di traccia corretto sopra.",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "Sconosciuto",
|
||||
statusSuccess: "Riuscito",
|
||||
statusFail: "Non riuscito",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store host",
|
||||
fieldClientHost: "Client host",
|
||||
fieldBodyLength: "Body length",
|
||||
fieldCostTime: "Costo",
|
||||
fieldMsgType: "Msg type",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "Tentativi",
|
||||
fieldContextCode: "Context code",
|
||||
rawPayload: "Traccia grezza",
|
||||
},
|
||||
});
|
||||
@@ -5972,6 +5972,8 @@ export default withEnglishFallback({
|
||||
viewTenant: "テナントを表示",
|
||||
viewNamespace: "名前空間を表示",
|
||||
viewTopic: "トピックを表示",
|
||||
connectionMissing: "接続が見つかりません。再接続してから再試行してください。",
|
||||
writeDeniedReadOnly: "現在の接続は読み取り専用モードです。書き込み操作は実行できません。",
|
||||
readOnly: "読み取り専用",
|
||||
allNamespaces: "すべての名前空間",
|
||||
newNamespace: "新規名前空間",
|
||||
@@ -6313,7 +6315,12 @@ export default withEnglishFallback({
|
||||
searchPlaceholder: "サブスクリプション名を検索",
|
||||
subscribedTopics: "サブスクライブされた Topic",
|
||||
refreshing: "更新中...",
|
||||
enriching: "オンライン詳細を読み込み中...",
|
||||
truncatedHint: "最初の {count} 件のコンシューマグループのみ表示しています(上限)。検索条件を絞ってください。",
|
||||
enrichFailedHint: "オンラインメンバー/購読 Topic の読み込みに失敗しました({error})。高速一覧のみ表示しています。",
|
||||
noConsumerGroups: "Consumer Group がありません",
|
||||
noMatches: "一致するコンシューマグループがありません",
|
||||
backlogPartialFailed: "{count} 件の Topic のバックログ読み込みに失敗しました: {error}",
|
||||
viewDetail: "詳細",
|
||||
editConfig: "設定",
|
||||
save: "保存",
|
||||
@@ -6324,7 +6331,18 @@ export default withEnglishFallback({
|
||||
consumerConsumeDetail: "消費詳細",
|
||||
noOnlineConsumers: "現在オンラインのコンシューマはいません",
|
||||
noConsumeDetail: "消費詳細がありません",
|
||||
noQueueConsumeProgress: "キューの消費進捗がありません",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "バックログ",
|
||||
invalidTopicScope: "トピックのスコープが無効です",
|
||||
lastConsumeTime: "最終消費時間",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "消費を許可",
|
||||
consumeBroadcastEnable: "ブロードキャスト消費を許可",
|
||||
consumeFromMinEnable: "最小 offset から開始",
|
||||
@@ -6339,6 +6357,7 @@ export default withEnglishFallback({
|
||||
normal: "通常",
|
||||
fifo: "FIFO",
|
||||
system: "システム",
|
||||
unknown: "不明",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "クラスタリング",
|
||||
@@ -6577,6 +6596,7 @@ export default withEnglishFallback({
|
||||
sourceDataCompare: "データ比較",
|
||||
sourceExtension: "拡張機能マネージャー",
|
||||
sourceSidebar: "オブジェクトツリー",
|
||||
sourceMq: "メッセージキュー",
|
||||
sourceObjectSource: "オブジェクトソースエディター",
|
||||
sourceDataGenerate: "データジェネレーター",
|
||||
sourceQueryHistory: "クエリ履歴のロールバック",
|
||||
@@ -6971,5 +6991,28 @@ export default withEnglishFallback({
|
||||
detailTitle: "消費トレース詳細",
|
||||
traceTopicRouteMissing:
|
||||
"トレース Topic が NameServer にルート情報がありません (CODE: 17)。\n\n一般的な原因:\n1. Broker でメッセージトレースが有効になっていない: broker.conf で traceTopicEnable=true を設定し Broker を再起動\n2. Producer/Consumer でトレースが有効になっていない: setEnableTrace(true)\n3. カスタムトレース Topic を使用している場合は、上で正しいトレース Topic を選択してください",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "不明",
|
||||
statusSuccess: "成功",
|
||||
statusFail: "失敗",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store Host",
|
||||
fieldClientHost: "Client Host",
|
||||
fieldBodyLength: "Body Length",
|
||||
fieldCostTime: "所要時間",
|
||||
fieldMsgType: "メッセージ種別",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "リトライ回数",
|
||||
fieldContextCode: "Context Code",
|
||||
rawPayload: "生トレース",
|
||||
},
|
||||
});
|
||||
@@ -3538,6 +3538,7 @@ export default withEnglishFallback({
|
||||
sourceDataCompare: "데이터 비교",
|
||||
sourceExtension: "확장 관리자",
|
||||
sourceSidebar: "객체 트리",
|
||||
sourceMq: "메시지 큐",
|
||||
sourceObjectSource: "객체 소스 편집기",
|
||||
sourceDataGenerate: "데이터 생성기",
|
||||
sourceQueryHistory: "쿼리 기록 롤백",
|
||||
@@ -5585,6 +5586,8 @@ export default withEnglishFallback({
|
||||
viewTenant: "테넌트 보기",
|
||||
viewNamespace: "네임스페이스 보기",
|
||||
viewTopic: "토픽 보기",
|
||||
connectionMissing: "연결을 찾을 수 없습니다. 다시 연결한 후 시도하세요.",
|
||||
writeDeniedReadOnly: "이 연결은 읽기 전용이며 쓰기 작업을 수행할 수 없습니다.",
|
||||
readOnly: "읽기 전용",
|
||||
allNamespaces: "모든 네임스페이스",
|
||||
newNamespace: "새 네임스페이스",
|
||||
@@ -5876,8 +5879,13 @@ export default withEnglishFallback({
|
||||
loading: "불러오는 중...",
|
||||
refresh: "새로고침",
|
||||
refreshing: "새로고침 중...",
|
||||
enriching: "온라인 상세 불러오는 중...",
|
||||
truncatedHint: "처음 {count}개의 컨슈머 그룹만 표시합니다(상한). 검색 범위를 좁혀 주세요.",
|
||||
enrichFailedHint: "온라인 멤버/구독 토픽 정보를 불러오지 못했습니다({error}). 빠른 목록만 표시합니다.",
|
||||
noSubscriptions: "이 토픽에 구독이 없습니다",
|
||||
noConsumerGroups: "컨슈머 그룹이 없습니다",
|
||||
noMatches: "일치하는 컨슈머 그룹이 없습니다",
|
||||
backlogPartialFailed: "{count}개 토픽의 백로그를 불러오지 못했습니다: {error}",
|
||||
viewDetail: "상세",
|
||||
editConfig: "구성",
|
||||
save: "저장",
|
||||
@@ -5888,7 +5896,18 @@ export default withEnglishFallback({
|
||||
consumerConsumeDetail: "소비 상세",
|
||||
noOnlineConsumers: "온라인 컨슈머가 없습니다",
|
||||
noConsumeDetail: "소비 상세가 없습니다",
|
||||
noQueueConsumeProgress: "큐 소비 진행 정보가 없습니다",
|
||||
operationTopic: "토픽",
|
||||
consumeDelay: "적체",
|
||||
invalidTopicScope: "잘못된 토픽 범위",
|
||||
lastConsumeTime: "마지막 소비 시간",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "소비 활성화",
|
||||
consumeBroadcastEnable: "브로드캐스트 소비 활성화",
|
||||
consumeFromMinEnable: "최소 오프셋부터 소비",
|
||||
@@ -5905,6 +5924,7 @@ export default withEnglishFallback({
|
||||
normal: "일반",
|
||||
fifo: "순서 보장",
|
||||
system: "시스템",
|
||||
unknown: "알 수 없음",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "클러스터",
|
||||
@@ -6149,6 +6169,29 @@ export default withEnglishFallback({
|
||||
detailTitle: "메시지 추적 상세",
|
||||
traceTopicRouteMissing:
|
||||
"추적 토픽이 NameServer에 라우트 정보가 없습니다 (CODE: 17).\n\n일반적인 원인:\n1. Broker 추적이 비활성화됨: broker.conf에서 traceTopicEnable=true를 설정하고 Broker를 다시 시작\n2. Producer/Consumer 추적이 비활성화됨: setEnableTrace(true) 호출\n3. 사용자 지정 추적 토픽 사용: 위에서 올바른 추적 토픽을 선택하세요",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "알 수 없음",
|
||||
statusSuccess: "성공",
|
||||
statusFail: "실패",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store Host",
|
||||
fieldClientHost: "Client Host",
|
||||
fieldBodyLength: "Body Length",
|
||||
fieldCostTime: "소요 시간",
|
||||
fieldMsgType: "메시지 유형",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "재시도",
|
||||
fieldContextCode: "Context Code",
|
||||
rawPayload: "원본 추적",
|
||||
},
|
||||
mqRaw: {
|
||||
title: "원시 API",
|
||||
|
||||
@@ -5919,6 +5919,8 @@ export default withEnglishFallback({
|
||||
viewTenant: "Ver locatário",
|
||||
viewNamespace: "Ver namespace",
|
||||
viewTopic: "Ver tópico",
|
||||
connectionMissing: "Conexão não encontrada. Reconecte e tente novamente.",
|
||||
writeDeniedReadOnly: "Esta conexão é somente leitura e não pode executar operações de gravação.",
|
||||
readOnly: "Somente leitura",
|
||||
allNamespaces: "Todos os namespaces",
|
||||
newNamespace: "Novo namespace",
|
||||
@@ -6260,7 +6262,12 @@ export default withEnglishFallback({
|
||||
searchPlaceholder: "Pesquisar nome da assinatura",
|
||||
subscribedTopics: "Tópicos inscritos",
|
||||
refreshing: "Atualizando...",
|
||||
enriching: "Carregando detalhes online...",
|
||||
truncatedHint: "Mostrando os primeiros {count} grupos de consumidores (limite). Restrinja a busca para encontrar outros.",
|
||||
enrichFailedHint: "Não foi possível carregar os detalhes online ({error}). Mostrando apenas a lista rápida.",
|
||||
noConsumerGroups: "Nenhum Consumer Group",
|
||||
noMatches: "Nenhum grupo de consumidores correspondente",
|
||||
backlogPartialFailed: "Falha ao carregar backlog de {count} tópico(s): {error}",
|
||||
viewDetail: "Detalhes",
|
||||
editConfig: "Configuração",
|
||||
save: "Salvar",
|
||||
@@ -6271,7 +6278,18 @@ export default withEnglishFallback({
|
||||
consumerConsumeDetail: "Detalhes do consumo",
|
||||
noOnlineConsumers: "Nenhum consumidor online no momento",
|
||||
noConsumeDetail: "Nenhum detalhe de consumo",
|
||||
noQueueConsumeProgress: "Sem progresso de consumo por fila",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "Backlog",
|
||||
invalidTopicScope: "Escopo de tópico inválido",
|
||||
lastConsumeTime: "Último consumo",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "Permitir consumo",
|
||||
consumeBroadcastEnable: "Permitir consumo em broadcast",
|
||||
consumeFromMinEnable: "Iniciar do offset mínimo",
|
||||
@@ -6286,6 +6304,7 @@ export default withEnglishFallback({
|
||||
normal: "Normal",
|
||||
fifo: "Ordenado",
|
||||
system: "Sistema",
|
||||
unknown: "Desconhecido",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "Cluster",
|
||||
@@ -6524,6 +6543,7 @@ export default withEnglishFallback({
|
||||
sourceDataCompare: "Comparação de dados",
|
||||
sourceExtension: "Gerenciador de extensões",
|
||||
sourceSidebar: "Árvore de objetos",
|
||||
sourceMq: "Fila de mensagens",
|
||||
sourceObjectSource: "Editor de código-fonte de objetos",
|
||||
sourceDataGenerate: "Gerador de dados",
|
||||
sourceQueryHistory: "Reversão do histórico de consultas",
|
||||
@@ -6918,5 +6938,28 @@ export default withEnglishFallback({
|
||||
detailTitle: "Detalhes do rastreamento de consumo",
|
||||
traceTopicRouteMissing:
|
||||
"O tópico de rastreamento não possui informações de rota no NameServer (CODE: 17).\n\nCausas comuns:\n1. Broker não habilitou rastreamento de mensagens: defina traceTopicEnable=true no broker.conf e reinicie o Broker\n2. Producer/Consumer não habilitou rastreamento: setEnableTrace(true)\n3. Usou um tópico de rastreamento personalizado, selecione o tópico correto acima.",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "Desconhecido",
|
||||
statusSuccess: "Sucesso",
|
||||
statusFail: "Falhou",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store host",
|
||||
fieldClientHost: "Client host",
|
||||
fieldBodyLength: "Body length",
|
||||
fieldCostTime: "Custo",
|
||||
fieldMsgType: "Msg type",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "Tentativas",
|
||||
fieldContextCode: "Context code",
|
||||
rawPayload: "Rastreamento bruto",
|
||||
},
|
||||
});
|
||||
@@ -3990,6 +3990,7 @@ export default withEnglishFallback({
|
||||
sourceDataCompare: "数据对比",
|
||||
sourceExtension: "扩展管理",
|
||||
sourceSidebar: "对象树",
|
||||
sourceMq: "消息队列",
|
||||
sourceObjectSource: "对象源码编辑器",
|
||||
sourceDataGenerate: "数据生成器",
|
||||
sourceQueryHistory: "查询历史回滚",
|
||||
@@ -6058,6 +6059,8 @@ export default withEnglishFallback({
|
||||
viewTenant: "查看租户",
|
||||
viewNamespace: "查看命名空间",
|
||||
viewTopic: "查看主题",
|
||||
connectionMissing: "未找到连接,请重新连接后再试。",
|
||||
writeDeniedReadOnly: "当前连接为只读模式,不能执行写操作。",
|
||||
readOnly: "只读",
|
||||
allNamespaces: "全部命名空间",
|
||||
newNamespace: "新建命名空间",
|
||||
@@ -6344,6 +6347,9 @@ export default withEnglishFallback({
|
||||
createSubscription: "创建订阅",
|
||||
selectTopicFirst: "请先选择一个主题",
|
||||
searchPlaceholder: "搜索订阅名称",
|
||||
enriching: "正在补全在线信息...",
|
||||
truncatedHint: "当前仅显示前 {count} 个消费组(已达上限),请缩小搜索范围查找其他组",
|
||||
enrichFailedHint: "在线成员/订阅 Topic 信息加载失败({error}),当前仅显示快速列表",
|
||||
subscribedTopics: "订阅 Topic",
|
||||
readOnly: "当前连接为只读模式,不能执行写操作",
|
||||
loading: "加载中...",
|
||||
@@ -6351,6 +6357,8 @@ export default withEnglishFallback({
|
||||
refreshing: "刷新中...",
|
||||
noSubscriptions: "该主题暂无订阅",
|
||||
noConsumerGroups: "暂无 Consumer Group",
|
||||
noMatches: "没有匹配的 Consumer Group",
|
||||
backlogPartialFailed: "{count} 个 Topic 的堆积信息加载失败:{error}",
|
||||
viewDetail: "详情",
|
||||
editConfig: "配置",
|
||||
save: "保存",
|
||||
@@ -6361,7 +6369,18 @@ export default withEnglishFallback({
|
||||
consumerConsumeDetail: "消费详情",
|
||||
noOnlineConsumers: "当前没有在线消费者",
|
||||
noConsumeDetail: "暂无消费详情",
|
||||
noQueueConsumeProgress: "暂无队列消费进度",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "积压",
|
||||
invalidTopicScope: "无效的 Topic 作用域",
|
||||
lastConsumeTime: "最后消费时间",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "允许消费",
|
||||
consumeBroadcastEnable: "允许广播消费",
|
||||
consumeFromMinEnable: "从最小 offset 开始",
|
||||
@@ -6378,6 +6397,7 @@ export default withEnglishFallback({
|
||||
normal: "普通",
|
||||
fifo: "顺序",
|
||||
system: "系统",
|
||||
unknown: "未知",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "集群",
|
||||
@@ -6620,6 +6640,29 @@ export default withEnglishFallback({
|
||||
searchHint: "请先搜索消息,再点击「查看消费轨迹」",
|
||||
viewTrace: "查看消费轨迹",
|
||||
detailTitle: "消费轨迹详情",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "未知",
|
||||
statusSuccess: "成功",
|
||||
statusFail: "失败",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store Host",
|
||||
fieldClientHost: "Client Host",
|
||||
fieldBodyLength: "Body Length",
|
||||
fieldCostTime: "耗时",
|
||||
fieldMsgType: "消息类型",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "重试次数",
|
||||
fieldContextCode: "Context Code",
|
||||
rawPayload: "原始轨迹",
|
||||
traceTopicRouteMissing: "轨迹 Topic 在 NameServer 上没有路由信息(CODE: 17)。\n\n常见原因:\n1. Broker 未开启消息轨迹:在 broker.conf 中设置 traceTopicEnable=true 并重启 Broker\n2. Producer/Consumer 未开启轨迹:setEnableTrace(true)\n3. 使用了自定义轨迹 Topic,请在上方选择正确的轨迹 Topic",
|
||||
},
|
||||
mqRaw: {
|
||||
|
||||
@@ -5912,6 +5912,8 @@ export default withEnglishFallback({
|
||||
viewTenant: "檢視租用戶",
|
||||
viewNamespace: "檢視命名空間",
|
||||
viewTopic: "檢視主題",
|
||||
connectionMissing: "找不到連線,請重新連線後再試。",
|
||||
writeDeniedReadOnly: "目前連線為唯讀模式,無法執行寫入操作。",
|
||||
readOnly: "唯讀",
|
||||
allNamespaces: "全部命名空間",
|
||||
newNamespace: "新建命名空間",
|
||||
@@ -6253,7 +6255,12 @@ export default withEnglishFallback({
|
||||
searchPlaceholder: "搜尋訂閱名稱",
|
||||
subscribedTopics: "訂閱 Topic",
|
||||
refreshing: "重新整理中...",
|
||||
enriching: "正在載入線上詳情...",
|
||||
truncatedHint: "目前僅顯示前 {count} 個消費組(已達上限),請縮小搜尋範圍查找其他組",
|
||||
enrichFailedHint: "線上成員/訂閱 Topic 資訊載入失敗({error}),目前僅顯示快速列表",
|
||||
noConsumerGroups: "暫無 Consumer Group",
|
||||
noMatches: "沒有符合的消費組",
|
||||
backlogPartialFailed: "{count} 個 Topic 的堆積資訊載入失敗:{error}",
|
||||
viewDetail: "詳情",
|
||||
editConfig: "配置",
|
||||
save: "儲存",
|
||||
@@ -6264,7 +6271,18 @@ export default withEnglishFallback({
|
||||
consumerConsumeDetail: "消費詳情",
|
||||
noOnlineConsumers: "當前沒有線上消費者",
|
||||
noConsumeDetail: "暫無消費詳情",
|
||||
noQueueConsumeProgress: "暫無佇列消費進度",
|
||||
operationTopic: "Topic",
|
||||
consumeDelay: "積壓",
|
||||
invalidTopicScope: "無效的主題作用域",
|
||||
lastConsumeTime: "最後消費時間",
|
||||
broker: "broker",
|
||||
queue: "queue",
|
||||
consumerClient: "consumerClient",
|
||||
brokerOffset: "brokerOffset",
|
||||
consumerOffset: "consumerOffset",
|
||||
diffTotal: "diffTotal",
|
||||
lastTimestamp: "lastTimestamp",
|
||||
consumeEnable: "允許消費",
|
||||
consumeBroadcastEnable: "允許廣播消費",
|
||||
consumeFromMinEnable: "從最小 offset 開始",
|
||||
@@ -6279,6 +6297,7 @@ export default withEnglishFallback({
|
||||
normal: "普通",
|
||||
fifo: "順序",
|
||||
system: "系統",
|
||||
unknown: "未知",
|
||||
},
|
||||
rocketmqGroupMode: {
|
||||
clustering: "集群",
|
||||
@@ -6517,6 +6536,7 @@ export default withEnglishFallback({
|
||||
sourceDataCompare: "資料比對",
|
||||
sourceExtension: "擴充功能管理",
|
||||
sourceSidebar: "物件樹",
|
||||
sourceMq: "訊息佇列",
|
||||
sourceObjectSource: "物件原始碼編輯器",
|
||||
sourceDataGenerate: "資料產生器",
|
||||
sourceQueryHistory: "查詢歷史回復",
|
||||
@@ -6909,6 +6929,29 @@ export default withEnglishFallback({
|
||||
searchHint: "請先搜尋消息,再點擊「檢視消費軌跡」",
|
||||
viewTrace: "檢視消費軌跡",
|
||||
detailTitle: "消費軌跡詳情",
|
||||
typePub: "Pub",
|
||||
typeSubBefore: "SubBefore",
|
||||
typeSubAfter: "SubAfter",
|
||||
typeEndTransaction: "EndTransaction",
|
||||
typeUnknown: "未知",
|
||||
statusSuccess: "成功",
|
||||
statusFail: "失敗",
|
||||
fieldRegionId: "Region",
|
||||
fieldGroup: "Group",
|
||||
fieldTopic: "Topic",
|
||||
fieldMsgId: "MsgId",
|
||||
fieldTags: "Tags",
|
||||
fieldKeys: "Keys",
|
||||
fieldStoreHost: "Store Host",
|
||||
fieldClientHost: "Client Host",
|
||||
fieldBodyLength: "Body Length",
|
||||
fieldCostTime: "耗時",
|
||||
fieldMsgType: "訊息類型",
|
||||
fieldOffsetMsgId: "Offset MsgId",
|
||||
fieldRequestId: "Request ID",
|
||||
fieldRetryTimes: "重試次數",
|
||||
fieldContextCode: "Context Code",
|
||||
rawPayload: "原始軌跡",
|
||||
traceTopicRouteMissing: "軌跡 Topic 在 NameServer 上沒有路由資訊(CODE: 17)。\n\n常見原因:\n1. Broker 未開啟消息軌跡:在 broker.conf 中設置 traceTopicEnable=true 並重新啟動 Broker\n2. Producer/Consumer 未開啟軌跡:setEnableTrace(true)\n3. 使用了自定義軌跡 Topic,請在上方選擇正確的軌跡 Topic",
|
||||
},
|
||||
});
|
||||
@@ -489,6 +489,7 @@ export const mqListClientConnections = forward("mqListClientConnections");
|
||||
export const mqListClientChannels = forward("mqListClientChannels");
|
||||
export const mqCloseClientConnection = forward("mqCloseClientConnection");
|
||||
export const mqListSubscriptions = forward("mqListSubscriptions");
|
||||
export const mqEnrichSubscriptions = forward("mqEnrichSubscriptions");
|
||||
export const mqCreateSubscription = forward("mqCreateSubscription");
|
||||
export const mqDeleteSubscription = forward("mqDeleteSubscription");
|
||||
export const mqSkipMessages = forward("mqSkipMessages");
|
||||
|
||||
@@ -217,6 +217,10 @@ export async function mqListSubscriptions(connectionId: string, topic: TopicRef)
|
||||
return post("/api/mq/subscriptions/list", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqEnrichSubscriptions(connectionId: string, topic: TopicRef): Promise<SubscriptionInfo[]> {
|
||||
return post("/api/mq/subscriptions/enrich", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
|
||||
return post("/api/mq/subscriptions/create", { connectionId, topic, sub, pos });
|
||||
}
|
||||
|
||||
@@ -211,6 +211,10 @@ export async function mqListSubscriptions(connectionId: string, topic: TopicRef)
|
||||
return invoke("mq_list_subscriptions", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqEnrichSubscriptions(connectionId: string, topic: TopicRef): Promise<SubscriptionInfo[]> {
|
||||
return invoke("mq_enrich_subscriptions", { connectionId, topic });
|
||||
}
|
||||
|
||||
export async function mqCreateSubscription(connectionId: string, topic: TopicRef, sub: string, pos: ResetPosition): Promise<void> {
|
||||
return invoke("mq_create_subscription", { connectionId, topic, sub, pos });
|
||||
}
|
||||
|
||||
@@ -5,13 +5,15 @@ describe("rocketmqConsumerGroupTypes", () => {
|
||||
it("resolves group type from consumerGroupType or subType", () => {
|
||||
expect(resolveRocketMqConsumerGroupType({ consumerGroupType: "FIFO", subType: "NORMAL" })).toBe("FIFO");
|
||||
expect(resolveRocketMqConsumerGroupType({ subType: "SYSTEM" })).toBe("SYSTEM");
|
||||
expect(resolveRocketMqConsumerGroupType({ consumerGroupType: "UNKNOWN" })).toBe("UNKNOWN");
|
||||
expect(resolveRocketMqConsumerGroupType({ subType: "CONSUME_PASSIVELY ? CLUSTERING" })).toBe("NORMAL");
|
||||
});
|
||||
|
||||
it("filters by dashboard group types", () => {
|
||||
const filters = { NORMAL: true, FIFO: true, SYSTEM: false };
|
||||
const filters = { NORMAL: true, FIFO: true, SYSTEM: false, UNKNOWN: true };
|
||||
expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "NORMAL", subType: "NORMAL" }, filters)).toBe(true);
|
||||
expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "SYSTEM", subType: "SYSTEM" }, filters)).toBe(false);
|
||||
expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "UNKNOWN", subType: "UNKNOWN" }, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves message model", () => {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatRocketMqTraceError, isRocketMqTraceTopicRouteMissingError } from "@/lib/mq/rocketmqTraceUtils";
|
||||
import { formatRocketMqTraceError, isParsedRocketMqTraceRecord, isRocketMqTraceTopicRouteMissingError, parseRocketMqTracePayload, splitRocketMqTraceKeys } from "@/lib/mq/rocketmqTraceUtils";
|
||||
|
||||
const SOH = "\u0001";
|
||||
const STX = "\u0002";
|
||||
|
||||
function joinFields(...parts: string[]): string {
|
||||
return parts.join(SOH);
|
||||
}
|
||||
|
||||
describe("rocketmqTraceUtils", () => {
|
||||
it("detects RocketMQ trace topic route missing errors", () => {
|
||||
@@ -11,4 +18,75 @@ describe("rocketmqTraceUtils", () => {
|
||||
const hint = "Enable traceTopicEnable on broker";
|
||||
expect(formatRocketMqTraceError(new Error("CODE: 17 DESC: No topic route info in name server for the topic: RMQ_SYS_TRACE_TOPIC"), hint)).toBe(hint);
|
||||
});
|
||||
|
||||
it("parses Pub records including optional clientHost", () => {
|
||||
const payload = joinFields(
|
||||
"Pub",
|
||||
"1785728122381",
|
||||
"DefaultRegion",
|
||||
"api-orch-server_producer",
|
||||
"SIGNATURE_OPERATE_LOG_TOPIC_DEV",
|
||||
"AC12021900073BCED0D20CC95A0D0197",
|
||||
"ADD_SIGNATURE_OPERATE_LOG_TAG",
|
||||
"AC12021900073BCED0D20CC95A0D0197 AC12021900073BCED0D20CC95BFF019F",
|
||||
"192.168.3.25:10911",
|
||||
"512",
|
||||
"4",
|
||||
"0",
|
||||
"AC12021900073BCED0D20CC95A0D0197",
|
||||
"true",
|
||||
"192.168.2.25",
|
||||
);
|
||||
|
||||
const [record] = parseRocketMqTracePayload(payload);
|
||||
expect(record.type).toBe("Pub");
|
||||
expect(record.timestamp).toBe(1785728122381);
|
||||
expect(record.success).toBe(true);
|
||||
expect(isParsedRocketMqTraceRecord(record)).toBe(true);
|
||||
expect(record.fields.find((field) => field.key === "group")?.value).toBe("api-orch-server_producer");
|
||||
expect(record.fields.find((field) => field.key === "topic")?.value).toBe("SIGNATURE_OPERATE_LOG_TOPIC_DEV");
|
||||
expect(record.fields.find((field) => field.key === "costTime")?.value).toBe("4 ms");
|
||||
expect(record.fields.find((field) => field.key === "clientHost")?.value).toBe("192.168.2.25");
|
||||
});
|
||||
|
||||
it("parses SubBefore and SubAfter with version-tolerant lengths", () => {
|
||||
const subBefore = joinFields("SubBefore", "1785728123000", "DefaultRegion", "LISTING_AGREEMENT_SYNC_TOPIC_LISTING_FULL_AGREEMENT_CONSUMER_DEV", "req-1", "AC12021900073BCED0D20CC95BFF019F", "0", "null", "192.168.2.30");
|
||||
const subAfterOld = joinFields("SubAfter", "req-1", "AC12021900073BCED0D20CC95BFF019F", "12", "true", "k1", "0");
|
||||
const subAfterNew = joinFields("SubAfter", "req-2", "AC12021900073BCED0D20CC95BFF019F", "20", "false", "k2", "1", "1785728123120", "LISTING_AGREEMENT_SYNC_TOPIC_LISTING_FULL_AGREEMENT_CONSUMER_DEV");
|
||||
|
||||
const records = parseRocketMqTracePayload(`${subBefore}${STX}${subAfterOld}${STX}${subAfterNew}`);
|
||||
expect(records).toHaveLength(3);
|
||||
expect(records[0].type).toBe("SubBefore");
|
||||
expect(records[0].fields.find((field) => field.key === "retryTimes")?.value).toBe("0");
|
||||
expect(records[0].fields.find((field) => field.key === "keys")?.value).toBe("-");
|
||||
expect(records[0].fields.find((field) => field.key === "clientHost")?.value).toBe("192.168.2.30");
|
||||
|
||||
expect(records[1].type).toBe("SubAfter");
|
||||
expect(records[1].success).toBe(true);
|
||||
expect(records[1].timestamp).toBeUndefined();
|
||||
expect(records[1].fields.find((field) => field.key === "costTime")?.value).toBe("12 ms");
|
||||
|
||||
expect(records[2].type).toBe("SubAfter");
|
||||
expect(records[2].success).toBe(false);
|
||||
expect(records[2].timestamp).toBe(1785728123120);
|
||||
expect(records[2].fields.find((field) => field.key === "group")?.value).toBe("LISTING_AGREEMENT_SYNC_TOPIC_LISTING_FULL_AGREEMENT_CONSUMER_DEV");
|
||||
});
|
||||
|
||||
it("normalizes Type@ prefix and falls back for unknown payloads", () => {
|
||||
const withAt = `Pub@1785728122381${SOH}DefaultRegion${SOH}g${SOH}t${SOH}m${SOH}tag${SOH}k${SOH}host${SOH}1${SOH}2${SOH}0${SOH}off${SOH}true`;
|
||||
const [parsed] = parseRocketMqTracePayload(withAt);
|
||||
expect(parsed.type).toBe("Pub");
|
||||
expect(parsed.fields.find((field) => field.key === "group")?.value).toBe("g");
|
||||
|
||||
const [unknown] = parseRocketMqTracePayload("not-a-trace-payload");
|
||||
expect(unknown.type).toBe("Unknown");
|
||||
expect(isParsedRocketMqTraceRecord(unknown)).toBe(false);
|
||||
expect(unknown.raw).toBe("not-a-trace-payload");
|
||||
});
|
||||
|
||||
it("splits KEYS into chips", () => {
|
||||
expect(splitRocketMqTraceKeys("a b,c")).toEqual(["a", "b", "c"]);
|
||||
expect(splitRocketMqTraceKeys("null")).toEqual([]);
|
||||
expect(splitRocketMqTraceKeys("-")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,21 @@
|
||||
import type { SubscriptionInfo } from "@/types/mq";
|
||||
|
||||
export type RocketMqConsumerGroupType = "NORMAL" | "FIFO" | "SYSTEM";
|
||||
export type RocketMqConsumerGroupType = "NORMAL" | "FIFO" | "SYSTEM" | "UNKNOWN";
|
||||
|
||||
export const ROCKETMQ_CONSUMER_GROUP_TYPES: readonly RocketMqConsumerGroupType[] = ["NORMAL", "FIFO", "SYSTEM"];
|
||||
export const ROCKETMQ_CONSUMER_GROUP_TYPES: readonly RocketMqConsumerGroupType[] = ["NORMAL", "FIFO", "SYSTEM", "UNKNOWN"];
|
||||
|
||||
export const DEFAULT_ROCKETMQ_CONSUMER_GROUP_TYPE_FILTERS: Record<RocketMqConsumerGroupType, boolean> = {
|
||||
NORMAL: true,
|
||||
FIFO: true,
|
||||
SYSTEM: false,
|
||||
UNKNOWN: true,
|
||||
};
|
||||
|
||||
export function resolveRocketMqConsumerGroupType(sub: Pick<SubscriptionInfo, "consumerGroupType" | "subType">): RocketMqConsumerGroupType {
|
||||
const raw = (sub.consumerGroupType ?? sub.subType)?.trim().toUpperCase();
|
||||
if (raw === "FIFO" || raw === "ORDER" || raw === "ORDERLY") return "FIFO";
|
||||
if (raw === "SYSTEM") return "SYSTEM";
|
||||
if (raw === "UNKNOWN") return "UNKNOWN";
|
||||
if (raw === "NORMAL") return "NORMAL";
|
||||
return "NORMAL";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
import { formatRocketMqTimestamp } from "@/lib/mq/rocketmqMessageUtils";
|
||||
|
||||
/** RocketMQ CODE 17: trace topic has no route on NameServer (trace not enabled or wrong topic). */
|
||||
export function isRocketMqTraceTopicRouteMissingError(error: unknown): boolean {
|
||||
@@ -12,3 +13,184 @@ export function formatRocketMqTraceError(error: unknown, traceTopicMissingHint:
|
||||
}
|
||||
return formatError(error);
|
||||
}
|
||||
|
||||
/** Content / field splitters from Apache RocketMQ TraceConstants. */
|
||||
const CONTENT_SPLITTER = "\u0001";
|
||||
const FIELD_SPLITTER = "\u0002";
|
||||
|
||||
export type RocketMqTraceType = "Pub" | "SubBefore" | "SubAfter" | "EndTransaction" | "Unknown";
|
||||
|
||||
/** Stable field keys mapped to mqTrace.field* i18n labels in the dialog. */
|
||||
export type RocketMqTraceFieldKey = "regionId" | "group" | "topic" | "msgId" | "tags" | "keys" | "storeHost" | "clientHost" | "bodyLength" | "costTime" | "msgType" | "offsetMsgId" | "requestId" | "retryTimes" | "contextCode";
|
||||
|
||||
export interface RocketMqTraceField {
|
||||
key: RocketMqTraceFieldKey;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface RocketMqTraceRecord {
|
||||
type: RocketMqTraceType;
|
||||
timestamp?: number;
|
||||
/** Parsed success flag when present (Pub / SubAfter). */
|
||||
success?: boolean;
|
||||
fields: RocketMqTraceField[];
|
||||
raw: string;
|
||||
}
|
||||
|
||||
function emptyDisplay(value: string | undefined): string {
|
||||
if (value == null) return "-";
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === "null") return "-";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseTimestamp(raw: string | undefined): number | undefined {
|
||||
if (!raw?.trim()) return undefined;
|
||||
const numeric = Number(raw);
|
||||
return Number.isFinite(numeric) ? numeric : undefined;
|
||||
}
|
||||
|
||||
function parseSuccess(raw: string | undefined): boolean | undefined {
|
||||
if (raw == null || !raw.trim()) return undefined;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === "true") return true;
|
||||
if (normalized === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatCostTime(raw: string | undefined): string {
|
||||
const value = emptyDisplay(raw);
|
||||
if (value === "-") return value;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return value;
|
||||
return `${numeric} ms`;
|
||||
}
|
||||
|
||||
function pushField(fields: RocketMqTraceField[], key: RocketMqTraceFieldKey, value: string | undefined): void {
|
||||
const display = key === "costTime" ? formatCostTime(value) : emptyDisplay(value);
|
||||
// Keep tags/keys/retry visible even when empty so Pub/Sub cards stay scannable.
|
||||
if (display === "-" && key !== "tags" && key !== "keys" && key !== "retryTimes") return;
|
||||
fields.push({ key, value: display });
|
||||
}
|
||||
|
||||
/** Normalize rare "Type@fields" prefix into official Type\\u0001fields form. */
|
||||
function normalizeTraceSegment(segment: string): string {
|
||||
return segment.replace(/^(Pub|SubBefore|SubAfter|EndTransaction)@/, `$1${CONTENT_SPLITTER}`);
|
||||
}
|
||||
|
||||
function parsePub(parts: string[], raw: string): RocketMqTraceRecord {
|
||||
// Pub: type, ts, region, group, topic, msgId, tags, keys, storeHost, bodyLength, cost, msgType, offsetMsgId, success[, clientHost]
|
||||
const timestamp = parseTimestamp(parts[1]);
|
||||
const success = parseSuccess(parts[13]);
|
||||
const fields: RocketMqTraceField[] = [];
|
||||
pushField(fields, "regionId", parts[2]);
|
||||
pushField(fields, "group", parts[3]);
|
||||
pushField(fields, "topic", parts[4]);
|
||||
pushField(fields, "msgId", parts[5]);
|
||||
pushField(fields, "tags", parts[6]);
|
||||
pushField(fields, "keys", parts[7]);
|
||||
pushField(fields, "storeHost", parts[8]);
|
||||
pushField(fields, "bodyLength", parts[9]);
|
||||
pushField(fields, "costTime", parts[10]);
|
||||
pushField(fields, "msgType", parts[11]);
|
||||
pushField(fields, "offsetMsgId", parts[12]);
|
||||
if (parts.length >= 15) pushField(fields, "clientHost", parts[14]);
|
||||
return { type: "Pub", timestamp, success, fields, raw };
|
||||
}
|
||||
|
||||
function parseSubBefore(parts: string[], raw: string): RocketMqTraceRecord {
|
||||
// SubBefore: type, ts, region, group, requestId, msgId, retryTimes, keys[, clientHost]
|
||||
// Some encoders also append storeHost before clientHost (length >= 9/10).
|
||||
const timestamp = parseTimestamp(parts[1]);
|
||||
const fields: RocketMqTraceField[] = [];
|
||||
pushField(fields, "regionId", parts[2]);
|
||||
pushField(fields, "group", parts[3]);
|
||||
pushField(fields, "requestId", parts[4]);
|
||||
pushField(fields, "msgId", parts[5]);
|
||||
pushField(fields, "retryTimes", parts[6]);
|
||||
pushField(fields, "keys", parts[7]);
|
||||
if (parts.length >= 10) {
|
||||
pushField(fields, "storeHost", parts[8]);
|
||||
pushField(fields, "clientHost", parts[9]);
|
||||
} else if (parts.length >= 9) {
|
||||
pushField(fields, "clientHost", parts[8]);
|
||||
}
|
||||
return { type: "SubBefore", timestamp, fields, raw };
|
||||
}
|
||||
|
||||
function parseSubAfter(parts: string[], raw: string): RocketMqTraceRecord {
|
||||
// SubAfter: type, requestId, msgId, cost, success, keys, contextCode[, timestamp, groupName]
|
||||
const success = parseSuccess(parts[4]);
|
||||
const timestamp = parts.length >= 8 ? parseTimestamp(parts[7]) : undefined;
|
||||
const fields: RocketMqTraceField[] = [];
|
||||
pushField(fields, "requestId", parts[1]);
|
||||
pushField(fields, "msgId", parts[2]);
|
||||
pushField(fields, "costTime", parts[3]);
|
||||
pushField(fields, "keys", parts[5]);
|
||||
pushField(fields, "contextCode", parts[6]);
|
||||
if (parts.length >= 9) pushField(fields, "group", parts[8]);
|
||||
return { type: "SubAfter", timestamp, success, fields, raw };
|
||||
}
|
||||
|
||||
function parseEndTransaction(parts: string[], raw: string): RocketMqTraceRecord {
|
||||
// EndTransaction: type, ts, region, group, topic, msgId, tags, keys, storeHost, msgType, clientHost, ...
|
||||
const timestamp = parseTimestamp(parts[1]);
|
||||
const fields: RocketMqTraceField[] = [];
|
||||
pushField(fields, "regionId", parts[2]);
|
||||
pushField(fields, "group", parts[3]);
|
||||
pushField(fields, "topic", parts[4]);
|
||||
pushField(fields, "msgId", parts[5]);
|
||||
pushField(fields, "tags", parts[6]);
|
||||
pushField(fields, "keys", parts[7]);
|
||||
pushField(fields, "storeHost", parts[8]);
|
||||
pushField(fields, "msgType", parts[9]);
|
||||
if (parts.length >= 11) pushField(fields, "clientHost", parts[10]);
|
||||
return { type: "EndTransaction", timestamp, fields, raw };
|
||||
}
|
||||
|
||||
function parseTraceSegment(segment: string): RocketMqTraceRecord {
|
||||
const raw = segment;
|
||||
const normalized = normalizeTraceSegment(segment.trim());
|
||||
if (!normalized) {
|
||||
return { type: "Unknown", fields: [], raw };
|
||||
}
|
||||
const parts = normalized.split(CONTENT_SPLITTER);
|
||||
const type = parts[0]?.trim();
|
||||
if (type === "Pub" && parts.length >= 14) return parsePub(parts, raw);
|
||||
if (type === "SubBefore" && parts.length >= 8) return parseSubBefore(parts, raw);
|
||||
if (type === "SubAfter" && parts.length >= 7) return parseSubAfter(parts, raw);
|
||||
if (type === "EndTransaction" && parts.length >= 9) return parseEndTransaction(parts, raw);
|
||||
return { type: "Unknown", fields: [], raw };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RocketMQ trace message body into structured records.
|
||||
* Official format: Type\\u0001field...\\u0002Type\\u0001...
|
||||
*/
|
||||
export function parseRocketMqTracePayload(payload: string): RocketMqTraceRecord[] {
|
||||
if (!payload?.trim()) return [];
|
||||
const segments = payload
|
||||
.split(FIELD_SPLITTER)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
// Body may be a single record without trailing STX.
|
||||
const units = segments.length ? segments : [payload.trim()];
|
||||
return units.map(parseTraceSegment);
|
||||
}
|
||||
|
||||
export function isParsedRocketMqTraceRecord(record: RocketMqTraceRecord): boolean {
|
||||
return record.type !== "Unknown" && record.fields.length > 0;
|
||||
}
|
||||
|
||||
/** Split KEYS header / keys field into display chips. */
|
||||
export function splitRocketMqTraceKeys(value: string | undefined): string[] {
|
||||
if (!value?.trim() || value.trim() === "-" || value.trim() === "null") return [];
|
||||
return value
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function rocketMqTraceRecordTimeLabel(record: RocketMqTraceRecord, fallbackTimestamp?: number): string {
|
||||
return formatRocketMqTimestamp(record.timestamp ?? fallbackTimestamp);
|
||||
}
|
||||
@@ -192,6 +192,8 @@ export interface SubscriptionInfo {
|
||||
consumerGroupType?: string;
|
||||
/** RocketMQ consumer group message model: CLUSTERING / BROADCASTING. */
|
||||
messageModel?: string;
|
||||
/** When true, backlog probe failed — do not render msgBacklog as healthy zero. */
|
||||
backlogUnavailable?: boolean;
|
||||
}
|
||||
|
||||
export interface RocketMqConsumerGroupConfig {
|
||||
@@ -228,9 +230,25 @@ export type ResetPosition = { kind: "earliest" } | { kind: "latest" } | { kind:
|
||||
|
||||
export type SkipCount = { kind: "all" } | { kind: "count"; count: number };
|
||||
|
||||
/** Per-queue consume progress (RocketMQ Dashboard consume-detail / Kafka lag rows). */
|
||||
export interface PartitionBacklog {
|
||||
partition: number;
|
||||
/** Consumer committed offset (`consumerOffset`). */
|
||||
currentOffset: number;
|
||||
/** Broker max offset (`brokerOffset`). */
|
||||
endOffset: number;
|
||||
lag: number;
|
||||
brokerName?: string;
|
||||
/** Last consume message store timestamp (ms). `0` means unavailable. */
|
||||
lastTimestamp?: number;
|
||||
consumerClient?: string;
|
||||
}
|
||||
|
||||
export interface BacklogStats {
|
||||
msgBacklog: number;
|
||||
backlogSize: number;
|
||||
/** Optional queue-level progress; omitted or empty when adapter only exposes totals. */
|
||||
partitions?: PartitionBacklog[];
|
||||
}
|
||||
|
||||
export interface ClusterInfo {
|
||||
|
||||
Reference in new issue
Block a user