Files
dbx/apps/desktop/src/components/mq/TopicsPanel.vue
T

1296 lines
45 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { useI18n } from "vue-i18n";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import type { NamespaceRef, TopicRef, TopicInfo, ListTopicsOpts, MqSystemKind, RocketMqTopicMessageType } from "@/types/mq";
import { mqListTopics, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions, mqGetClusterInfo } from "@/lib/backend/api";
import type { ClusterInfo } from "@/types/mq";
import RocketMqTopicDialogs, { type RocketMqTopicDialogKind } from "./rocketmq/RocketMqTopicDialogs.vue";
import SendMessagePanel from "./SendMessagePanel.vue";
import ExchangesPanel from "./ExchangesPanel.vue";
import MqTypeFilterBar from "./shared/MqTypeFilterBar.vue";
import type { MqTab } from "@/lib/mq/mqConsoleDefaults";
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
import { formatMqRateCompact, rabbitMqArgumentsSummary, rabbitMqFeatureBadges, rabbitMqQueueType, type RabbitMqFeatureBadge } from "@/lib/mq/rabbitmqQueueFeatures";
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;
type VirtualTopicRow = {
id: string;
topic: TopicInfo;
};
interface Props {
connectionId: string;
tenant?: string;
namespace?: string;
readOnly?: boolean;
supportsPartitionedTopics?: boolean;
isFlatMqCluster?: boolean;
mqSystemKind?: MqSystemKind;
supportsExchanges?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
topicSelected: [topic: TopicInfo];
navigateTab: [payload: { tab: MqTab; topic?: TopicInfo; subscription?: string; preferDlqTopic?: boolean }];
}>();
const { t } = useI18n();
const topics = ref<TopicInfo[]>([]);
const loading = ref(false);
const error = ref<string>();
const dialogError = ref<string>();
const showCreateDialog = ref(false);
const showPartitionsDialog = ref(false);
const selectedTopic = ref<TopicInfo>();
const editingTopic = ref<TopicInfo>();
const topicSearch = ref("");
const deleteTarget = ref<TopicInfo>();
const showDeleteDialog = ref(false);
const deleting = ref(false);
const clusterInfo = ref<ClusterInfo>();
const activeRocketMqDialog = ref<RocketMqTopicDialogKind | null>(null);
const rocketMqDialogTopic = ref<TopicInfo>();
const showSendDialog = ref(false);
const sendDialogTopic = ref<TopicInfo>();
const ROCKETMQ_PERM_OPTIONS = [
{ value: 6, labelKey: "mqTopics.permReadWrite" },
{ value: 4, labelKey: "mqTopics.permRead" },
{ value: 2, labelKey: "mqTopics.permWrite" },
] as const;
const formData = ref({
topicName: "",
persistent: true,
partitioned: false,
partitions: 4,
messageType: "NORMAL" as RocketMqTopicMessageType,
brokerName: "",
readQueueNums: 8,
writeQueueNums: 8,
perm: 6,
});
const newPartitions = ref(4);
const includeNonPersistent = ref(false);
const includeSystemTopics = ref(false);
const messageTypeFilters = ref<Record<RocketMqTopicMessageType, boolean>>({
...DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS,
});
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
const isKafkaCluster = computed(() => props.mqSystemKind === "kafka");
const isRabbitMqCluster = computed(() => props.mqSystemKind === "rabbitmq");
// RabbitMQ "all vhosts" mode: rows carry their own vhost in `namespace`.
const showNamespaceColumn = computed(() => isRabbitMqCluster.value && isAllVhostsNamespace(props.namespace));
// RabbitMQ splits the topics tab into Queues (topics table) and Exchanges views.
const showRabbitMqSubTabs = computed(() => isRabbitMqCluster.value && props.supportsExchanges === true);
const rabbitMqSubTab = ref<"queues" | "exchanges">("queues");
const rabbitMqMessageSort = ref<"asc" | "desc" | null>(null);
const rocketMqTopicTypeOptions = ROCKETMQ_TOPIC_MESSAGE_TYPES;
const rocketMqCreatableTopicTypes = ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES;
const rocketMqClusterName = computed(() => clusterInfo.value?.clusterId ?? "-");
const rocketMqBrokerOptions = computed(() => clusterInfo.value?.brokers ?? []);
const rocketMqMasterBrokers = computed(() => rocketMqBrokerOptions.value.filter((broker) => !broker.role || broker.role === "MASTER"));
const typeFilteredTopics = computed(() => {
let rows = topics.value;
if (isRocketMqCluster.value) {
rows = rows.filter((topic) => matchesRocketMqTypeFilters(topic, messageTypeFilters.value));
} else if (props.isFlatMqCluster && !includeSystemTopics.value) {
rows = rows.filter((topic) => !topic.internal);
}
return rows;
});
const filteredTopics = computed(() => {
const query = topicSearch.value.trim().toLowerCase();
if (!query) return typeFilteredTopics.value;
return typeFilteredTopics.value.filter((topic) => {
return topic.name.toLowerCase().includes(query) || topic.shortName.toLowerCase().includes(query);
});
});
const displayedTopics = computed(() => {
if (!isRabbitMqCluster.value || !rabbitMqMessageSort.value) return filteredTopics.value;
const direction = rabbitMqMessageSort.value === "asc" ? 1 : -1;
return [...filteredTopics.value].sort((left, right) => {
const countDifference = (rabbitMqReadyMessageCount(left) - rabbitMqReadyMessageCount(right)) * direction;
return countDifference || left.shortName.localeCompare(right.shortName);
});
});
/** Stable ids for RecycleScroller; avoids mounting all topic rows at once. */
const virtualTopicRows = computed<VirtualTopicRow[]>(() =>
displayedTopics.value.map((topic) => ({
id: showNamespaceColumn.value ? `${topic.namespace ?? ""}:${topic.name}` : topic.name,
topic,
})),
);
const topicsGridTemplate = computed(() => {
const cols: string[] = ["minmax(180px, 1.6fr)"];
if (showNamespaceColumn.value) cols.push("minmax(100px, 0.8fr)");
if (isRabbitMqCluster.value) {
// RabbitMQ queues: type(+state) | features | messages | consumers | rates | actions
cols.push("110px", "170px", "90px", "70px", "190px");
} else {
cols.push("120px");
}
if (!isRocketMqCluster.value && !isRabbitMqCluster.value) cols.push("140px");
// RocketMQ keeps tiled row actions (status/route/consumers/…) — reserve a wider actions column.
cols.push(isRocketMqCluster.value ? "minmax(560px, 2.2fr)" : "minmax(150px, 1fr)");
return cols.join(" ");
});
/** Min content width from grid track mins + gaps + padding; enables shared horizontal scroll. */
const topicsTableMinWidthPx = computed(() => {
let min = 180; // name
if (showNamespaceColumn.value) min += 100;
let colCount = 0;
if (isRabbitMqCluster.value) {
// type + features + messages + consumers + rates + actions
min += 110 + 170 + 90 + 70 + 190 + 150;
colCount = 7; // name, type, features, messages, consumers, rates, actions
} else {
min += 120; // type
if (!isRocketMqCluster.value) min += 140; // partitions
min += isRocketMqCluster.value ? 560 : 200; // actions
// RocketMQ: name + type + actions. Others: + partitions.
colCount = isRocketMqCluster.value ? 3 : 4;
}
if (showNamespaceColumn.value) colCount += 1;
min += (colCount - 1) * 8; // column-gap
min += 24; // horizontal padding
return min;
});
const userTopicCount = computed(() => {
if (isRocketMqCluster.value) {
return topics.value.filter((topic) => isRocketMqBusinessMessageType(resolveRocketMqMessageType(topic))).length;
}
return topics.value.filter((topic) => !topic.internal).length;
});
function topicRowSelected(topic: TopicInfo): boolean {
return selectedTopic.value?.name === topic.name && selectedTopic.value?.namespace === topic.namespace;
}
function rabbitMqReadyMessageCount(topic: TopicInfo): number {
return topic.messagesReady ?? topic.messageCount ?? 0;
}
/** Queue type label for the type column; unknown types are never guessed. */
function rabbitMqQueueTypeLabel(topic: TopicInfo): string {
return rabbitMqQueueType(topic) ?? t("mqTopics.rabbitmqQueueTypeUnknown");
}
function rabbitMqQueueTypeBadgeClass(topic: TopicInfo): string {
const type = rabbitMqQueueType(topic);
if (type === "quorum") return "badge-info";
if (type === "stream") return "badge-warning";
return "badge-default";
}
function rabbitMqFeatureBadgesFor(topic: TopicInfo): RabbitMqFeatureBadge[] {
return rabbitMqFeatureBadges(topic);
}
/** Tooltip for the message-count column: Ready / Unacked / Total / Consumers. */
function rabbitMqMessageCountTitle(topic: TopicInfo): string {
const parts = [`${t("mqRabbitMqMonitoring.messagesReady")}: ${topic.messagesReady ?? "-"}`, `${t("mqRabbitMqMonitoring.messagesUnacked")}: ${topic.messagesUnacked ?? "-"}`, `${t("mqTopics.messageCount")}: ${topic.messageCount ?? "-"}`];
if (topic.consumerCount !== undefined) parts.push(`${t("mqTopics.consumers")}: ${topic.consumerCount}`);
if (topic.state) parts.push(`${t("mqTopics.rabbitmqState")}: ${topic.state}`);
const summary = rabbitMqArgumentsSummary(topic);
if (summary) parts.push(`${t("mqTopics.rabbitmqArguments")}: ${summary}`);
return parts.join(" · ");
}
/** Tooltip for the rates column; rates are msg/s sampled by the management API. */
function rabbitMqRatesTitle(): string {
return `${t("mqTopics.rabbitmqPublishRate")} / ${t("mqTopics.rabbitmqDeliverRate")} / ${t("mqTopics.rabbitmqAckRate")} (msg/s)`;
}
function toggleRabbitMqMessageSort() {
rabbitMqMessageSort.value = rabbitMqMessageSort.value === "desc" ? "asc" : "desc";
}
function topicTypeLabel(topic: TopicInfo): string {
if (isRocketMqCluster.value) {
const type = resolveRocketMqMessageType(topic);
return t(`mqTopics.rocketmqType.${type.toLowerCase()}`);
}
if (topic.internal) return t("mqTopics.systemTopic");
if (topic.partitioned) return t("mqTopics.partitionedTopic");
return t("mqTopics.normalTopic");
}
function topicTypeBadgeClass(topic: TopicInfo): string {
if (isRocketMqCluster.value) {
const type = resolveRocketMqMessageType(topic);
if (type === "SYSTEM" || type === "RETRY" || type === "DLQ") return "badge-warning";
if (type === "DELAY" || type === "FIFO" || type === "TRANSACTION") return "badge-info";
return "badge-default";
}
if (topic.internal) return "badge-warning";
if (topic.partitioned) return "badge-info";
return "badge-default";
}
function isTopicProtected(topic: TopicInfo): boolean {
return isRocketMqCluster.value ? isProtectedRocketMqTopic(topic) : !!topic.internal;
}
const editingCurrentPartitions = computed(() => editingTopic.value?.partitions ?? 0);
const canSubmitPartitionUpdate = computed(() => {
const current = editingCurrentPartitions.value;
return !props.readOnly && current > 0 && Number.isFinite(newPartitions.value) && newPartitions.value > current;
});
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return false;
}
return confirmMqWrite(operation);
}
async function loadTopics() {
if (!props.tenant || !props.namespace) {
topics.value = [];
return;
}
loading.value = true;
error.value = undefined;
try {
const ns: NamespaceRef = {
tenant: props.tenant,
namespace: props.namespace,
};
const opts: ListTopicsOpts = {
includeNonPersistent: includeNonPersistent.value,
};
topics.value = await mqListTopics(props.connectionId, ns, opts);
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
async function loadClusterInfo() {
if (!isRocketMqCluster.value) return;
try {
clusterInfo.value = await mqGetClusterInfo(props.connectionId);
} catch (e: unknown) {
console.warn("[DBX] Failed to load RocketMQ cluster info:", e);
}
}
function openCreateDialog() {
if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return;
}
dialogError.value = undefined;
const defaultBroker = rocketMqMasterBrokers.value[0]?.brokerName ?? rocketMqBrokerOptions.value[0]?.brokerName ?? "";
formData.value = {
topicName: "",
persistent: true,
partitioned: props.isFlatMqCluster ?? false,
partitions: 4,
messageType: "NORMAL",
brokerName: defaultBroker,
readQueueNums: 8,
writeQueueNums: 8,
perm: 6,
};
showCreateDialog.value = true;
}
function openRocketMqDialog(kind: RocketMqTopicDialogKind, topic: TopicInfo) {
rocketMqDialogTopic.value = topic;
activeRocketMqDialog.value = kind;
}
function closeRocketMqDialog() {
activeRocketMqDialog.value = null;
rocketMqDialogTopic.value = undefined;
}
function handleRocketMqNavigate(payload: { tab: "subscriptions" | "messages"; subscription?: string }) {
if (!rocketMqDialogTopic.value) return;
if (payload.tab === "messages") {
openSendDialog(rocketMqDialogTopic.value);
closeRocketMqDialog();
return;
}
emit("navigateTab", {
tab: payload.tab,
topic: rocketMqDialogTopic.value,
subscription: payload.subscription,
});
closeRocketMqDialog();
}
function openSendDialog(topic: TopicInfo) {
sendDialogTopic.value = topic;
showSendDialog.value = true;
}
function closeSendDialog() {
showSendDialog.value = false;
sendDialogTopic.value = undefined;
}
function navigateToMessages(topic: TopicInfo) {
if (isRocketMqCluster.value) {
openSendDialog(topic);
return;
}
emit("navigateTab", { tab: "messages", topic });
}
function navigateToMessageQuery(topic: TopicInfo, preferDlqTopic = false) {
emit("navigateTab", {
tab: "messages",
topic,
preferDlqTopic,
});
}
function isDlqTopic(topic: TopicInfo): boolean {
return resolveRocketMqMessageType(topic) === "DLQ";
}
function openPartitionsDialog(topic: TopicInfo) {
if (props.readOnly) {
error.value = t("mqTopics.readOnly");
return;
}
dialogError.value = undefined;
if (!topic.partitions || topic.partitions < 1) {
error.value = t("mqTopics.currentPartitionsUnknown");
return;
}
editingTopic.value = topic;
newPartitions.value = topic.partitions + 1;
showPartitionsDialog.value = true;
}
async function handleCreate() {
if (!(await guardWritable(t("mqTopics.create")))) return;
if (!formData.value.topicName.trim() || !props.tenant || !props.namespace) {
dialogError.value = t("mqTopics.topicNameRequired");
return;
}
loading.value = true;
error.value = undefined;
try {
const topicRef: TopicRef = {
tenant: props.tenant,
namespace: props.namespace,
topic: formData.value.topicName,
persistent: formData.value.persistent,
};
if (isRocketMqCluster.value) {
topicRef.messageType = formData.value.messageType;
topicRef.brokerName = formData.value.brokerName || undefined;
topicRef.readQueueNums = formData.value.readQueueNums;
topicRef.writeQueueNums = formData.value.writeQueueNums;
topicRef.perm = formData.value.perm;
}
const partitions = props.supportsPartitionedTopics !== false && formData.value.partitioned ? formData.value.partitions : undefined;
await mqCreateTopic(props.connectionId, topicRef, partitions);
showCreateDialog.value = false;
dialogError.value = undefined;
await loadTopics();
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
loading.value = false;
}
}
function handleDelete(topic: TopicInfo) {
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);
if (!namespace) {
error.value = t("mqAdmin.selectNamespaceToWrite");
showDeleteDialog.value = false;
return;
}
deleting.value = true;
error.value = undefined;
try {
const topicRef: TopicRef = {
tenant: props.tenant,
namespace,
topic: topic.shortName,
persistent: topic.persistent,
};
await mqDeleteTopic(props.connectionId, topicRef, false);
if (selectedTopic.value?.name === topic.name && selectedTopic.value?.namespace === topic.namespace) {
selectedTopic.value = undefined;
}
showDeleteDialog.value = false;
await loadTopics();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
deleting.value = false;
}
}
async function handleUpdatePartitions() {
if (!(await guardWritable(t("mqTopics.adjustPartitions")))) return;
if (!editingTopic.value || !props.tenant || !props.namespace) return;
const currentPartitions = editingTopic.value.partitions;
if (!currentPartitions || currentPartitions < 1) {
dialogError.value = t("mqTopics.currentPartitionsUnknown");
return;
}
if (newPartitions.value <= currentPartitions) {
dialogError.value = t("mqTopics.partitionMustIncrease");
return;
}
loading.value = true;
error.value = undefined;
try {
const topicRef: TopicRef = {
tenant: props.tenant,
namespace: props.namespace,
topic: editingTopic.value.shortName,
persistent: editingTopic.value.persistent,
};
await mqUpdatePartitions(props.connectionId, topicRef, newPartitions.value);
showPartitionsDialog.value = false;
dialogError.value = undefined;
await loadTopics();
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
loading.value = false;
}
}
function selectTopic(topic: TopicInfo) {
selectedTopic.value = topic;
emit("topicSelected", topic);
}
function normalizePartitionInput() {
const min = editingCurrentPartitions.value + 1;
if (!showPartitionsDialog.value || min <= 1) return;
if (!Number.isFinite(Number(newPartitions.value)) || Number(newPartitions.value) < min) {
newPartitions.value = min;
}
}
watch(
() => [props.tenant, props.namespace],
() => {
selectedTopic.value = undefined;
loadTopics();
if (isRocketMqCluster.value) void loadClusterInfo();
},
{ immediate: true },
);
watch(
() => props.mqSystemKind,
() => {
if (isRocketMqCluster.value) void loadClusterInfo();
},
{ immediate: true },
);
watch(includeNonPersistent, () => {
loadTopics();
});
watch(newPartitions, () => {
if (dialogError.value === t("mqTopics.partitionMustIncrease") && canSubmitPartitionUpdate.value) {
dialogError.value = undefined;
}
});
</script>
<template>
<div class="topics-panel">
<div v-if="showRabbitMqSubTabs" class="rabbitmq-subtabs">
<button :class="{ active: rabbitMqSubTab === 'queues' }" @click="rabbitMqSubTab = 'queues'">{{ t("mqTopics.tabQueues") }}</button>
<button :class="{ active: rabbitMqSubTab === 'exchanges' }" @click="rabbitMqSubTab = 'exchanges'">{{ t("mqTopics.tabExchanges") }}</button>
</div>
<ExchangesPanel v-if="showRabbitMqSubTabs && rabbitMqSubTab === 'exchanges'" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :read-only="readOnly" />
<template v-else>
<div class="panel-toolbar">
<div class="toolbar-left">
<h3>{{ t("mqTopics.title") }}</h3>
<input v-model="topicSearch" type="search" class="topic-search" :placeholder="t('mqTopics.searchPlaceholder')" :disabled="loading && !topics.length" />
<span v-if="topics.length" class="topic-count"> {{ filteredTopics.length }} / {{ typeFilteredTopics.length }} </span>
<label v-if="isKafkaCluster" class="checkbox-label">
<input v-model="includeSystemTopics" type="checkbox" />
{{ t("mqTopics.includeSystemTopics") }}
</label>
<label v-else-if="!isRocketMqCluster" class="checkbox-label">
<input v-model="includeNonPersistent" type="checkbox" />
{{ t("mqTopics.includeNonPersistent") }}
</label>
</div>
<div class="toolbar-actions">
<button @click="loadTopics" :disabled="loading || !tenant || !namespace" class="btn-secondary">
{{ loading ? t("mqTopics.refreshing") : t("mqTopics.refresh") }}
</button>
<button @click="openCreateDialog" :disabled="loading || readOnly || !tenant || !namespace || showNamespaceColumn" :title="showNamespaceColumn ? t('mqAdmin.selectNamespaceToCreate') : undefined" class="btn-primary">+ {{ t("mqTopics.createTopic") }}</button>
</div>
</div>
<MqTypeFilterBar v-if="isRocketMqCluster && tenant && namespace" :label="t('mqTopics.typeFilter')">
<label v-for="type in rocketMqTopicTypeOptions" :key="type" class="checkbox-label compact">
<input v-model="messageTypeFilters[type]" type="checkbox" />
{{ t(`mqTopics.rocketmqType.${type.toLowerCase()}`) }}
</label>
</MqTypeFilterBar>
<div v-if="!tenant || !namespace" class="panel-placeholder">{{ t("mqTopics.selectTenantNamespace") }}</div>
<div v-else-if="error" class="panel-error">{{ error }}</div>
<div v-else-if="loading && !topics.length" class="panel-loading">{{ t("mqTopics.loading") }}</div>
<div v-else-if="!topics.length" class="panel-placeholder">{{ t("mqTopics.noTopics") }}</div>
<div v-else-if="!filteredTopics.length" class="panel-placeholder">
{{ isRocketMqCluster && userTopicCount === 0 ? t("mqTopics.noUserTopics") : isKafkaCluster && !includeSystemTopics && userTopicCount === 0 ? t("mqTopics.noUserTopics") : t("mqTopics.noMatches") }}
</div>
<div v-else class="topics-table">
<!-- Shared horizontal scroller keeps header/body columns aligned when the grid min-width exceeds the panel. -->
<div class="topics-table-hscroll" :style="{ '--topics-table-min-width': `${topicsTableMinWidthPx}px` }">
<div class="topics-table-header" :style="{ gridTemplateColumns: topicsGridTemplate }">
<div class="topics-col">{{ t("mqTopics.name") }}</div>
<div v-if="showNamespaceColumn" class="topics-col">{{ t("mqAdmin.namespace") }}</div>
<div v-if="isRabbitMqCluster" class="topics-col">{{ t("mqTopics.rabbitmqQueueType") }}</div>
<div v-else class="topics-col">{{ t("mqTopics.type") }}</div>
<div v-if="isRabbitMqCluster" class="topics-col">{{ t("mqTopics.rabbitmqFeatures") }}</div>
<div v-if="isRabbitMqCluster" class="topics-col">
<button type="button" class="topics-sort-button" data-testid="rabbitmq-message-sort" @click="toggleRabbitMqMessageSort">
{{ t("mqTopics.messageCount") }}
<span v-if="rabbitMqMessageSort" aria-hidden="true">{{ rabbitMqMessageSort === "desc" ? "↓" : "↑" }}</span>
</button>
</div>
<div v-else-if="!isRocketMqCluster" class="topics-col">{{ t("mqTopics.partitions") }}</div>
<div v-if="isRabbitMqCluster" class="topics-col">{{ t("mqTopics.consumers") }}</div>
<div v-if="isRabbitMqCluster" class="topics-col" :title="rabbitMqRatesTitle()">{{ t("mqTopics.rabbitmqRates") }}</div>
<div class="topics-col">{{ t("mqTopics.actions") }}</div>
</div>
<RecycleScroller class="topics-scroller" :items="virtualTopicRows" :item-size="TOPIC_ROW_HEIGHT" :buffer="200" key-field="id">
<template #default="{ item: row }">
<div class="topics-row" :class="{ selected: topicRowSelected(row.topic) }" :style="{ gridTemplateColumns: topicsGridTemplate, height: `${TOPIC_ROW_HEIGHT}px` }" @click="selectTopic(row.topic)">
<div class="topics-col topic-name">
<div class="topic-name-cell">
<span class="topic-name-text" :title="row.topic.shortName">{{ row.topic.shortName }}</span>
<span v-if="!row.topic.persistent" class="badge badge-warning">{{ t("mqTopics.nonPersistent") }}</span>
</div>
</div>
<div v-if="showNamespaceColumn" class="topics-col">{{ row.topic.namespace || "-" }}</div>
<div v-if="isRabbitMqCluster" class="topics-col">
<span class="badge" :class="rabbitMqQueueTypeBadgeClass(row.topic)" :title="t('mqTopics.rabbitmqQueueTypeHint')">
{{ rabbitMqQueueTypeLabel(row.topic) }}
</span>
<span v-if="row.topic.state" class="queue-state" :title="t('mqTopics.rabbitmqState')">{{ row.topic.state }}</span>
</div>
<div v-else class="topics-col">
<span class="badge" :class="topicTypeBadgeClass(row.topic)">
{{ topicTypeLabel(row.topic) }}
</span>
</div>
<div v-if="isRabbitMqCluster" class="topics-col" data-testid="rabbitmq-features">
<span v-if="rabbitMqFeatureBadgesFor(row.topic).length" class="feature-badges">
<span v-for="badge in rabbitMqFeatureBadgesFor(row.topic)" :key="badge.key" class="feature-badge" :class="badge.kind === 'feature' ? 'feature-flag' : ''" :title="badge.title">
{{ badge.label }}
</span>
</span>
<span v-else class="text-muted">-</span>
</div>
<div v-if="isRabbitMqCluster" class="topics-col" data-testid="rabbitmq-message-count" :title="rabbitMqMessageCountTitle(row.topic)">
{{ rabbitMqReadyMessageCount(row.topic) }}
</div>
<div v-else-if="!isRocketMqCluster" class="topics-col">
<span v-if="row.topic.partitioned">{{ row.topic.partitions ? t("mqTopics.partitionCount", { count: row.topic.partitions }) : t("mqTopics.partitionsUnknown") }}</span>
<span v-else class="text-muted">-</span>
</div>
<div v-if="isRabbitMqCluster" class="topics-col" data-testid="rabbitmq-consumers">{{ row.topic.consumerCount ?? "-" }}</div>
<div v-if="isRabbitMqCluster" class="topics-col topics-rates" data-testid="rabbitmq-rates" :title="rabbitMqRatesTitle()">
<span>{{ formatMqRateCompact(row.topic.publishRate) }}</span>
<span class="rate-sep">/</span>
<span>{{ formatMqRateCompact(row.topic.deliverRate) }}</span>
<span class="rate-sep">/</span>
<span>{{ formatMqRateCompact(row.topic.ackRate) }}</span>
</div>
<div class="topics-col actions" @click.stop>
<template v-if="isRocketMqCluster">
<button type="button" class="btn-sm" @click="openRocketMqDialog('status', row.topic)">{{ t("mqTopics.actionStatus") }}</button>
<button type="button" class="btn-sm" @click="openRocketMqDialog('route', row.topic)">{{ t("mqTopics.actionRoute") }}</button>
<button type="button" class="btn-sm" @click="openRocketMqDialog('consumers', row.topic)">{{ t("mqTopics.actionConsumers") }}</button>
<button v-if="isDlqTopic(row.topic)" type="button" class="btn-sm" @click="navigateToMessageQuery(row.topic, true)">{{ t("mqRocketmq.viewDlqMessages") }}</button>
<template v-else>
<button type="button" class="btn-sm" @click="navigateToMessageQuery(row.topic)">{{ t("mqRocketmq.actionMessageQuery") }}</button>
<button type="button" class="btn-sm" :disabled="readOnly" @click="navigateToMessages(row.topic)">{{ t("mqRocketmq.actionSendMessage") }}</button>
</template>
<button type="button" class="btn-sm" @click="openRocketMqDialog('config', row.topic)">{{ t("mqTopics.actionConfig") }}</button>
<button type="button" class="btn-sm" :disabled="readOnly || isTopicProtected(row.topic)" @click="openRocketMqDialog('reset', row.topic)">{{ t("mqTopics.actionReset") }}</button>
<button type="button" class="btn-sm" :disabled="readOnly || isTopicProtected(row.topic)" @click="openRocketMqDialog('skip', row.topic)">{{ t("mqTopics.actionSkip") }}</button>
<button type="button" class="btn-sm btn-danger" :disabled="readOnly || isTopicProtected(row.topic)" @click="handleDelete(row.topic)">{{ t("mqTopics.delete") }}</button>
</template>
<template v-else>
<button v-if="row.topic.partitioned && supportsPartitionedTopics !== false && !isTopicProtected(row.topic)" type="button" @click="openPartitionsDialog(row.topic)" :disabled="readOnly || !row.topic.partitions" class="btn-sm">
{{ t("mqTopics.adjustPartitions") }}
</button>
<button
type="button"
@click="handleDelete(row.topic)"
:disabled="readOnly || isTopicProtected(row.topic) || (showNamespaceColumn && !row.topic.namespace)"
:title="showNamespaceColumn && !row.topic.namespace ? t('mqAdmin.selectNamespaceToWrite') : undefined"
class="btn-sm btn-danger"
>
{{ t("mqTopics.delete") }}
</button>
</template>
</div>
</div>
</template>
</RecycleScroller>
</div>
</div>
<!-- Create Dialog -->
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqTopics.createTopic") }}</h3>
<button @click="showCreateDialog = false" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div v-if="!isRocketMqCluster && !isFlatMqCluster" class="form-group">
<label>{{ t("mqTopics.tenantNamespace") }}</label>
<input type="text" :value="`${tenant} / ${namespace}`" disabled />
</div>
<div v-if="isRocketMqCluster" class="form-group">
<label>{{ t("mqTopics.clusterName") }}</label>
<input type="text" :value="rocketMqClusterName" disabled />
</div>
<div v-if="isRocketMqCluster" class="form-group">
<label>{{ t("mqTopics.brokerName") }}*</label>
<select v-model="formData.brokerName" :disabled="readOnly">
<option value="">{{ t("mqTopics.allBrokers") }}</option>
<option v-for="broker in rocketMqMasterBrokers.length ? rocketMqMasterBrokers : rocketMqBrokerOptions" :key="broker.brokerName || broker.id" :value="broker.brokerName || ''">
{{ broker.brokerName || `${broker.host}:${broker.port}` }}
</option>
</select>
</div>
<div class="form-group">
<label>{{ t("mqTopics.topicName") }}*</label>
<input v-model="formData.topicName" type="text" :placeholder="t('mqTopics.topicNamePlaceholder')" :disabled="readOnly" />
</div>
<div v-if="isRocketMqCluster" class="form-group">
<label>{{ t("mqTopics.messageType") }}*</label>
<select v-model="formData.messageType" :disabled="readOnly">
<option v-for="type in rocketMqCreatableTopicTypes" :key="type" :value="type">
{{ t(`mqTopics.rocketmqType.${type.toLowerCase()}`) }}
</option>
</select>
<div class="form-hint">{{ t("mqTopics.messageTypeHint") }}</div>
</div>
<div v-if="isRocketMqCluster" class="form-row-inline">
<div class="form-group">
<label>{{ t("mqTopics.readQueues") }}*</label>
<input v-model.number="formData.readQueueNums" type="number" min="1" max="256" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqTopics.writeQueues") }}*</label>
<input v-model.number="formData.writeQueueNums" type="number" min="1" max="256" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqTopics.perm") }}*</label>
<select v-model.number="formData.perm" :disabled="readOnly">
<option v-for="opt in ROCKETMQ_PERM_OPTIONS" :key="opt.value" :value="opt.value">{{ t(opt.labelKey) }}</option>
</select>
</div>
</div>
<div v-if="!isRocketMqCluster" class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="formData.persistent" :disabled="readOnly" />
{{ t("mqTopics.persistentRecommended") }}
</label>
<div class="form-hint">{{ t("mqTopics.persistentHint") }}</div>
</div>
<div v-if="!isRocketMqCluster && supportsPartitionedTopics !== false" class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="formData.partitioned" :disabled="readOnly" />
{{ t("mqTopics.enablePartitions") }}
</label>
<div v-if="formData.partitioned" class="form-subgroup">
<label>{{ t("mqTopics.partitionQuantity") }}*</label>
<input v-model.number="formData.partitions" type="number" min="1" max="256" :disabled="readOnly" />
<div class="form-hint">{{ t("mqTopics.partitionHint") }}</div>
</div>
</div>
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
</div>
<div class="dialog-footer">
<button @click="showCreateDialog = false" class="btn-secondary">{{ t("mqTopics.cancel") }}</button>
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqTopics.create") }}</button>
</div>
</div>
</div>
<!-- Update Partitions Dialog -->
<div v-if="showPartitionsDialog" class="dialog-overlay" @click="showPartitionsDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqTopics.updatePartitionsTitle", { name: editingTopic?.shortName }) }}</h3>
<button @click="showPartitionsDialog = false" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label>{{ t("mqTopics.currentPartitions") }}</label>
<input type="number" :value="editingTopic?.partitions" disabled />
</div>
<div class="form-group">
<label>{{ t("mqTopics.newPartitions") }}*</label>
<input v-model.number="newPartitions" type="number" :min="editingCurrentPartitions + 1" max="256" :disabled="readOnly" @change="normalizePartitionInput" @blur="normalizePartitionInput" />
<div class="form-hint">{{ t("mqTopics.partitionMinHint", { min: editingCurrentPartitions + 1 }) }}</div>
</div>
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
</div>
<div class="dialog-footer">
<button @click="showPartitionsDialog = false" class="btn-secondary">{{ t("mqTopics.cancel") }}</button>
<button @click="handleUpdatePartitions" :disabled="loading || !canSubmitPartitionUpdate" class="btn-primary">{{ t("mqTopics.update") }}</button>
</div>
</div>
</div>
<!-- Delete Confirm Dialog -->
<DangerConfirmDialog v-model:open="showDeleteDialog" :title="t('mqTopics.delete')" :message="t('mqTopics.confirmDelete', { name: deleteTarget?.shortName ?? '' })" :confirm-label="t('mqTopics.delete')" :loading="deleting" :close-on-confirm="false" @confirm="confirmDelete" />
<RocketMqTopicDialogs
v-if="isRocketMqCluster"
:connection-id="connectionId"
:tenant="tenant"
:namespace="namespace"
:topic="rocketMqDialogTopic"
:dialog="activeRocketMqDialog"
:read-only="readOnly"
:broker-options="rocketMqBrokerOptions"
@close="closeRocketMqDialog"
@navigate="handleRocketMqNavigate"
@refreshed="loadTopics"
/>
<div v-if="showSendDialog && sendDialogTopic" class="dialog-overlay" @click="closeSendDialog">
<div class="dialog send-dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqMessages.title") }}</h3>
<button type="button" class="btn-close" @click="closeSendDialog">×</button>
</div>
<div class="dialog-body send-dialog-body">
<SendMessagePanel embedded :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :topic="sendDialogTopic" :read-only="readOnly" mq-system-kind="rocketmq" :is-flat-mq-cluster="true" :supports-peek-messages="false" />
</div>
</div>
</div>
</template>
</div>
</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));
--topics-border: var(--border, var(--color-border, #e5e7eb));
--topics-border-light: color-mix(in srgb, var(--topics-border) 68%, transparent);
height: 100%;
display: flex;
flex-direction: column;
}
.rabbitmq-subtabs {
display: flex;
gap: 4px;
padding: 8px 16px 0;
border-bottom: 1px solid var(--color-border);
background: var(--color-background-secondary);
}
.rabbitmq-subtabs button {
padding: 8px 16px;
border: none;
background: transparent;
cursor: pointer;
color: var(--color-text-secondary);
border-bottom: 2px solid transparent;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.rabbitmq-subtabs button:hover {
color: var(--color-text);
background: var(--color-hover);
}
.rabbitmq-subtabs button.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
background: var(--color-background);
}
.panel-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
}
.topic-type-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
padding: 10px 16px;
border-bottom: 1px solid var(--topics-border-light);
background: color-mix(in srgb, var(--topics-header-bg) 72%, transparent);
}
.topic-type-filters-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
margin-right: 4px;
}
.checkbox-label.compact {
font-size: 12px;
gap: 6px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-left h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 0 0 auto;
}
.topic-search {
width: min(320px, 32vw);
min-width: 180px;
padding: 6px 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;
}
.topic-search:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
.topic-count {
flex: 0 0 auto;
color: var(--color-text-tertiary);
font-size: 12px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
cursor: pointer;
}
.panel-placeholder,
.panel-error,
.panel-loading {
padding: 24px;
text-align: center;
color: var(--color-text-secondary);
}
.panel-error {
color: var(--color-error);
}
.topics-table {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--topics-surface);
}
.topics-table-hscroll {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow-x: auto;
overflow-y: hidden;
}
.topics-table-header,
.topics-row {
display: grid;
align-items: center;
column-gap: 8px;
padding: 0 12px;
min-width: var(--topics-table-min-width, 100%);
}
.topics-table-header {
flex: 0 0 auto;
height: 38px;
/* Match RecycleScroller classic-scrollbar gutter so header/body columns stay aligned. */
overflow: hidden;
scrollbar-gutter: stable;
background: var(--topics-header-bg);
border-bottom: 1px solid var(--topics-border);
box-shadow:
0 1px 0 var(--topics-border),
0 2px 6px rgba(0, 0, 0, 0.04);
z-index: 2;
}
.topics-table-header .topics-col {
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary);
}
.topics-sort-button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.topics-scroller {
flex: 1;
min-height: 0;
height: 100%;
min-width: var(--topics-table-min-width, 100%);
/* RecycleScroller owns overflow-y; reserve gutter for classic scrollbars (Windows). */
scrollbar-gutter: stable;
}
.topics-row {
width: 100%;
cursor: pointer;
border-bottom: 1px solid var(--topics-border-light);
background: var(--topics-surface);
box-sizing: border-box;
}
.topics-row:hover {
background: var(--color-hover);
}
.topics-row.selected {
background: var(--color-primary-alpha);
}
.topics-col {
min-width: 0;
font-size: 13px;
}
.topic-name-cell {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.topic-name-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.topic-name {
font-weight: 500;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--dbx-radius-fixed-4);
font-size: 11px;
font-weight: 500;
}
.badge-default {
background: var(--color-background-secondary);
color: var(--color-text-secondary);
}
.badge-info {
background: var(--color-info-alpha);
color: var(--color-info);
}
.badge-warning {
background: var(--color-warning-alpha);
color: var(--color-warning);
}
.text-muted {
color: var(--color-text-tertiary);
font-style: italic;
}
/* RabbitMQ queue feature badges (compact, tooltip-bearing). */
.queue-state {
margin-left: 4px;
font-size: 11px;
color: var(--color-text-tertiary);
white-space: nowrap;
}
.feature-badges {
display: inline-flex;
gap: 3px;
flex-wrap: nowrap;
overflow: hidden;
}
.feature-badge {
display: inline-block;
padding: 1px 5px;
border-radius: var(--dbx-radius-fixed-4);
font-size: 10px;
font-weight: 600;
line-height: 1.5;
background: var(--color-background-secondary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
white-space: nowrap;
cursor: help;
}
.feature-badge.feature-flag {
background: var(--color-warning-alpha);
border-color: transparent;
color: var(--color-warning);
}
.topics-rates {
font-family: var(--font-mono, monospace);
font-size: 11px;
white-space: nowrap;
cursor: help;
}
.topics-rates .rate-sep {
margin: 0 3px;
color: var(--color-text-tertiary);
}
.actions {
display: flex;
gap: 6px;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-start;
overflow-x: auto;
min-width: 0;
}
.form-row-inline {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.form-group select {
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);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Dialog styles */
.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;
max-height: 60vh;
overflow-y: auto;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
font-size: 13px;
}
.form-group input[type="text"],
.form-group input[type="number"] {
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;
}
.form-group input:disabled {
background: var(--color-background-secondary);
color: var(--color-text-secondary);
}
.form-subgroup {
margin-top: 12px;
padding-left: 24px;
}
.form-hint {
margin-top: 4px;
font-size: 12px;
color: var(--color-text-tertiary);
}
.form-error {
margin-top: 12px;
padding: 8px 12px;
background: var(--color-error-bg);
color: var(--color-error);
border-radius: var(--dbx-radius-fixed-4);
font-size: 13px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--color-border);
}
.send-dialog {
width: min(760px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
}
.send-dialog-body {
padding: 0;
max-height: calc(100vh - 140px);
overflow: auto;
}
</style>