* 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>
812 lines
25 KiB
Vue
812 lines
25 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onUnmounted, ref, watch } from "vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import type { MqSystemKind, SendMessageRequest, SendMessageResponse, TopicInfo, TopicRef } from "@/types/mq";
|
|
import { mqViewMessage, mqQueryMessagesByKey, mqQueryMessagesByTopic, mqSendMessage, mqPeekMessages } from "@/lib/backend/api";
|
|
import { formatRocketMqMessagePayload, formatRocketMqTimestamp, parseRocketMqMessagesFromResult, rocketMqDisplayFromPeeked, rocketMqMessagePayload, type RocketMqDisplayMessage } from "@/lib/mq/rocketmqMessageUtils";
|
|
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";
|
|
|
|
interface Props {
|
|
connectionId: string;
|
|
tenant?: string;
|
|
namespace?: string;
|
|
topic?: TopicInfo;
|
|
readOnly?: boolean;
|
|
mqSystemKind?: MqSystemKind;
|
|
embedded?: boolean;
|
|
preferDlqTopic?: boolean;
|
|
tracePanel?: boolean;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
const { t } = useI18n();
|
|
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
|
|
|
const topicSelectRef = ref<InstanceType<typeof RocketMqTopicSelect>>();
|
|
const availableTopics = ref<TopicInfo[]>([]);
|
|
const topicsLoading = computed(() => topicSelectRef.value?.loading ?? false);
|
|
const topicName = ref("");
|
|
const queryMsgId = ref("");
|
|
const queryKey = ref("");
|
|
const queryBeginTime = ref("");
|
|
const queryEndTime = ref("");
|
|
const queryMaxNum = ref(32);
|
|
const queryLoading = ref(false);
|
|
const queryError = ref<string>();
|
|
const queryMessages = ref<RocketMqDisplayMessage[]>([]);
|
|
const activeQueryMode = ref<"topic" | "key" | "msgId">("topic");
|
|
const selectedMessage = ref<RocketMqDisplayMessage>();
|
|
const detailLoading = ref(false);
|
|
const detailError = ref<string>();
|
|
const resendLoading = ref(false);
|
|
const resendError = ref<string>();
|
|
const resendSuccess = ref<SendMessageResponse>();
|
|
const payloadCopied = ref(false);
|
|
const traceDialogOpen = ref(false);
|
|
const traceMsgId = ref("");
|
|
const traceTopic = ref(DEFAULT_ROCKETMQ_TRACE_TOPIC);
|
|
const consumerGroup = ref("");
|
|
const dlqUseKeyQuery = ref(false);
|
|
const noDlqTopics = ref(false);
|
|
|
|
let payloadCopiedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let resendSuccessTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
|
|
const isDlqTopicSelected = computed(() => {
|
|
if (props.tracePanel) return false;
|
|
if (!isRocketMqCluster.value || !topicName.value.trim()) return false;
|
|
const fromList = availableTopics.value.find((item) => item.shortName === topicName.value.trim());
|
|
if (fromList) return resolveRocketMqMessageType(fromList) === "DLQ";
|
|
return topicSelectRef.value?.resolveTopicType(topicName.value) === "DLQ";
|
|
});
|
|
const showBusinessQueryModes = computed(() => !isDlqTopicSelected.value);
|
|
const canViewMessageTrace = computed(() => isRocketMqCluster.value && showBusinessQueryModes.value);
|
|
const traceTopicOptions = computed(() => buildRocketMqTraceTopicOptions(availableTopics.value));
|
|
const contextPlaceholder = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.connectionNotReady") : t("mqMessages.selectNamespaceOrTopicFirst")));
|
|
|
|
const selectedTopicRef = computed<TopicRef | null>(() => {
|
|
const topic = topicName.value.trim();
|
|
if (!topic || !props.tenant || !props.namespace) return null;
|
|
const selected = availableTopics.value.find((item) => item.shortName === topic);
|
|
return {
|
|
tenant: props.tenant,
|
|
namespace: props.namespace,
|
|
topic,
|
|
persistent: selected?.persistent ?? true,
|
|
partitioned: selected?.partitioned,
|
|
};
|
|
});
|
|
|
|
const detailPayload = computed(() => (selectedMessage.value ? rocketMqMessagePayload(selectedMessage.value) : ""));
|
|
const formattedDetailPayload = computed(() => formatRocketMqMessagePayload(detailPayload.value));
|
|
const resendTopic = computed(() => selectedMessage.value?.topic?.trim() || selectedTopicRef.value?.topic || topicName.value.trim());
|
|
const canResend = computed(() => !props.readOnly && !isDlqTopicSelected.value && !!resendTopic.value && !!detailPayload.value.trim());
|
|
|
|
const detailHeaders = computed(() => {
|
|
const headers = selectedMessage.value?.headers ?? {};
|
|
return Object.entries(headers).filter(([key]) => key !== "TAGS");
|
|
});
|
|
|
|
function pad2(value: number): string {
|
|
return String(value).padStart(2, "0");
|
|
}
|
|
|
|
function toDatetimeLocalValue(date: Date): string {
|
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
|
}
|
|
|
|
function defaultTimeRange(): { begin: string; end: string } {
|
|
const begin = new Date();
|
|
begin.setHours(0, 0, 0, 0);
|
|
const end = new Date(begin);
|
|
end.setDate(end.getDate() + 1);
|
|
return {
|
|
begin: toDatetimeLocalValue(begin),
|
|
end: toDatetimeLocalValue(end),
|
|
};
|
|
}
|
|
|
|
async function loadTopics() {
|
|
await topicSelectRef.value?.loadTopics();
|
|
}
|
|
|
|
function handleTopicsLoaded(topics: TopicInfo[]) {
|
|
availableTopics.value = topics;
|
|
noDlqTopics.value = !topics.some((topic) => resolveRocketMqMessageType(topic) === "DLQ");
|
|
if (props.preferDlqTopic) {
|
|
const firstDlq = topics.find((topic) => resolveRocketMqMessageType(topic) === "DLQ");
|
|
if (firstDlq) topicName.value = firstDlq.shortName;
|
|
} else if (props.topic && !topicName.value) {
|
|
topicName.value = props.topic.shortName;
|
|
} else if (!topicName.value && topics.length === 1) {
|
|
topicName.value = topics[0].shortName;
|
|
}
|
|
if (!traceTopicOptions.value.includes(traceTopic.value)) {
|
|
traceTopic.value = DEFAULT_ROCKETMQ_TRACE_TOPIC;
|
|
}
|
|
}
|
|
|
|
async function runQuery() {
|
|
const topic = selectedTopicRef.value;
|
|
if (!topic) {
|
|
queryError.value = t("mqMessages.selectTopicBeforeLoad");
|
|
return;
|
|
}
|
|
queryLoading.value = true;
|
|
queryError.value = undefined;
|
|
queryMessages.value = [];
|
|
selectedMessage.value = undefined;
|
|
try {
|
|
if (isDlqTopicSelected.value) {
|
|
if (dlqUseKeyQuery.value && queryKey.value.trim()) {
|
|
const end = Date.now();
|
|
const begin = end - 7 * 24 * 60 * 60 * 1000;
|
|
const result = await mqQueryMessagesByKey(props.connectionId, topic, queryKey.value.trim(), begin, end, 64);
|
|
queryMessages.value = parseRocketMqMessagesFromResult(result);
|
|
} else {
|
|
const group = consumerGroup.value.trim() || "__dbx_rocketmq_dlq__";
|
|
const peeked = await mqPeekMessages(props.connectionId, topic, group, 64);
|
|
const messages = Array.isArray(peeked) ? peeked : peeked.messages;
|
|
queryMessages.value = messages.map((msg) => rocketMqDisplayFromPeeked(msg, topic.topic));
|
|
}
|
|
} else if (activeQueryMode.value === "msgId") {
|
|
const id = queryMsgId.value.trim();
|
|
if (!id) throw new Error(t("mqMessages.msgIdRequired"));
|
|
const result = await mqViewMessage(props.connectionId, topic, id);
|
|
queryMessages.value = parseRocketMqMessagesFromResult(result);
|
|
} else if (activeQueryMode.value === "key") {
|
|
const key = queryKey.value.trim();
|
|
if (!key) throw new Error(t("mqMessages.queryKeyRequired"));
|
|
// Dashboard key query only needs topic + key; broker returns up to 64 recent matches.
|
|
// Do not validate hidden Topic-mode time fields — Key uses a fixed 0..now window.
|
|
const result = await mqQueryMessagesByKey(props.connectionId, topic, key, 0, Date.now(), 64);
|
|
queryMessages.value = parseRocketMqMessagesFromResult(result);
|
|
} else {
|
|
const begin = queryBeginTime.value ? Date.parse(queryBeginTime.value) : Date.parse(defaultTimeRange().begin);
|
|
const end = queryEndTime.value ? Date.parse(queryEndTime.value) : Date.parse(defaultTimeRange().end);
|
|
if (!Number.isFinite(begin) || !Number.isFinite(end)) {
|
|
throw new Error(t("mqMessages.invalidTimeRange"));
|
|
}
|
|
if (begin >= end) {
|
|
throw new Error(t("mqMessages.endTimeMustBeAfterBegin"));
|
|
}
|
|
const maxNum = Math.max(1, Math.min(200, Number(queryMaxNum.value) || 32));
|
|
const result = await mqQueryMessagesByTopic(props.connectionId, topic, begin, end, maxNum);
|
|
queryMessages.value = parseRocketMqMessagesFromResult(result);
|
|
}
|
|
} catch (e: unknown) {
|
|
queryError.value = formatError(e);
|
|
} finally {
|
|
queryLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function viewMessageDetails(message: RocketMqDisplayMessage) {
|
|
const topic = selectedTopicRef.value;
|
|
const msgId = message.messageId?.trim();
|
|
resendError.value = undefined;
|
|
resendSuccess.value = undefined;
|
|
payloadCopied.value = false;
|
|
if (isDlqTopicSelected.value || !topic || !msgId) {
|
|
selectedMessage.value = message;
|
|
detailError.value = undefined;
|
|
return;
|
|
}
|
|
detailLoading.value = true;
|
|
detailError.value = undefined;
|
|
try {
|
|
const result = await mqViewMessage(props.connectionId, topic, msgId);
|
|
const rows = parseRocketMqMessagesFromResult(result);
|
|
selectedMessage.value = rows[0] ?? message;
|
|
} catch (e: unknown) {
|
|
detailError.value = formatError(e);
|
|
selectedMessage.value = message;
|
|
} finally {
|
|
detailLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function closeDetails() {
|
|
selectedMessage.value = undefined;
|
|
detailError.value = undefined;
|
|
resendError.value = undefined;
|
|
resendSuccess.value = undefined;
|
|
payloadCopied.value = false;
|
|
}
|
|
|
|
function openTraceDialog(message: RocketMqDisplayMessage) {
|
|
const msgId = message.messageId?.trim();
|
|
if (!msgId) return;
|
|
traceMsgId.value = msgId;
|
|
traceDialogOpen.value = true;
|
|
}
|
|
|
|
function closeTraceDialog() {
|
|
traceDialogOpen.value = false;
|
|
traceMsgId.value = "";
|
|
}
|
|
|
|
async function copyPayload() {
|
|
const text = detailPayload.value;
|
|
if (!text) return;
|
|
try {
|
|
await copyToClipboard(text);
|
|
payloadCopied.value = true;
|
|
if (payloadCopiedTimer) clearTimeout(payloadCopiedTimer);
|
|
payloadCopiedTimer = setTimeout(() => {
|
|
payloadCopied.value = false;
|
|
}, 2000);
|
|
} catch (e: unknown) {
|
|
detailError.value = formatError(e);
|
|
}
|
|
}
|
|
|
|
function clearResendSuccessLater() {
|
|
if (resendSuccessTimer) clearTimeout(resendSuccessTimer);
|
|
resendSuccessTimer = setTimeout(() => {
|
|
resendSuccess.value = undefined;
|
|
}, 4000);
|
|
}
|
|
|
|
async function resendMessage() {
|
|
if (props.readOnly) {
|
|
resendError.value = t("mqMessages.readOnlyCannotSend");
|
|
return;
|
|
}
|
|
if (!(await confirmMqWrite(t("mqMessages.resendMessage")))) return;
|
|
const topic = resendTopic.value;
|
|
const payloadText = detailPayload.value.trim();
|
|
if (!topic) {
|
|
resendError.value = t("mqMessages.selectTargetTopic");
|
|
return;
|
|
}
|
|
if (!payloadText) {
|
|
resendError.value = t("mqMessages.messageContentRequired");
|
|
return;
|
|
}
|
|
|
|
resendLoading.value = true;
|
|
resendError.value = undefined;
|
|
resendSuccess.value = undefined;
|
|
try {
|
|
const message = selectedMessage.value;
|
|
const headers: Record<string, string> = { ...message?.headers };
|
|
if (isRocketMqCluster.value && message?.tag?.trim()) {
|
|
headers.TAGS = message.tag.trim();
|
|
}
|
|
const req: SendMessageRequest = {
|
|
topic,
|
|
key: message?.key?.trim() || undefined,
|
|
payloadBase64: btoa(unescape(encodeURIComponent(payloadText))),
|
|
payloadText,
|
|
headers,
|
|
};
|
|
resendSuccess.value = await mqSendMessage(props.connectionId, req);
|
|
clearResendSuccessLater();
|
|
} catch (e: unknown) {
|
|
resendError.value = formatError(e);
|
|
} finally {
|
|
resendLoading.value = false;
|
|
}
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
if (payloadCopiedTimer) clearTimeout(payloadCopiedTimer);
|
|
if (resendSuccessTimer) clearTimeout(resendSuccessTimer);
|
|
});
|
|
|
|
watch(
|
|
() => [props.tenant, props.namespace],
|
|
() => {
|
|
const range = defaultTimeRange();
|
|
queryBeginTime.value = range.begin;
|
|
queryEndTime.value = range.end;
|
|
queryMessages.value = [];
|
|
selectedMessage.value = undefined;
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(
|
|
() => props.topic,
|
|
(newTopic) => {
|
|
if (newTopic) {
|
|
topicName.value = newTopic.shortName;
|
|
}
|
|
},
|
|
);
|
|
|
|
watch(isDlqTopicSelected, (isDlq) => {
|
|
queryError.value = undefined;
|
|
queryMessages.value = [];
|
|
selectedMessage.value = undefined;
|
|
if (isDlq) {
|
|
dlqUseKeyQuery.value = false;
|
|
}
|
|
});
|
|
|
|
watch(activeQueryMode, () => {
|
|
queryError.value = undefined;
|
|
queryMessages.value = [];
|
|
selectedMessage.value = undefined;
|
|
});
|
|
|
|
watch(topicName, () => {
|
|
queryMessages.value = [];
|
|
selectedMessage.value = undefined;
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="message-query-panel">
|
|
<div v-if="!embedded" class="panel-toolbar">
|
|
<h3>{{ tracePanel ? t("mqTrace.title") : t("mqMessages.queryTitle") }}</h3>
|
|
<button type="button" class="btn-secondary" :disabled="topicsLoading || !tenant || !namespace" @click="loadTopics">
|
|
{{ topicsLoading ? t("mqMessages.querying") : t("mqMessages.refreshTopicList") }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="!tenant || !namespace" class="panel-placeholder">{{ contextPlaceholder }}</div>
|
|
|
|
<div v-else-if="!tracePanel && preferDlqTopic && noDlqTopics" class="panel-placeholder">{{ t("mqRocketmq.noDlqTopics") }}</div>
|
|
|
|
<div v-else class="query-content">
|
|
<div v-if="showBusinessQueryModes" class="query-mode-tabs">
|
|
<button type="button" class="query-tab" :class="{ active: activeQueryMode === 'topic' }" @click="activeQueryMode = 'topic'">
|
|
{{ t("mqMessages.queryTabTopic") }}
|
|
</button>
|
|
<button type="button" class="query-tab" :class="{ active: activeQueryMode === 'key' }" @click="activeQueryMode = 'key'">
|
|
{{ t("mqMessages.queryTabKey") }}
|
|
</button>
|
|
<button type="button" class="query-tab" :class="{ active: activeQueryMode === 'msgId' }" @click="activeQueryMode = 'msgId'">
|
|
{{ t("mqMessages.queryTabMsgId") }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="query-filters">
|
|
<label class="filter-field filter-topic">
|
|
<span>{{ t("mqMessages.queryTopicLabel") }}</span>
|
|
<RocketMqTopicSelect ref="topicSelectRef" v-model="topicName" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :disabled="queryLoading" :show-type-filter="false" @loaded="handleTopicsLoaded" @select="runQuery" />
|
|
</label>
|
|
|
|
<label v-if="tracePanel" class="filter-field">
|
|
<span>{{ t("mqTrace.traceTopic") }}</span>
|
|
<select v-model="traceTopic" :disabled="queryLoading">
|
|
<option v-for="item in traceTopicOptions" :key="item" :value="item">{{ item }}</option>
|
|
</select>
|
|
</label>
|
|
|
|
<template v-if="isDlqTopicSelected">
|
|
<label class="filter-field">
|
|
<span>{{ t("mqRocketmq.dlqConsumerGroup") }}</span>
|
|
<input v-model="consumerGroup" type="text" :placeholder="t('mqMessages.dlqConsumerGroupPlaceholder')" :disabled="queryLoading || dlqUseKeyQuery" />
|
|
</label>
|
|
<label class="filter-field checkbox-field">
|
|
<span class="checkbox-label">
|
|
<input v-model="dlqUseKeyQuery" type="checkbox" :disabled="queryLoading" />
|
|
{{ t("mqRocketmq.dlqQueryByKey") }}
|
|
</span>
|
|
</label>
|
|
<label v-if="dlqUseKeyQuery" class="filter-field">
|
|
<span>{{ t("mqMessages.queryKey") }}</span>
|
|
<input v-model="queryKey" type="text" :placeholder="t('mqMessages.queryKeyPlaceholder')" :disabled="queryLoading" />
|
|
</label>
|
|
</template>
|
|
|
|
<label v-else-if="activeQueryMode === 'msgId'" class="filter-field">
|
|
<span>{{ t("mqMessages.msgId") }}</span>
|
|
<input v-model="queryMsgId" type="text" :placeholder="t('mqMessages.msgIdPlaceholder')" :disabled="queryLoading" />
|
|
</label>
|
|
|
|
<label v-else-if="activeQueryMode === 'key'" class="filter-field">
|
|
<span>{{ t("mqMessages.queryKey") }}</span>
|
|
<input v-model="queryKey" type="text" :placeholder="t('mqMessages.queryKeyPlaceholder')" :disabled="queryLoading" />
|
|
</label>
|
|
|
|
<template v-else-if="activeQueryMode === 'topic'">
|
|
<label class="filter-field">
|
|
<span>{{ t("mqMessages.beginTime") }}</span>
|
|
<input v-model="queryBeginTime" type="datetime-local" :disabled="queryLoading" />
|
|
</label>
|
|
|
|
<label class="filter-field">
|
|
<span>{{ t("mqMessages.endTime") }}</span>
|
|
<input v-model="queryEndTime" type="datetime-local" :disabled="queryLoading" />
|
|
</label>
|
|
|
|
<label class="filter-field filter-narrow">
|
|
<span>{{ t("mqMessages.maxNum") }}</span>
|
|
<input v-model.number="queryMaxNum" type="number" min="1" max="200" :disabled="queryLoading" />
|
|
</label>
|
|
</template>
|
|
|
|
<div class="filter-actions">
|
|
<button type="button" class="btn-primary" :disabled="queryLoading || !selectedTopicRef" @click="runQuery">
|
|
{{ queryLoading ? t("mqMessages.querying") : t("mqMessages.query") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="queryError" class="panel-error">{{ queryError }}</div>
|
|
|
|
<div class="results-section">
|
|
<div v-if="queryLoading" class="results-empty">{{ t("mqMessages.messagesLoading") }}</div>
|
|
<div v-else-if="!queryMessages.length" class="results-empty">{{ t("mqMessages.noQueryResults") }}</div>
|
|
<div v-else class="results-table-wrap">
|
|
<table class="results-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t("mqMessages.tableMessageId") }}</th>
|
|
<th>{{ t("mqMessages.tableTag") }}</th>
|
|
<th>{{ t("mqMessages.tableKey") }}</th>
|
|
<th>{{ t("mqMessages.tableStoreTime") }}</th>
|
|
<th>{{ t("mqMessages.tableOperation") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(message, index) in queryMessages" :key="`${message.messageId ?? index}-${message.partition ?? 0}`">
|
|
<td class="mono">{{ message.messageId ?? "-" }}</td>
|
|
<td>{{ message.tag ?? "-" }}</td>
|
|
<td>{{ message.key ?? "-" }}</td>
|
|
<td>{{ formatRocketMqTimestamp(message.timestamp) }}</td>
|
|
<td class="operation-cell">
|
|
<button type="button" class="btn-sm" :disabled="detailLoading" @click="viewMessageDetails(message)">
|
|
{{ t("mqMessages.viewDetails") }}
|
|
</button>
|
|
<button v-if="canViewMessageTrace" type="button" class="btn-sm" :disabled="!message.messageId" @click="openTraceDialog(message)">
|
|
{{ t("mqTrace.viewTrace") }}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<RocketMqMessageDetailDialog
|
|
:message="selectedMessage"
|
|
:detail-loading="detailLoading"
|
|
:detail-error="detailError"
|
|
:resend-loading="resendLoading"
|
|
:resend-error="resendError"
|
|
:resend-success="resendSuccess"
|
|
:payload-copied="payloadCopied"
|
|
:resend-topic="resendTopic"
|
|
:formatted-detail-payload="formattedDetailPayload"
|
|
:detail-headers="detailHeaders"
|
|
:can-resend="canResend"
|
|
:can-view-trace="canViewMessageTrace"
|
|
:read-only="readOnly"
|
|
@close="closeDetails"
|
|
@copy="copyPayload"
|
|
@resend="resendMessage"
|
|
@view-trace="selectedMessage && openTraceDialog(selectedMessage)"
|
|
/>
|
|
|
|
<RocketMqTraceDetailDialog :open="traceDialogOpen" :connection-id="connectionId" :msg-id="traceMsgId" :trace-topic="traceTopic" @close="closeTraceDialog" />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@import "./shared/mqPanel.css";
|
|
|
|
.message-query-panel {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.panel-toolbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.panel-toolbar h3 {
|
|
margin: 0;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.panel-placeholder,
|
|
.results-empty {
|
|
padding: 40px 24px;
|
|
text-align: center;
|
|
color: var(--color-text-secondary);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.query-content {
|
|
flex: 1;
|
|
overflow: auto;
|
|
padding: 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.query-mode-tabs {
|
|
display: flex;
|
|
gap: 24px;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.query-tab {
|
|
padding: 0 0 10px;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
background: none;
|
|
color: var(--color-text-secondary);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.query-tab.active {
|
|
color: var(--color-primary);
|
|
border-bottom-color: var(--color-primary);
|
|
}
|
|
|
|
.query-filters {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
align-items: end;
|
|
}
|
|
|
|
.filter-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
flex: 1 1 200px;
|
|
min-width: 0;
|
|
max-width: 320px;
|
|
color: var(--color-text-secondary);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.filter-topic {
|
|
flex: 1 1 280px;
|
|
max-width: 360px;
|
|
}
|
|
|
|
.topic-select-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.topic-select-row select {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.spin {
|
|
display: inline-block;
|
|
animation: spin-anim 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin-anim {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.filter-narrow {
|
|
flex: 0 1 120px;
|
|
max-width: 120px;
|
|
}
|
|
|
|
.filter-field select,
|
|
.filter-field input {
|
|
width: 100%;
|
|
padding: 7px 10px;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
background: var(--color-background);
|
|
color: var(--color-text);
|
|
font-size: 13px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.filter-actions {
|
|
display: flex;
|
|
align-items: end;
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.checkbox-field {
|
|
flex: 0 1 auto;
|
|
max-width: none;
|
|
}
|
|
|
|
.checkbox-field .checkbox-label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 13px;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.panel-error {
|
|
padding: 10px 14px;
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
background: var(--color-error-bg);
|
|
color: var(--color-error);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.results-section {
|
|
flex: 1;
|
|
min-height: 240px;
|
|
}
|
|
|
|
.results-table-wrap {
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
overflow: auto;
|
|
}
|
|
|
|
.results-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.results-table th,
|
|
.results-table td {
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid var(--color-border);
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}
|
|
|
|
.results-table th {
|
|
background: var(--color-background-secondary);
|
|
color: var(--color-text-secondary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.results-table tbody tr:hover {
|
|
background: var(--color-hover, color-mix(in srgb, var(--color-primary) 6%, transparent));
|
|
}
|
|
|
|
.mono {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.operation-cell {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
|
|
.dialog-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0, 0, 0, 0.45);
|
|
}
|
|
|
|
.dialog {
|
|
width: min(720px, calc(100vw - 32px));
|
|
max-height: calc(100vh - 64px);
|
|
overflow: auto;
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
background: var(--color-background);
|
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.dialog-wide {
|
|
width: min(860px, calc(100vw - 32px));
|
|
}
|
|
|
|
.dialog-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.dialog-header h3 {
|
|
margin: 0;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.dialog-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 10px;
|
|
padding: 14px 16px;
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
|
|
.dialog-body {
|
|
padding: 16px 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 18px;
|
|
overflow: auto;
|
|
}
|
|
|
|
.detail-section h4,
|
|
.section-heading h4 {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.section-heading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.detail-grid {
|
|
display: grid;
|
|
grid-template-columns: 140px 1fr;
|
|
gap: 8px 12px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.detail-grid > span:nth-child(odd) {
|
|
color: var(--color-text-secondary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.panel-success {
|
|
padding: 10px 14px;
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
color: var(--color-primary);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.detail-payload {
|
|
margin: 0;
|
|
padding: 12px;
|
|
max-height: 360px;
|
|
overflow: auto;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
background: var(--color-background-secondary);
|
|
color: var(--color-text);
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
button:disabled,
|
|
input:disabled,
|
|
select:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
</style>
|