Files
dbx/apps/desktop/src/components/mq/RawApiPanel.vue
T
2f9ca313cb 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>
2026-08-06 01:12:31 +08:00

540 lines
14 KiB
Vue

<script setup lang="ts">
import { formatError } from "@/lib/backend/errorUtils";
import { computed, ref } from "vue";
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;
tenant?: string;
namespace?: string;
topic?: TopicInfo;
readOnly?: boolean;
}
const props = defineProps<Props>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const methods = ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"];
type RawApiPreset = {
label: string;
description: string;
method: string;
path: string;
query?: Record<string, string>;
body?: unknown;
unavailable?: boolean;
};
const method = ref("GET");
const path = ref("/admin/v2/");
const queryText = ref("");
const bodyText = ref("");
const response = ref<MqRawResponse>();
const loading = ref(false);
const error = ref<string>();
const presetsCollapsed = ref(true);
const readOnlyMessage = computed(() => t("mqRaw.readOnly"));
const isReadMethod = computed(() => method.value === "GET" || method.value === "HEAD" || method.value === "OPTIONS");
const requestDisabled = computed(() => loading.value || (props.readOnly && !isReadMethod.value));
const formattedBody = computed(() => JSON.stringify(response.value?.body ?? null, null, 2));
const bodyTextareaRows = computed(() => Math.min(24, Math.max(8, bodyText.value.split(/\r?\n/).length + 1)));
const topicDomain = computed(() => (props.topic?.persistent === false ? "non-persistent" : "persistent"));
const topicPath = computed(() => {
if (!props.tenant || !props.namespace || !props.topic) return undefined;
return `/admin/v2/${topicDomain.value}/${pathSegment(props.tenant)}/${pathSegment(props.namespace)}/${pathSegment(props.topic.shortName)}`;
});
const namespacePath = computed(() => {
if (!props.tenant || !props.namespace) return undefined;
return `/admin/v2/namespaces/${pathSegment(props.tenant)}/${pathSegment(props.namespace)}`;
});
const tenantPath = computed(() => {
if (!props.tenant) return undefined;
return `/admin/v2/tenants/${pathSegment(props.tenant)}`;
});
const presets = computed<RawApiPreset[]>(() => {
const tenant = tenantPath.value;
const namespace = namespacePath.value;
const topic = topicPath.value;
return [
{
label: t("mqRaw.presetBrokerVersion"),
description: t("mqRaw.presetBrokerVersionDesc"),
method: "GET",
path: "/admin/v2/brokers/version",
},
{
label: t("mqRaw.presetClusters"),
description: t("mqRaw.presetClustersDesc"),
method: "GET",
path: "/admin/v2/clusters",
},
{
label: t("mqRaw.presetTenant"),
description: t("mqRaw.presetTenantDesc"),
method: "GET",
path: tenant ?? "/admin/v2/tenants/{tenant}",
unavailable: !tenant,
},
{
label: t("mqRaw.presetNamespacePolicies"),
description: t("mqRaw.presetNamespacePoliciesDesc"),
method: "GET",
path: namespace ?? "/admin/v2/namespaces/{tenant}/{namespace}",
unavailable: !namespace,
},
{
label: t("mqRaw.presetBundles"),
description: t("mqRaw.presetBundlesDesc"),
method: "GET",
path: namespace ? `${namespace}/bundles` : "/admin/v2/namespaces/{tenant}/{namespace}/bundles",
unavailable: !namespace,
},
{
label: t("mqRaw.presetTopicInternalStats"),
description: t("mqRaw.presetTopicInternalStatsDesc"),
method: "GET",
path: topic ? `${topic}/internalStats` : "/admin/v2/persistent/{tenant}/{namespace}/{topic}/internalStats",
unavailable: !topic,
},
{
label: t("mqRaw.presetPartitionedStats"),
description: t("mqRaw.presetPartitionedStatsDesc"),
method: "GET",
path: topic ? `${topic}/partitioned-stats` : "/admin/v2/persistent/{tenant}/{namespace}/{topic}/partitioned-stats",
query: { perPartition: "true" },
unavailable: !topic,
},
{
label: t("mqRaw.presetSchema"),
description: t("mqRaw.presetSchemaDesc"),
method: "GET",
path: props.tenant && props.namespace && props.topic ? `/admin/v2/schemas/${pathSegment(props.tenant)}/${pathSegment(props.namespace)}/${pathSegment(props.topic.shortName)}/schema` : "/admin/v2/schemas/{tenant}/{namespace}/{topic}/schema",
unavailable: !topic,
},
];
});
function pathSegment(value: string) {
return encodeURIComponent(value);
}
function parseQuery() {
const query: Record<string, string> = {};
for (const rawLine of queryText.value.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const separator = line.indexOf("=");
if (separator === -1) {
query[line] = "";
continue;
}
query[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
}
return Object.keys(query).length ? query : undefined;
}
function parseBody() {
const text = bodyText.value.trim();
if (!text) return undefined;
return JSON.parse(text);
}
function formatQuery(query?: Record<string, string>) {
return query
? Object.entries(query)
.map(([key, value]) => `${key}=${value}`)
.join("\n")
: "";
}
function formatBody(body?: unknown) {
return body === undefined ? "" : JSON.stringify(body, null, 2);
}
function formatJsonBody() {
const text = bodyText.value.trim();
if (!text) return;
try {
bodyText.value = safeJsonFormat(text, 2);
error.value = undefined;
} catch (e: unknown) {
error.value = t("mqRaw.jsonBodyInvalid", { error: formatError(e) });
}
}
function applyPreset(preset: RawApiPreset) {
if (preset.unavailable) return;
method.value = preset.method;
path.value = preset.path;
queryText.value = formatQuery(preset.query);
bodyText.value = formatBody(preset.body);
response.value = undefined;
error.value = undefined;
}
async function executeRequest() {
error.value = undefined;
response.value = undefined;
if (props.readOnly && !isReadMethod.value) {
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;
}
loading.value = true;
try {
response.value = await mqRawRequest(props.connectionId, {
method: method.value,
path: path.value.trim(),
query: parseQuery(),
body: isReadMethod.value ? undefined : parseBody(),
});
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="raw-api-panel">
<div class="panel-toolbar">
<h3>{{ t("mqRaw.title") }}</h3>
<button @click="executeRequest" :disabled="requestDisabled" class="btn-primary">
{{ loading ? t("mqRaw.sending") : t("mqRaw.sendRequest") }}
</button>
</div>
<div class="raw-content">
<div v-if="readOnly" class="readonly-hint">{{ t("mqRaw.readOnlyHint") }}</div>
<div v-if="error" class="panel-error">{{ error }}</div>
<section class="preset-panel" :class="{ collapsed: presetsCollapsed }">
<button type="button" class="preset-header" @click="presetsCollapsed = !presetsCollapsed">
<h4>{{ t("mqRaw.commonEndpoints") }}</h4>
<span>{{ t("mqRaw.fillFromSelection") }}</span>
<span class="preset-toggle">{{ presetsCollapsed ? t("mqRaw.expand") : t("mqRaw.collapse") }}</span>
</button>
<div v-if="!presetsCollapsed" class="preset-grid">
<button v-for="preset in presets" :key="preset.label" type="button" class="preset-button" :disabled="preset.unavailable" @click="applyPreset(preset)">
<span class="preset-method">{{ preset.method }}</span>
<span class="preset-copy">
<strong>{{ preset.label }}</strong>
<small>{{ preset.description }}</small>
</span>
</button>
</div>
</section>
<section class="request-panel">
<div class="request-line">
<label>
{{ t("mqRaw.method") }}
<select v-model="method">
<option v-for="item in methods" :key="item" :value="item">{{ item }}</option>
</select>
</label>
<label class="path-field">
{{ t("mqRaw.path") }}
<input v-model="path" type="text" :placeholder="t('mqRaw.pathPlaceholder')" />
</label>
</div>
<label>
{{ t("mqRaw.queryParams") }}
<textarea v-model="queryText" rows="4" :placeholder="t('mqRaw.queryPlaceholder')"></textarea>
</label>
<label>
<span class="field-header">
<span>{{ t("mqRaw.jsonBody") }}</span>
<button type="button" class="btn-secondary compact" :disabled="isReadMethod || !bodyText.trim()" @click="formatJsonBody">{{ t("mqRaw.format") }}</button>
</span>
<textarea v-model="bodyText" class="json-body-textarea" :rows="bodyTextareaRows" :placeholder="t('mqRaw.bodyPlaceholder')" :disabled="isReadMethod"></textarea>
</label>
</section>
<section class="response-panel">
<h4>{{ t("mqRaw.response") }}</h4>
<div v-if="response" class="response-meta">
<span>HTTP {{ response.status }}</span>
<span v-if="response.text">{{ t("mqRaw.textResponse") }}</span>
</div>
<pre v-if="response">{{ response.text || formattedBody }}</pre>
<div v-else class="empty-state">{{ t("mqRaw.noRequestYet") }}</div>
</section>
</div>
</div>
</template>
<style scoped>
@import "./shared/mqPanel.css";
.raw-api-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;
}
.raw-content {
flex: 1;
overflow: auto;
padding: 16px;
}
.request-panel,
.preset-panel,
.response-panel {
padding: 14px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background-secondary);
}
.request-panel {
margin-top: 16px;
}
.response-panel {
margin-top: 16px;
}
.preset-header {
display: flex;
width: 100%;
gap: 12px;
align-items: center;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--color-text);
text-align: left;
cursor: pointer;
}
.preset-panel:not(.collapsed) .preset-header {
margin-bottom: 12px;
}
.preset-header h4 {
margin: 0;
}
.preset-header span {
color: var(--color-text-secondary);
font-size: 12px;
}
.preset-header span:nth-child(2) {
margin-left: auto;
}
.preset-toggle {
color: var(--color-primary) !important;
font-size: 12px;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 8px;
}
.preset-button {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
gap: 10px;
align-items: start;
min-height: 58px;
padding: 10px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
text-align: left;
cursor: pointer;
}
.preset-button:hover:not(:disabled) {
border-color: var(--color-primary);
}
.preset-method {
display: inline-flex;
justify-content: center;
padding: 2px 6px;
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-primary-alpha);
color: var(--color-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 11px;
font-weight: 600;
}
.preset-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.preset-copy strong {
font-size: 13px;
font-weight: 600;
}
.preset-copy small {
color: var(--color-text-secondary);
font-size: 12px;
line-height: 1.35;
}
.request-line {
display: grid;
grid-template-columns: 140px minmax(260px, 1fr);
gap: 12px;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
color: var(--color-text-secondary);
font-size: 13px;
}
.field-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
input,
select,
textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
box-sizing: border-box;
font: inherit;
}
textarea,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
}
textarea {
resize: vertical;
}
.json-body-textarea {
min-height: 180px;
max-height: 56vh;
}
textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
h4 {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
}
.response-meta {
display: flex;
gap: 8px;
margin-bottom: 8px;
color: var(--color-text-secondary);
font-size: 12px;
}
pre {
margin: 0;
padding: 12px;
max-height: 420px;
overflow: auto;
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
}
.empty-state {
padding: 24px;
color: var(--color-text-secondary);
text-align: center;
}
.panel-error,
.readonly-hint {
padding: 12px 16px;
margin-bottom: 12px;
border-radius: var(--dbx-radius-fixed-6);
font-size: 13px;
}
.panel-error {
background: var(--color-error-bg);
color: var(--color-error);
}
.readonly-hint {
background: var(--color-warning-alpha);
color: var(--color-warning);
}
.btn-secondary.compact {
padding: 3px 8px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 700px) {
.request-line {
grid-template-columns: 1fr;
}
}
</style>