* 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>
798 lines
29 KiB
Vue
798 lines
29 KiB
Vue
<script setup lang="ts">
|
||
import { formatError } from "@/lib/backend/errorUtils";
|
||
import { ref, computed, onMounted, watch } from "vue";
|
||
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,
|
||
isAllVhostsNamespace,
|
||
isFlatMqSystemKind,
|
||
normalizeMqTabForSystemKind,
|
||
RABBITMQ_ALL_VHOSTS,
|
||
RABBITMQ_MQ_TENANT,
|
||
resolveAvailableMqTabs,
|
||
resolveInitialMqTab,
|
||
resolveMqSystemKindFromConnection,
|
||
resolveRabbitMqDefaultVhost,
|
||
type MqTab,
|
||
} from "@/lib/mq/mqConsoleDefaults";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import type { AcceptableValue } from "reka-ui";
|
||
import TenantsPanel from "./TenantsPanel.vue";
|
||
import NamespacesPanel from "./NamespacesPanel.vue";
|
||
import TopicsPanel from "./TopicsPanel.vue";
|
||
import SubscriptionsPanel from "./SubscriptionsPanel.vue";
|
||
import MonitoringPanel from "./MonitoringPanel.vue";
|
||
import ProducerConsumerPanel from "./ProducerConsumerPanel.vue";
|
||
import PoliciesPanel from "./PoliciesPanel.vue";
|
||
import PermissionsPanel from "./PermissionsPanel.vue";
|
||
import RawApiPanel from "./RawApiPanel.vue";
|
||
import MessageTracePanel from "./MessageTracePanel.vue";
|
||
import RocketMqMessagesPanel from "./RocketMqMessagesPanel.vue";
|
||
import SendMessagePanel from "./SendMessagePanel.vue";
|
||
import MessageQueryPanel from "./MessageQueryPanel.vue";
|
||
import BrokerPanel from "./BrokerPanel.vue";
|
||
import RabbitMqClientsPanel from "./rabbitmq/RabbitMqClientsPanel.vue";
|
||
import RabbitMqPermissionsPanel from "./rabbitmq/RabbitMqPermissionsPanel.vue";
|
||
import RabbitMqPoliciesPanel from "./rabbitmq/RabbitMqPoliciesPanel.vue";
|
||
import RabbitMqMonitoringPanel from "./rabbitmq/RabbitMqMonitoringPanel.vue";
|
||
|
||
interface Props {
|
||
connectionId: string;
|
||
initialTenant?: string;
|
||
initialTab?: MqTab;
|
||
readOnly?: boolean;
|
||
}
|
||
|
||
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";
|
||
|
||
function normalizeFlatMqTenant(tenant: string | undefined, systemKind: MqSystemKind | undefined): string | undefined {
|
||
// RabbitMQ has no tenant concept: the console pins a synthetic tenant and
|
||
// exposes virtual hosts as namespaces instead.
|
||
if (systemKind === "rabbitmq") return tenant ? RABBITMQ_MQ_TENANT : undefined;
|
||
if (tenant === "_kafka" || tenant === FLAT_MQ_CONTEXT) return FLAT_MQ_CONTEXT;
|
||
return tenant;
|
||
}
|
||
|
||
// The connection's default virtual host acts as the initial RabbitMQ namespace.
|
||
const rabbitMqDefaultVhost = computed(() => resolveRabbitMqDefaultVhost(connectionStore.getConfig(props.connectionId)));
|
||
|
||
function initialMqNamespace(systemKind: MqSystemKind | undefined): string | undefined {
|
||
if (systemKind === "rabbitmq") return rabbitMqDefaultVhost.value;
|
||
return isFlatMqSystemKind(systemKind) ? FLAT_MQ_CONTEXT : undefined;
|
||
}
|
||
|
||
// State
|
||
const activeTab = ref<MqTab>(
|
||
resolveInitialMqTab({
|
||
initialTab: props.initialTab,
|
||
initialTenant: props.initialTenant,
|
||
systemKind: configuredSystemKind.value,
|
||
}),
|
||
);
|
||
const selectedTenant = ref<string | undefined>(normalizeFlatMqTenant(props.initialTenant, configuredSystemKind.value));
|
||
const selectedNamespace = ref<string | undefined>(initialMqNamespace(configuredSystemKind.value));
|
||
const selectedTopic = ref<TopicInfo>();
|
||
const selectedSubscriptionName = ref<string>();
|
||
const capabilities = ref<MqClusterInfo["capabilities"]>();
|
||
const clusterInfo = ref<MqClusterInfo>();
|
||
const loading = ref(false);
|
||
const error = ref<string>();
|
||
const preferDlqTopic = ref(props.initialTab === "dlq");
|
||
|
||
// RabbitMQ vhost switcher (tab-bar namespace dropdown).
|
||
const CREATE_NAMESPACE_VALUE = "__create_namespace__";
|
||
const rabbitMqVhosts = ref<string[]>([]);
|
||
const showCreateNamespaceDialog = ref(false);
|
||
const createNamespaceName = ref("");
|
||
const createNamespaceError = ref<string>();
|
||
const creatingNamespace = ref(false);
|
||
|
||
// Computed
|
||
const mqSystemKind = computed<MqSystemKind | undefined>(() => clusterInfo.value?.systemKind ?? configuredSystemKind.value);
|
||
const isFlatMqCluster = computed(() => isFlatMqSystemKind(mqSystemKind.value));
|
||
const isRabbitMqCluster = computed(() => mqSystemKind.value === "rabbitmq");
|
||
const isRocketMqCluster = computed(() => mqSystemKind.value === "rocketmq");
|
||
const rocketmqClusterLabel = computed(() => {
|
||
if (!isRocketMqCluster.value) return undefined;
|
||
const fromOptions = clusterOptions.value[0];
|
||
if (fromOptions) return fromOptions;
|
||
const config = connectionStore.getConfig(props.connectionId);
|
||
const external = config?.external_config as Partial<MqAdminConfig> | undefined;
|
||
const extra = external?.extra as Record<string, unknown> | undefined;
|
||
const clusterName = extra?.clusterName ?? extra?.cluster_name;
|
||
return typeof clusterName === "string" && clusterName.trim() ? clusterName.trim() : undefined;
|
||
});
|
||
const effectiveTenant = computed(() => {
|
||
if (isRabbitMqCluster.value) return RABBITMQ_MQ_TENANT;
|
||
return isFlatMqCluster.value ? normalizeFlatMqTenant(selectedTenant.value, mqSystemKind.value) || FLAT_MQ_CONTEXT : selectedTenant.value;
|
||
});
|
||
const effectiveNamespace = computed(() => {
|
||
if (isRabbitMqCluster.value) return selectedNamespace.value || rabbitMqDefaultVhost.value;
|
||
return isFlatMqCluster.value ? selectedNamespace.value || FLAT_MQ_CONTEXT : selectedNamespace.value;
|
||
});
|
||
const breadcrumbTenant = computed(() => (isFlatMqCluster.value ? undefined : selectedTenant.value));
|
||
const breadcrumbNamespace = computed(() => (isFlatMqCluster.value && !isRabbitMqCluster.value ? undefined : selectedNamespace.value));
|
||
const breadcrumbNamespaceLabel = computed(() => (isAllVhostsNamespace(breadcrumbNamespace.value) ? t("mqAdmin.allNamespaces") : breadcrumbNamespace.value));
|
||
const effectiveCapabilities = computed(() => capabilities.value ?? defaultMqCapabilitiesForSystemKind(configuredSystemKind.value));
|
||
const canManageTenants = computed(() => effectiveCapabilities.value.supportsTenants);
|
||
const canManageNamespaces = computed(() => effectiveCapabilities.value.supportsNamespaces);
|
||
const canManagePartitionedTopics = computed(() => effectiveCapabilities.value.supportsPartitionedTopics);
|
||
const canManageSubscriptions = computed(() => effectiveCapabilities.value.supportsSubscriptions);
|
||
const canCreateSubscription = computed(() => effectiveCapabilities.value.supportsCreateSubscription);
|
||
const canResetCursor = computed(() => effectiveCapabilities.value.supportsResetCursor);
|
||
const canSkipMessages = computed(() => effectiveCapabilities.value.supportsSkipMessages);
|
||
const canClearBacklog = computed(() => effectiveCapabilities.value.supportsClearBacklog);
|
||
const canPeekMessages = computed(() => effectiveCapabilities.value.supportsPeekMessages);
|
||
const canExpireMessages = computed(() => effectiveCapabilities.value.supportsExpireMessages);
|
||
const canManageRateLimits = computed(() => effectiveCapabilities.value.supportsRateLimits);
|
||
const canManageBacklogQuota = computed(() => effectiveCapabilities.value.supportsBacklogQuota);
|
||
const canManageRetention = computed(() => effectiveCapabilities.value.supportsRetention);
|
||
const canManagePolicies = computed(() => {
|
||
return canManageRateLimits.value || canManageBacklogQuota.value || canManageRetention.value;
|
||
});
|
||
const canManagePermissions = computed(() => effectiveCapabilities.value.supportsPermissions);
|
||
const canManageUserPermissions = computed(() => effectiveCapabilities.value.supportsUserPermissions ?? false);
|
||
const canManageRabbitMqPolicies = computed(() => effectiveCapabilities.value.supportsPolicies ?? false);
|
||
const canClusterMonitor = computed(() => effectiveCapabilities.value.supportsClusterMonitoring ?? false);
|
||
const canSendMessage = computed(() => effectiveCapabilities.value.supportsSendMessage ?? false);
|
||
const canManageExchanges = computed(() => effectiveCapabilities.value.supportsExchanges ?? false);
|
||
const canManageClientConnections = computed(() => effectiveCapabilities.value.supportsClientConnections ?? false);
|
||
const canMessageQuery = computed(() => effectiveCapabilities.value.supportsMessageQuery ?? false);
|
||
const canMessageTrace = computed(() => effectiveCapabilities.value.supportsMessageTrace ?? false);
|
||
const canUseRawApi = computed(() => effectiveCapabilities.value.supportsRawAdminApi);
|
||
const clusterOptions = computed(() => mqClusterOptionsFromExtra(clusterInfo.value?.extra));
|
||
const availableTabs = computed<MqTab[]>(() =>
|
||
resolveAvailableMqTabs({
|
||
systemKind: mqSystemKind.value,
|
||
capabilities: effectiveCapabilities.value,
|
||
}),
|
||
);
|
||
|
||
function tabLabelKey(tab: MqTab): string {
|
||
if (isRocketMqCluster.value) {
|
||
if (tab === "broker") return "mqAdmin.tabCluster";
|
||
if (tab === "subscriptions") return "mqAdmin.tabConsumers";
|
||
if (tab === "permissions") return "mqAdmin.tabAcl";
|
||
}
|
||
const defaults: Record<MqTab, string> = {
|
||
tenants: "mqAdmin.tabTenants",
|
||
namespaces: "mqAdmin.tabNamespaces",
|
||
topics: "mqAdmin.tabTopics",
|
||
subscriptions: "mqAdmin.tabSubscriptions",
|
||
monitoring: "mqAdmin.tabMonitoring",
|
||
clients: "mqAdmin.tabClients",
|
||
producers: "mqAdmin.tabProducers",
|
||
policies: "mqAdmin.tabPolicies",
|
||
permissions: "mqAdmin.tabPermissions",
|
||
messages: "mqAdmin.tabMessages",
|
||
raw: "mqAdmin.tabRawApi",
|
||
broker: "mqAdmin.tabBroker",
|
||
dlq: "mqAdmin.tabDlq",
|
||
trace: "mqAdmin.tabTrace",
|
||
};
|
||
return defaults[tab];
|
||
}
|
||
|
||
// Methods
|
||
async function loadClusterInfo() {
|
||
loading.value = true;
|
||
error.value = undefined;
|
||
try {
|
||
clusterInfo.value = await mqTestConnection(props.connectionId);
|
||
capabilities.value = clusterInfo.value.capabilities;
|
||
reconcileActiveTab();
|
||
} catch (e: unknown) {
|
||
error.value = formatError(e);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadRabbitMqVhosts() {
|
||
try {
|
||
const namespaces = await mqListNamespaces(props.connectionId, RABBITMQ_MQ_TENANT);
|
||
rabbitMqVhosts.value = namespaces.map((ns) => ns.namespace);
|
||
} catch (e: unknown) {
|
||
console.warn("[DBX] Failed to load RabbitMQ vhosts:", e);
|
||
}
|
||
}
|
||
|
||
function switchNamespace(namespace: string) {
|
||
selectedNamespace.value = namespace;
|
||
selectedTopic.value = undefined;
|
||
selectedSubscriptionName.value = undefined;
|
||
}
|
||
|
||
function handleNamespaceSelect(value: AcceptableValue) {
|
||
if (typeof value !== "string") return;
|
||
if (value === CREATE_NAMESPACE_VALUE) {
|
||
if (props.readOnly) return;
|
||
createNamespaceName.value = "";
|
||
createNamespaceError.value = undefined;
|
||
showCreateNamespaceDialog.value = true;
|
||
return;
|
||
}
|
||
switchNamespace(value);
|
||
}
|
||
|
||
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");
|
||
return;
|
||
}
|
||
creatingNamespace.value = true;
|
||
createNamespaceError.value = undefined;
|
||
try {
|
||
const ns: NamespaceRef = { tenant: RABBITMQ_MQ_TENANT, namespace: name };
|
||
await mqCreateNamespace(props.connectionId, ns, {});
|
||
showCreateNamespaceDialog.value = false;
|
||
await loadRabbitMqVhosts();
|
||
switchNamespace(name);
|
||
} catch (e: unknown) {
|
||
createNamespaceError.value = formatError(e);
|
||
} finally {
|
||
creatingNamespace.value = false;
|
||
}
|
||
}
|
||
|
||
function selectTenant(tenant: string) {
|
||
selectedTenant.value = tenant;
|
||
selectedNamespace.value = initialMqNamespace(mqSystemKind.value);
|
||
selectedTopic.value = undefined;
|
||
selectedSubscriptionName.value = undefined;
|
||
if (canManageNamespaces.value) {
|
||
activeTab.value = "namespaces";
|
||
} else {
|
||
activeTab.value = "topics";
|
||
}
|
||
}
|
||
|
||
function handleTenantSelected(tenant: string) {
|
||
selectTenant(tenant);
|
||
}
|
||
|
||
function handleNamespaceSelected(namespace: string) {
|
||
selectedNamespace.value = namespace;
|
||
selectedTopic.value = undefined;
|
||
selectedSubscriptionName.value = undefined;
|
||
activeTab.value = "topics";
|
||
}
|
||
|
||
function handleNamespaceRolesSelected(namespace: string) {
|
||
selectedNamespace.value = namespace;
|
||
selectedTopic.value = undefined;
|
||
selectedSubscriptionName.value = undefined;
|
||
activeTab.value = canManagePermissions.value ? "permissions" : "namespaces";
|
||
}
|
||
|
||
function handleTopicSelected(topic: TopicInfo) {
|
||
selectedTopic.value = topic;
|
||
selectedSubscriptionName.value = undefined;
|
||
if (isRocketMqCluster.value) {
|
||
activeTab.value = canManageSubscriptions.value ? "subscriptions" : "topics";
|
||
} else {
|
||
activeTab.value = isFlatMqCluster.value ? "monitoring" : canManageSubscriptions.value ? "subscriptions" : "monitoring";
|
||
}
|
||
}
|
||
|
||
function handleNavigateTab(payload: { tab: MqTab; topic?: TopicInfo; subscription?: string; preferDlqTopic?: boolean }) {
|
||
if (payload.topic) {
|
||
selectedTopic.value = payload.topic;
|
||
}
|
||
if (payload.subscription) {
|
||
selectedSubscriptionName.value = payload.subscription;
|
||
} else if (payload.topic) {
|
||
selectedSubscriptionName.value = undefined;
|
||
}
|
||
if (payload.preferDlqTopic !== undefined) {
|
||
preferDlqTopic.value = payload.preferDlqTopic;
|
||
}
|
||
setActiveTab(payload.tab);
|
||
}
|
||
|
||
function handleSubscriptionSelected(subscription: string) {
|
||
selectedSubscriptionName.value = subscription;
|
||
activeTab.value = isRocketMqCluster.value ? "producers" : "clients";
|
||
}
|
||
|
||
function handleProducerTopicSelected(topic: TopicInfo | undefined) {
|
||
selectedTopic.value = topic;
|
||
selectedSubscriptionName.value = undefined;
|
||
}
|
||
|
||
function goToTenantLevel() {
|
||
selectedNamespace.value = undefined;
|
||
selectedTopic.value = undefined;
|
||
selectedSubscriptionName.value = undefined;
|
||
activeTab.value = canManageTenants.value ? "tenants" : firstAvailableTab();
|
||
}
|
||
|
||
function goToNamespaceLevel() {
|
||
selectedTopic.value = undefined;
|
||
selectedSubscriptionName.value = undefined;
|
||
activeTab.value = canManageNamespaces.value ? "namespaces" : "topics";
|
||
}
|
||
|
||
function goToTopicLevel() {
|
||
selectedSubscriptionName.value = undefined;
|
||
activeTab.value = "topics";
|
||
}
|
||
|
||
function setActiveTab(tab: MqTab) {
|
||
const normalized = normalizeMqTabForSystemKind(tab, mqSystemKind.value);
|
||
activeTab.value = availableTabs.value.includes(normalized) ? normalized : firstAvailableTab();
|
||
}
|
||
|
||
function firstAvailableTab(): MqTab {
|
||
return availableTabs.value[0] ?? "topics";
|
||
}
|
||
|
||
function reconcileActiveTab() {
|
||
if (!availableTabs.value.includes(activeTab.value)) {
|
||
activeTab.value = firstAvailableTab();
|
||
}
|
||
}
|
||
|
||
watch(availableTabs, reconcileActiveTab);
|
||
watch(
|
||
isRabbitMqCluster,
|
||
(isRabbitMq) => {
|
||
if (isRabbitMq) void loadRabbitMqVhosts();
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
watch(
|
||
() => props.initialTenant,
|
||
(tenant) => {
|
||
const normalized = normalizeFlatMqTenant(tenant, mqSystemKind.value);
|
||
if (normalized && normalized !== selectedTenant.value) {
|
||
selectTenant(normalized);
|
||
}
|
||
},
|
||
);
|
||
watch(
|
||
() => props.initialTab,
|
||
(tab) => {
|
||
if (tab) {
|
||
if (tab === "dlq") preferDlqTopic.value = true;
|
||
setActiveTab(tab);
|
||
}
|
||
},
|
||
);
|
||
|
||
// Lifecycle
|
||
onMounted(async () => {
|
||
try {
|
||
await connectionStore.ensureConnected(props.connectionId);
|
||
} catch (e) {
|
||
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
|
||
}
|
||
loadClusterInfo();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="mq-admin-console">
|
||
<!-- Top Toolbar -->
|
||
<div class="mq-toolbar">
|
||
<div class="mq-breadcrumb">
|
||
<span v-if="mqSystemKind" class="cluster-info">
|
||
{{ isRocketMqCluster && rocketmqClusterLabel ? rocketmqClusterLabel : mqSystemKind.toUpperCase() }}
|
||
{{ clusterInfo?.serverVersion ? ` ${clusterInfo.serverVersion}` : "" }}
|
||
</span>
|
||
<span v-if="breadcrumbTenant" class="breadcrumb-separator">›</span>
|
||
<button v-if="breadcrumbTenant" class="breadcrumb-button" @click="goToTenantLevel" :title="t('mqAdmin.viewTenant')">{{ breadcrumbTenant }}</button>
|
||
<span v-if="breadcrumbNamespace" class="breadcrumb-separator">›</span>
|
||
<button v-if="breadcrumbNamespace" class="breadcrumb-button" @click="goToNamespaceLevel" :title="t('mqAdmin.viewNamespace')">{{ breadcrumbNamespaceLabel }}</button>
|
||
<span v-if="selectedTopic" class="breadcrumb-separator">›</span>
|
||
<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>
|
||
</div>
|
||
|
||
<!-- Tab Bar -->
|
||
<div class="mq-tabs">
|
||
<div class="mq-tabs-list">
|
||
<button v-for="tab in availableTabs" :key="tab" :class="{ active: activeTab === tab }" @click="setActiveTab(tab)">
|
||
{{ t(tabLabelKey(tab)) }}
|
||
</button>
|
||
</div>
|
||
<div v-if="isRabbitMqCluster" class="mq-namespace-switcher">
|
||
<Select :model-value="selectedNamespace" @update:model-value="handleNamespaceSelect">
|
||
<SelectTrigger class="h-7 w-[180px] rounded-lg text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem :value="RABBITMQ_ALL_VHOSTS">{{ t("mqAdmin.allNamespaces") }}</SelectItem>
|
||
<SelectItem v-for="vhost in rabbitMqVhosts" :key="vhost" :value="vhost">{{ vhost }}</SelectItem>
|
||
<SelectItem :value="CREATE_NAMESPACE_VALUE" :disabled="readOnly">+ {{ t("mqAdmin.newNamespace") }}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content Area -->
|
||
<div class="mq-content">
|
||
<TenantsPanel v-if="activeTab === 'tenants'" :connection-id="connectionId" :supports-tenants="canManageTenants" :read-only="readOnly" :cluster-options="clusterOptions" @tenant-selected="handleTenantSelected" />
|
||
<NamespacesPanel
|
||
v-else-if="activeTab === 'namespaces'"
|
||
:connection-id="connectionId"
|
||
:tenant="effectiveTenant"
|
||
:supports-namespaces="canManageNamespaces"
|
||
:supports-permissions="canManagePermissions"
|
||
:read-only="readOnly"
|
||
@namespace-selected="handleNamespaceSelected"
|
||
@namespace-roles-selected="handleNamespaceRolesSelected"
|
||
/>
|
||
<TopicsPanel
|
||
v-else-if="activeTab === 'topics'"
|
||
:connection-id="connectionId"
|
||
:tenant="effectiveTenant"
|
||
:namespace="effectiveNamespace"
|
||
:read-only="readOnly"
|
||
:supports-partitioned-topics="canManagePartitionedTopics"
|
||
:is-flat-mq-cluster="isFlatMqCluster"
|
||
:mq-system-kind="mqSystemKind"
|
||
:supports-exchanges="canManageExchanges"
|
||
@topic-selected="handleTopicSelected"
|
||
@navigate-tab="handleNavigateTab"
|
||
/>
|
||
<SubscriptionsPanel
|
||
v-else-if="activeTab === 'subscriptions' && canManageSubscriptions"
|
||
:connection-id="connectionId"
|
||
:topic="selectedTopic"
|
||
:tenant="effectiveTenant"
|
||
:namespace="effectiveNamespace"
|
||
:read-only="readOnly"
|
||
:mq-system-kind="mqSystemKind"
|
||
:is-flat-mq-cluster="isFlatMqCluster"
|
||
:supports-create-subscription="canCreateSubscription"
|
||
:supports-reset-cursor="canResetCursor"
|
||
:supports-skip-messages="canSkipMessages"
|
||
:supports-clear-backlog="canClearBacklog"
|
||
:supports-peek-messages="canPeekMessages"
|
||
:supports-expire-messages="canExpireMessages"
|
||
@subscription-selected="handleSubscriptionSelected"
|
||
/>
|
||
<RabbitMqMonitoringPanel v-else-if="activeTab === 'monitoring' && isRabbitMqCluster && canClusterMonitor" :connection-id="connectionId" />
|
||
<MonitoringPanel v-else-if="activeTab === 'monitoring'" :connection-id="connectionId" :topic="selectedTopic" :tenant="effectiveTenant" :namespace="effectiveNamespace" :mq-system-kind="mqSystemKind" />
|
||
<RabbitMqClientsPanel v-else-if="activeTab === 'clients' && isRabbitMqCluster && canManageClientConnections" :connection-id="connectionId" :namespace="effectiveNamespace" :read-only="readOnly" />
|
||
<ProducerConsumerPanel
|
||
v-else-if="activeTab === 'clients'"
|
||
:connection-id="connectionId"
|
||
:topic="selectedTopic"
|
||
:tenant="effectiveTenant"
|
||
:namespace="effectiveNamespace"
|
||
:read-only="readOnly"
|
||
:selected-subscription="selectedSubscriptionName"
|
||
:is-flat-mq-cluster="isFlatMqCluster"
|
||
:mq-system-kind="mqSystemKind"
|
||
/>
|
||
<ProducerConsumerPanel
|
||
v-else-if="activeTab === 'producers'"
|
||
view-mode="producers"
|
||
:connection-id="connectionId"
|
||
:topic="selectedTopic"
|
||
:tenant="effectiveTenant"
|
||
:namespace="effectiveNamespace"
|
||
:read-only="readOnly"
|
||
:selected-subscription="selectedSubscriptionName"
|
||
:is-flat-mq-cluster="isFlatMqCluster"
|
||
:mq-system-kind="mqSystemKind"
|
||
@topic-selected="handleProducerTopicSelected"
|
||
/>
|
||
<RocketMqMessagesPanel
|
||
v-else-if="activeTab === 'messages' && isRocketMqCluster && (canMessageQuery || canSendMessage)"
|
||
:connection-id="connectionId"
|
||
:tenant="effectiveTenant"
|
||
:namespace="effectiveNamespace"
|
||
:topic="selectedTopic"
|
||
:read-only="readOnly"
|
||
:mq-system-kind="mqSystemKind"
|
||
:prefer-dlq-topic="preferDlqTopic"
|
||
/>
|
||
<MessageTracePanel v-else-if="activeTab === 'trace' && isRocketMqCluster && canMessageTrace" :connection-id="connectionId" :tenant="effectiveTenant" :namespace="effectiveNamespace" :topic="selectedTopic" :read-only="readOnly" :mq-system-kind="mqSystemKind" />
|
||
<MessageQueryPanel v-else-if="activeTab === 'messages' && canMessageQuery && !isRocketMqCluster" :connection-id="connectionId" :tenant="effectiveTenant" :namespace="effectiveNamespace" :topic="selectedTopic" :read-only="readOnly" :mq-system-kind="mqSystemKind" />
|
||
<SendMessagePanel
|
||
v-else-if="activeTab === 'messages' && canSendMessage && !isRocketMqCluster && !canMessageQuery"
|
||
:connection-id="connectionId"
|
||
:tenant="effectiveTenant"
|
||
:namespace="effectiveNamespace"
|
||
:topic="selectedTopic"
|
||
:read-only="readOnly"
|
||
:mq-system-kind="mqSystemKind"
|
||
:is-flat-mq-cluster="isFlatMqCluster"
|
||
:supports-peek-messages="canPeekMessages"
|
||
/>
|
||
<BrokerPanel v-else-if="activeTab === 'broker'" :connection-id="connectionId" :read-only="readOnly" :mq-system-kind="mqSystemKind" />
|
||
<RabbitMqPoliciesPanel v-else-if="activeTab === 'policies' && isRabbitMqCluster && canManageRabbitMqPolicies" :connection-id="connectionId" :namespace="effectiveNamespace" :read-only="readOnly" />
|
||
<PoliciesPanel
|
||
v-else-if="activeTab === 'policies' && canManagePolicies"
|
||
:connection-id="connectionId"
|
||
:topic="selectedTopic"
|
||
:tenant="effectiveTenant"
|
||
:namespace="effectiveNamespace"
|
||
:read-only="readOnly"
|
||
:is-flat-mq-cluster="isFlatMqCluster"
|
||
:supports-rate-limits="canManageRateLimits"
|
||
:supports-backlog-quota="canManageBacklogQuota"
|
||
:supports-retention="canManageRetention"
|
||
/>
|
||
<RabbitMqPermissionsPanel v-else-if="activeTab === 'permissions' && isRabbitMqCluster && canManageUserPermissions" :connection-id="connectionId" :namespace="effectiveNamespace" :read-only="readOnly" />
|
||
<PermissionsPanel v-else-if="activeTab === 'permissions' && canManagePermissions" :connection-id="connectionId" :topic="selectedTopic" :tenant="effectiveTenant" :namespace="effectiveNamespace" :read-only="readOnly" :mq-system-kind="mqSystemKind" />
|
||
<RawApiPanel v-else-if="activeTab === 'raw' && canUseRawApi" :connection-id="connectionId" :tenant="selectedTenant" :namespace="selectedNamespace" :topic="selectedTopic" :read-only="readOnly" />
|
||
</div>
|
||
|
||
<!-- Create Namespace (vhost) Dialog -->
|
||
<div v-if="showCreateNamespaceDialog" class="dialog-overlay" @click="showCreateNamespaceDialog = false">
|
||
<div class="dialog" @click.stop>
|
||
<div class="dialog-header">
|
||
<h3>{{ t("mqAdmin.newNamespace") }}</h3>
|
||
<button @click="showCreateNamespaceDialog = false" class="btn-close">×</button>
|
||
</div>
|
||
<div class="dialog-body">
|
||
<div class="form-group">
|
||
<label>{{ t("mqNamespaces.namespaceName") }}</label>
|
||
<input v-model="createNamespaceName" type="text" :placeholder="t('mqNamespaces.namespaceNamePlaceholder')" :disabled="readOnly" />
|
||
</div>
|
||
<div v-if="createNamespaceError" class="form-error">{{ createNamespaceError }}</div>
|
||
</div>
|
||
<div class="dialog-footer">
|
||
<button @click="showCreateNamespaceDialog = false" class="btn-secondary">{{ t("mqNamespaces.cancel") }}</button>
|
||
<button @click="handleCreateNamespace" :disabled="creatingNamespace || readOnly" class="btn-primary">{{ t("mqNamespaces.create") }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
@import "./shared/mqPanel.css";
|
||
|
||
.mq-admin-console {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
background: var(--color-background);
|
||
}
|
||
|
||
.mq-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px 16px;
|
||
border-bottom: 1px solid var(--color-border);
|
||
background: var(--color-background-secondary);
|
||
}
|
||
|
||
.mq-breadcrumb {
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: 14px;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.cluster-info {
|
||
font-weight: 600;
|
||
color: var(--color-primary);
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.breadcrumb-separator {
|
||
margin: 0 8px;
|
||
color: var(--color-text-tertiary);
|
||
}
|
||
|
||
.breadcrumb-item {
|
||
color: var(--color-text);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.breadcrumb-button {
|
||
border: none;
|
||
border-radius: var(--dbx-radius-fixed-4);
|
||
background: transparent;
|
||
color: var(--color-text);
|
||
cursor: pointer;
|
||
font: inherit;
|
||
font-weight: 500;
|
||
padding: 2px 4px;
|
||
}
|
||
|
||
.breadcrumb-button:hover {
|
||
background: var(--color-hover);
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
.toolbar-error {
|
||
color: var(--color-error);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.toolbar-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.readonly-badge {
|
||
padding: 2px 8px;
|
||
border: 1px solid var(--color-warning);
|
||
border-radius: var(--dbx-radius-fixed-4);
|
||
color: var(--color-warning);
|
||
font-size: 12px;
|
||
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;
|
||
border-bottom: 1px solid var(--color-border);
|
||
background: var(--color-background-secondary);
|
||
}
|
||
|
||
.mq-tabs-list {
|
||
display: flex;
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.mq-tabs-list button {
|
||
padding: 10px 20px;
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
color: var(--color-text-secondary);
|
||
border-bottom: 2px solid transparent;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.mq-tabs-list button:hover {
|
||
color: var(--color-text);
|
||
background: var(--color-hover);
|
||
}
|
||
|
||
.mq-tabs-list button.active {
|
||
color: var(--color-primary);
|
||
border-bottom-color: var(--color-primary);
|
||
background: var(--color-background);
|
||
}
|
||
|
||
.mq-namespace-switcher {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 4px 12px;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.mq-content {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.mq-content :deep(table) {
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
.mq-content :deep(thead th) {
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.mq-content :deep(tbody td) {
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.mq-content :deep(tbody tr:last-child td) {
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
/* Create namespace dialog */
|
||
.dialog-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.dialog {
|
||
background: var(--color-background);
|
||
border-radius: var(--dbx-radius-fixed-6);
|
||
width: 90%;
|
||
max-width: 500px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.dialog-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.dialog-header h3 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.dialog-body {
|
||
padding: 20px;
|
||
}
|
||
|
||
.dialog-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
padding: 16px 20px;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
margin-bottom: 6px;
|
||
font-weight: 500;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.form-group input[type="text"] {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--dbx-radius-fixed-4);
|
||
font-size: 14px;
|
||
box-sizing: border-box;
|
||
background: var(--color-background);
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.form-error {
|
||
color: var(--color-error);
|
||
font-size: 13px;
|
||
}
|
||
|
||
button:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
</style>
|