feat(rocketmq): add agent and management console

This commit is contained in:
AiLi authored and GitHub committed 2026-07-20 22:58:05 +08:00
1 parent d642d01eac
commit 574172f667
86 files changed
+12373 -505

No files matched your search

@@ -47,6 +47,7 @@ import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage,
import { appendConnectionErrorHints, isJdbcMissingRuntimeDependencyError } from "@/lib/connection/connectionErrorHints";
import { postgresTlsModeForForm } from "@/lib/connection/postgresTlsMode";
import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapServers";
import { normalizeRocketmqNamesrvAddr } from "@/lib/connection/rocketmqNamesrv";
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import { isSqlServerLegacyCompatibilityMode, requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityMode, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
@@ -512,6 +513,8 @@ const configTab = ref<ConfigTab>("connection");
const MQ_KAFKA_SECURITY_PROTOCOL_AUTO = "__auto";
const mqAdminUrl = ref("http://127.0.0.1:8080");
const mqSystemKind = ref<MqSystemKind>("pulsar");
const mqRocketmqNamesrvAddr = ref("127.0.0.1:9876");
const mqRocketmqClusterName = ref("");
const mqKafkaBootstrapServers = ref("127.0.0.1:9092");
const mqKafkaSecurityProtocol = ref(MQ_KAFKA_SECURITY_PROTOCOL_AUTO);
const mqKafkaSaslMechanism = ref("PLAIN");
@@ -534,10 +537,29 @@ const mqTlsSkipVerify = ref(false);
const mqPinnedVersion = ref(pinnedVersionToSelection(undefined));
const mqTokenSigningMode = ref<MqTokenSigningMode>("none");
const mqTokenSigningKey = ref("");
const mqSystemOptions = computed(() => [
{ value: "pulsar" as const, label: t("connection.mqSystemPulsar") },
{ value: "kafka" as const, label: t("connection.mqSystemKafka") },
]);
const MQ_DRIVER_LABELS: Record<MqSystemKind, string> = {
pulsar: "Apache Pulsar",
kafka: "Apache Kafka",
rocketmq: "Apache RocketMQ",
};
function mqSystemKindFromProfile(profile: string): MqSystemKind {
if (profile === "kafka") return "kafka";
if (profile === "rocketmq") return "rocketmq";
return "pulsar";
}
function syncMqSystemKindFromSelectedType() {
if (form.value.db_type !== "mq") return;
mqSystemKind.value = mqSystemKindFromProfile(selectedType.value);
}
function resolveMqSystemKind(config?: Partial<MqAdminConfig>): MqSystemKind {
if (config?.systemKind === "kafka" || config?.systemKind === "rocketmq" || config?.systemKind === "pulsar") {
return config.systemKind;
}
return mqSystemKindFromProfile(selectedType.value);
}
const mqKafkaSecurityProtocolOptions = computed(() => [
{ value: MQ_KAFKA_SECURITY_PROTOCOL_AUTO, label: t("connection.mqSecurityAuto") },
{ value: "PLAINTEXT", label: "PLAINTEXT" },
@@ -778,6 +800,7 @@ const driverProfiles: Record<
zookeeper: { type: "zookeeper", port: 2181, user: "", label: "Apache ZooKeeper", icon: "zookeeper" },
mq: { type: "mq", port: 8080, user: "", label: "Apache Pulsar", icon: "pulsar", host: "127.0.0.1" },
kafka: { type: "mq", port: 9092, user: "", label: "Apache Kafka", icon: "kafka", host: "127.0.0.1" },
rocketmq: { type: "mq", port: 9876, user: "", label: "Apache RocketMQ", icon: "rocketmq", host: "127.0.0.1" },
nacos: { type: "nacos", port: 8848, user: "nacos", label: "Nacos", icon: "nacos", host: "127.0.0.1" },
iris: { type: "iris", port: 1972, user: "_SYSTEM", label: "IRIS", icon: "iris" },
influxdb: { type: "influxdb", port: 8086, user: "", label: "InfluxDB", icon: "InfluxDB" },
@@ -805,8 +828,12 @@ function profileForConfig(config: ConnectionConfig) {
if (config.driver_profile === "oceanbase-oracle") return "oceanbase";
return config.driver_profile;
}
if (config.db_type === "mq" && (config.external_config as MqAdminConfig | undefined)?.systemKind === "kafka") return "kafka";
if (config.db_type === "mq") return "mq";
if (config.db_type === "mq") {
const kind = (config.external_config as MqAdminConfig | undefined)?.systemKind;
if (kind === "kafka") return "kafka";
if (kind === "rocketmq") return "rocketmq";
return "mq";
}
if (config.db_type === "dameng") return "dm";
if (config.db_type === "oceanbase-oracle") return "oceanbase";
return config.db_type;
@@ -848,13 +875,15 @@ function parseJaasStringProperty(value: string, key: string): string {
}
function resetMqFields(config?: Partial<MqAdminConfig>) {
const systemKind = config?.systemKind === "kafka" ? "kafka" : "pulsar";
const systemKind = resolveMqSystemKind(config);
const extra = mqExtraRecord(config);
const properties = mqExtraProperties(extra);
const jaasConfig = mqExtraPropertyString(extra, "sasl.jaas.config");
mqSystemKind.value = systemKind;
mqAdminUrl.value = config?.adminUrl?.trim() || (systemKind === "kafka" ? "" : "http://127.0.0.1:8080");
mqAdminUrl.value = config?.adminUrl?.trim() || (systemKind === "kafka" || systemKind === "rocketmq" ? "" : "http://127.0.0.1:8080");
mqKafkaBootstrapServers.value = mqExtraString(extra, "bootstrapServers") || "127.0.0.1:9092";
mqRocketmqNamesrvAddr.value = mqExtraString(extra, "namesrvAddr") || mqExtraString(extra, "namesrv_addr") || "127.0.0.1:9876";
mqRocketmqClusterName.value = mqExtraString(extra, "clusterName") || mqExtraString(extra, "cluster_name");
mqKafkaSecurityProtocol.value = mqExtraString(extra, "securityProtocol") || MQ_KAFKA_SECURITY_PROTOCOL_AUTO;
mqKafkaSaslMechanism.value = mqExtraString(extra, "saslMechanism") || "PLAIN";
mqKafkaKerberosPrincipal.value = parseJaasStringProperty(jaasConfig, "principal");
@@ -886,13 +915,23 @@ function resetMqFields(config?: Partial<MqAdminConfig>) {
}
function defaultMqFieldsForProfile(profile: string): Partial<MqAdminConfig> | undefined {
if (profile !== "kafka") return undefined;
return {
systemKind: "kafka",
adminUrl: "",
auth: { kind: "none" },
extra: { bootstrapServers: "127.0.0.1:9092" },
};
if (profile === "kafka") {
return {
systemKind: "kafka",
adminUrl: "",
auth: { kind: "none" },
extra: { bootstrapServers: "127.0.0.1:9092" },
};
}
if (profile === "rocketmq") {
return {
systemKind: "rocketmq",
adminUrl: "",
auth: { kind: "none" },
extra: { namesrvAddr: "127.0.0.1:9876" },
};
}
return undefined;
}
function hydrateMqFields(value: unknown) {
@@ -903,12 +942,21 @@ function hydrateMqFields(value: unknown) {
resetMqFields(value as Partial<MqAdminConfig>);
}
watch(selectedType, () => {
syncMqSystemKindFromSelectedType();
});
watch(mqSystemKind, (kind) => {
if (kind === "kafka") {
if (!mqKafkaBootstrapServers.value.trim()) mqKafkaBootstrapServers.value = "127.0.0.1:9092";
if (!isMqAuthKindAllowedForSystem(kind, mqAuthKind.value)) mqAuthKind.value = "none";
return;
}
if (kind === "rocketmq") {
if (!mqRocketmqNamesrvAddr.value.trim()) mqRocketmqNamesrvAddr.value = "127.0.0.1:9876";
if (!isMqAuthKindAllowedForSystem(kind, mqAuthKind.value)) mqAuthKind.value = "none";
return;
}
if (!mqAdminUrl.value.trim()) mqAdminUrl.value = "http://127.0.0.1:8080";
});
@@ -1058,6 +1106,23 @@ function buildMqAdminConfig(): MqAdminConfig {
};
}
if (systemKind === "rocketmq") {
const namesrvAddr = normalizeRocketmqNamesrvAddr(mqRocketmqNamesrvAddr.value);
const extra: Record<string, unknown> = { namesrvAddr };
if (mqRocketmqClusterName.value.trim()) extra.clusterName = mqRocketmqClusterName.value.trim();
if (mqAuthKind.value === "basic") {
extra.accessKey = mqBasicUsername.value.trim();
extra.secretKey = mqBasicPassword.value;
}
return {
systemKind: "rocketmq",
adminUrl: "",
auth: buildMqAuth(),
tlsSkipVerify: mqTlsSkipVerify.value || undefined,
extra,
};
}
return {
systemKind: mqSystemKind.value,
adminUrl: requireMqField(mqAdminUrl.value, t("connection.mqAdminUrlRequired")),
@@ -1392,6 +1457,20 @@ async function testConnectionWithTimeout(config: ConnectionConfig, runId: number
}
}
function applyMqRocketmqNamesrv(config: LegacyConnectionConfig, namesrvAddr: string) {
const first = normalizeRocketmqNamesrvAddr(namesrvAddr).split(";")[0];
if (!first) throw new Error(t("connection.rocketmqNamesrvAddrRequired"));
let parsed: URL;
try {
parsed = new URL(`rocketmq://${first}`);
} catch {
throw new Error(t("connection.rocketmqNamesrvAddrInvalid"));
}
config.host = parsed.hostname;
config.port = Number(parsed.port) || 9876;
config.ssl = false;
}
function applyMqAdminUrl(config: LegacyConnectionConfig, adminUrl: string) {
let parsed: URL;
try {
@@ -1562,6 +1641,7 @@ function applyProfile(val: string, preserveConnectionFields = false) {
}
if (profile.type === "mq") {
resetMqFields(defaultMqFieldsForProfile(val));
syncMqSystemKindFromSelectedType();
form.value.database = undefined;
form.value.connection_string = undefined;
}
@@ -1908,6 +1988,7 @@ const iconTypeMap: Record<string, string> = {
zookeeper: "zookeeper",
mq: "mq",
kafka: "kafka",
rocketmq: "rocketmq",
nacos: "nacos",
dm: "dm",
h2: "h2",
@@ -2002,6 +2083,7 @@ const dbOptions: DbOption[] = [
{ value: "zookeeper", label: "Apache ZooKeeper" },
{ value: "mq", label: "Apache Pulsar" },
{ value: "kafka", label: "Apache Kafka" },
{ value: "rocketmq", label: "Apache RocketMQ" },
{ value: "nacos", label: "Nacos" },
{ value: "influxdb", label: "InfluxDB" },
{ value: "iris", label: "IRIS" },
@@ -2332,7 +2414,9 @@ const connectionLabelTopClass = `${connectionLabelClass} mt-2`;
const connectionLabelSmallPaddedClass = `${connectionLabelClass} pt-2 text-xs`;
const hasRequiredConnectionTarget = computed(() => {
if (form.value.db_type === "mq") {
return mqSystemKind.value === "kafka" ? !!mqKafkaBootstrapServers.value.trim() : !!mqAdminUrl.value.trim();
if (mqSystemKind.value === "kafka") return !!mqKafkaBootstrapServers.value.trim();
if (mqSystemKind.value === "rocketmq") return !!mqRocketmqNamesrvAddr.value.trim();
return !!mqAdminUrl.value.trim();
}
if (form.value.db_type === "zookeeper") return !!(form.value.host || form.value.connection_string || connectionUrlInput.value.trim());
if (form.value.db_type === "nacos") return !!nacosServerAddr.value.trim();
@@ -2657,14 +2741,17 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
if (!config.is_production) config.is_production = undefined;
config.production_databases = [...new Set((config.production_databases || []).map((database) => database.trim()).filter(Boolean))];
if (!config.production_databases.length) config.production_databases = undefined;
if (config.db_type === "mq") {
if (form.value.db_type === "mq") {
const mqConfig = buildMqAdminConfig();
config.external_config = mqConfig;
config.driver_profile = mqConfig.systemKind === "kafka" ? "kafka" : "pulsar";
config.driver_label = mqConfig.systemKind === "kafka" ? "Apache Kafka" : "Apache Pulsar";
config.driver_profile = mqConfig.systemKind;
config.driver_label = MQ_DRIVER_LABELS[mqConfig.systemKind];
if (mqConfig.systemKind === "kafka") {
const extra = mqExtraRecord(mqConfig);
applyMqKafkaBootstrapServers(config, mqExtraString(extra, "bootstrapServers"), mqExtraString(extra, "securityProtocol"));
} else if (mqConfig.systemKind === "rocketmq") {
const extra = mqExtraRecord(mqConfig);
applyMqRocketmqNamesrv(config, mqExtraString(extra, "namesrvAddr") || mqExtraString(extra, "namesrv_addr"));
} else {
applyMqAdminUrl(config, mqConfig.adminUrl);
}
@@ -4524,19 +4611,6 @@ function openExternalUrl(url: string) {
<!-- Message Queue: admin URL and auth -->
<template v-else-if="form.db_type === 'mq'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqSystem") }}</Label>
<Select v-model="mqSystemKind">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in mqSystemOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<template v-if="mqSystemKind === 'kafka'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqBootstrapServers") }}</Label>
@@ -4556,6 +4630,16 @@ function openExternalUrl(url: string) {
</Select>
</div>
</template>
<template v-else-if="mqSystemKind === 'rocketmq'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.rocketmqNamesrvAddr") }}</Label>
<Input v-model="mqRocketmqNamesrvAddr" class="col-span-3" :placeholder="t('connection.rocketmqNamesrvAddrPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.rocketmqClusterName") }}</Label>
<Input v-model="mqRocketmqClusterName" class="col-span-3" :placeholder="t('connection.rocketmqClusterNamePlaceholder')" />
</div>
</template>
<template v-else>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqAdminUrl") }}</Label>
@@ -4566,11 +4650,11 @@ function openExternalUrl(url: string) {
<Label :class="connectionLabelClass">{{ t("connection.mqAuth") }}</Label>
<div class="col-span-3 flex flex-wrap gap-2">
<Button size="sm" :variant="mqAuthKind === 'none' ? 'default' : 'outline'" @click="mqAuthKind = 'none'">{{ t("connection.mqAuthNone") }}</Button>
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'token' ? 'default' : 'outline'" @click="mqAuthKind = 'token'">{{ t("connection.mqAuthToken") }}</Button>
<Button size="sm" :variant="mqAuthKind === 'basic' ? 'default' : 'outline'" @click="mqAuthKind = 'basic'">{{ t("connection.mqAuthBasic") }}</Button>
<Button v-if="mqSystemKind === 'pulsar'" size="sm" :variant="mqAuthKind === 'token' ? 'default' : 'outline'" @click="mqAuthKind = 'token'">{{ t("connection.mqAuthToken") }}</Button>
<Button size="sm" :variant="mqAuthKind === 'basic' ? 'default' : 'outline'" @click="mqAuthKind = 'basic'">{{ mqSystemKind === "rocketmq" ? t("connection.rocketmqAclAuth") : t("connection.mqAuthBasic") }}</Button>
<Button v-if="mqSystemKind === 'kafka'" size="sm" :variant="mqAuthKind === 'kerberos' ? 'default' : 'outline'" @click="mqAuthKind = 'kerberos'">{{ t("connection.mqAuthKerberos") }}</Button>
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'apiKey' ? 'default' : 'outline'" @click="mqAuthKind = 'apiKey'">{{ t("connection.mqAuthApiKey") }}</Button>
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'oauth2' ? 'default' : 'outline'" @click="mqAuthKind = 'oauth2'">{{ t("connection.mqAuthOauth2") }}</Button>
<Button v-if="mqSystemKind === 'pulsar'" size="sm" :variant="mqAuthKind === 'apiKey' ? 'default' : 'outline'" @click="mqAuthKind = 'apiKey'">{{ t("connection.mqAuthApiKey") }}</Button>
<Button v-if="mqSystemKind === 'pulsar'" size="sm" :variant="mqAuthKind === 'oauth2' ? 'default' : 'outline'" @click="mqAuthKind = 'oauth2'">{{ t("connection.mqAuthOauth2") }}</Button>
</div>
</div>
<template v-if="mqAuthKind === 'token'">
@@ -4581,11 +4665,11 @@ function openExternalUrl(url: string) {
</template>
<template v-else-if="mqAuthKind === 'basic'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
<Label :class="connectionLabelClass">{{ mqSystemKind === "rocketmq" ? t("connection.rocketmqAccessKey") : t("connection.user") }}</Label>
<Input v-model="mqBasicUsername" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.password") }}</Label>
<Label :class="connectionLabelClass">{{ mqSystemKind === "rocketmq" ? t("connection.rocketmqSecretKey") : t("connection.password") }}</Label>
<PasswordInput v-model="mqBasicPassword" class="col-span-3" />
</div>
<div v-if="mqSystemKind === 'kafka'" class="grid grid-cols-4 items-center gap-4">
@@ -4686,7 +4770,7 @@ function openExternalUrl(url: string) {
<span class="text-xs text-muted-foreground">{{ t("connection.mqTlsSkipVerify") }}</span>
</label>
</div>
<div v-if="mqSystemKind !== 'kafka'" class="grid grid-cols-4 items-center gap-4">
<div v-if="mqSystemKind === 'pulsar'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqPinnedVersion") }}</Label>
<Select v-model="mqPinnedVersion">
<SelectTrigger class="col-span-3 h-9">
@@ -4702,7 +4786,7 @@ function openExternalUrl(url: string) {
</SelectContent>
</Select>
</div>
<div v-if="mqSystemKind !== 'kafka'" class="grid grid-cols-4 items-center gap-4">
<div v-if="mqSystemKind === 'pulsar'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqTokenSigning") }}</Label>
<Select v-model="mqTokenSigningMode">
<SelectTrigger class="col-span-3 h-9">
@@ -88,6 +88,7 @@ const assetIcons: Record<string, string> = {
mq: "pulsar",
pulsar: "pulsar",
kafka: "kafka",
rocketmq: "rocketmq",
nacos: "nacos.png",
iris: "iris.png",
influxdb: "influxdb",
@@ -434,6 +434,7 @@ function tabDatabaseIconType(tab: QueryTab) {
const externalConfig = connection.external_config as { systemKind?: unknown } | undefined;
const systemKind = typeof externalConfig?.systemKind === "string" ? externalConfig.systemKind : "";
if (connection.driver_profile === "kafka" || systemKind === "kafka") return "kafka";
if (connection.driver_profile === "rocketmq" || systemKind === "rocketmq") return "rocketmq";
if (connection.driver_profile === "pulsar" || systemKind === "pulsar") return "pulsar";
}
return connection.driver_profile || connection.db_type;
+54 -11
View File
@@ -1,17 +1,30 @@
<script setup lang="ts">
import { formatError } from "@/lib/backend/errorUtils";
import { ref, watch, onMounted, onUnmounted } from "vue";
import { ref, watch, onMounted, onUnmounted, computed } from "vue";
import { useI18n } from "vue-i18n";
import type { ClusterInfo } from "@/types/mq";
import type { ClusterInfo, MqAdminConfig, MqSystemKind } from "@/types/mq";
import { mqGetClusterInfo } from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
interface Props {
connectionId: string;
readOnly?: boolean;
mqSystemKind?: MqSystemKind;
}
const props = defineProps<Props>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
const rocketmqNamesrvAddr = computed(() => {
if (!isRocketMqCluster.value) return undefined;
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 addr = extra?.namesrvAddr ?? extra?.namesrv_addr;
return typeof addr === "string" && addr.trim() ? addr.trim() : undefined;
});
const clusterInfo = ref<ClusterInfo>();
const loading = ref(false);
@@ -92,7 +105,7 @@ onUnmounted(() => {
<template>
<div class="broker-panel">
<div class="panel-toolbar">
<h3>{{ t("mqBroker.title") }}</h3>
<h3>{{ isRocketMqCluster ? t("mqBroker.clusterTitle") : t("mqBroker.title") }}</h3>
<div class="toolbar-actions">
<label class="checkbox-label">
<input type="checkbox" v-model="autoRefresh" />
@@ -104,7 +117,7 @@ onUnmounted(() => {
<option :value="30">{{ t("mqBroker.refreshInterval30s") }}</option>
<option :value="60">{{ t("mqBroker.refreshInterval60s") }}</option>
</select>
<button @click="refreshNow" :disabled="loading" class="btn-sm">
<button @click="refreshNow" :disabled="loading" class="btn-secondary">
{{ loading ? t("mqBroker.refreshing") : t("mqBroker.refreshNow") }}
</button>
</div>
@@ -119,20 +132,20 @@ onUnmounted(() => {
<h4>{{ t("mqBroker.clusterOverview") }}</h4>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">🔗</div>
<div v-if="!isRocketMqCluster" class="stat-icon">🔗</div>
<div class="stat-content">
<div class="stat-label">{{ t("mqBroker.clusterId") }}</div>
<div class="stat-value stat-value-sm">{{ clusterInfo.clusterId || t("mqBroker.unknown") }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🖥️</div>
<div v-if="!isRocketMqCluster" class="stat-icon">🖥️</div>
<div class="stat-content">
<div class="stat-label">{{ t("mqBroker.brokerCount") }}</div>
<div class="stat-value">{{ clusterInfo.brokerCount }}</div>
</div>
</div>
<div class="stat-card">
<div v-if="!isRocketMqCluster" class="stat-card">
<div class="stat-icon">👑</div>
<div class="stat-content">
<div class="stat-label">{{ t("mqBroker.controller") }}</div>
@@ -144,6 +157,12 @@ onUnmounted(() => {
</div>
</div>
</div>
<div v-else class="stat-card">
<div class="stat-content">
<div class="stat-label">{{ t("mqRocketmq.namesrvAddr") }}</div>
<div class="stat-value stat-value-sm">{{ rocketmqNamesrvAddr || t("mqBroker.unknown") }}</div>
</div>
</div>
</div>
</div>
@@ -154,20 +173,25 @@ onUnmounted(() => {
<thead>
<tr>
<th>{{ t("mqBroker.nodeId") }}</th>
<th v-if="isRocketMqCluster">{{ t("mqBroker.brokerName") }}</th>
<th>{{ t("mqBroker.host") }}</th>
<th>{{ t("mqBroker.port") }}</th>
<th>{{ t("mqBroker.rack") }}</th>
<th v-if="!isRocketMqCluster">{{ t("mqBroker.rack") }}</th>
<th>{{ t("mqBroker.role") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="broker in clusterInfo.brokers" :key="broker.id" :class="{ 'is-controller': broker.id === clusterInfo.controllerId }">
<tr v-for="broker in clusterInfo.brokers" :key="`${broker.id}-${broker.host}:${broker.port}`" :class="{ 'is-controller': !isRocketMqCluster && broker.id === clusterInfo.controllerId }">
<td class="node-id">{{ broker.id }}</td>
<td v-if="isRocketMqCluster">{{ broker.brokerName || "-" }}</td>
<td>{{ broker.host }}</td>
<td>{{ broker.port }}</td>
<td>{{ broker.rack || "-" }}</td>
<td v-if="!isRocketMqCluster">{{ broker.rack || "-" }}</td>
<td>
<span v-if="broker.id === clusterInfo.controllerId" class="role-badge controller">{{ t("mqBroker.roleController") }}</span>
<span v-if="isRocketMqCluster" class="role-badge" :class="broker.role === 'MASTER' ? 'controller' : 'follower'">
{{ broker.role === "MASTER" ? t("mqBroker.roleMaster") : broker.role === "SLAVE" ? t("mqBroker.roleSlave") : t("mqBroker.unknown") }}
</span>
<span v-else-if="broker.id === clusterInfo.controllerId" class="role-badge controller">{{ t("mqBroker.roleController") }}</span>
<span v-else class="role-badge follower">{{ t("mqBroker.roleFollower") }}</span>
</td>
</tr>
@@ -383,4 +407,23 @@ onUnmounted(() => {
background: var(--color-background-tertiary);
color: var(--color-text-secondary);
}
.btn-secondary {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,870 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { MqSystemKind, SendMessageRequest, SendMessageResponse, TopicInfo, TopicRef } from "@/types/mq";
import { mqViewMessage, mqQueryMessagesByKey, mqQueryMessagesByTopic, mqSendMessage, mqPeekMessages } from "@/lib/backend/api";
import { formatRocketMqMessagePayload, formatRocketMqTimestamp, parseRocketMqMessagesFromResult, rocketMqDisplayFromPeeked, rocketMqMessagePayload, type RocketMqDisplayMessage } from "@/lib/mq/rocketmqMessageUtils";
import { formatError } from "@/lib/backend/errorUtils";
import { copyToClipboard } from "@/lib/common/clipboard";
import { buildRocketMqTraceTopicOptions, DEFAULT_ROCKETMQ_TRACE_TOPIC, resolveRocketMqMessageType } from "@/lib/mq/rocketmqTopicTypes";
import RocketMqTraceDetailDialog from "./RocketMqTraceDetailDialog.vue";
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
import RocketMqMessageDetailDialog from "./shared/RocketMqMessageDetailDialog.vue";
interface Props {
connectionId: string;
tenant?: string;
namespace?: string;
topic?: TopicInfo;
readOnly?: boolean;
mqSystemKind?: MqSystemKind;
embedded?: boolean;
preferDlqTopic?: boolean;
tracePanel?: boolean;
}
const props = defineProps<Props>();
const { t } = useI18n();
const topicSelectRef = ref<InstanceType<typeof RocketMqTopicSelect>>();
const availableTopics = ref<TopicInfo[]>([]);
const topicsLoading = computed(() => topicSelectRef.value?.loading ?? false);
const topicName = ref("");
const queryMsgId = ref("");
const queryKey = ref("");
const queryBeginTime = ref("");
const queryEndTime = ref("");
const queryMaxNum = ref(32);
const queryLoading = ref(false);
const queryError = ref<string>();
const queryMessages = ref<RocketMqDisplayMessage[]>([]);
const activeQueryMode = ref<"topic" | "key" | "msgId">("topic");
const selectedMessage = ref<RocketMqDisplayMessage>();
const detailLoading = ref(false);
const detailError = ref<string>();
const resendLoading = ref(false);
const resendError = ref<string>();
const resendSuccess = ref<SendMessageResponse>();
const payloadCopied = ref(false);
const traceDialogOpen = ref(false);
const traceMsgId = ref("");
const traceTopic = ref(DEFAULT_ROCKETMQ_TRACE_TOPIC);
const consumerGroup = ref("");
const dlqUseKeyQuery = ref(false);
const noDlqTopics = ref(false);
let payloadCopiedTimer: ReturnType<typeof setTimeout> | undefined;
let resendSuccessTimer: ReturnType<typeof setTimeout> | undefined;
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
const isDlqTopicSelected = computed(() => {
if (props.tracePanel) return false;
if (!isRocketMqCluster.value || !topicName.value.trim()) return false;
const fromList = availableTopics.value.find((item) => item.shortName === topicName.value.trim());
if (fromList) return resolveRocketMqMessageType(fromList) === "DLQ";
return topicSelectRef.value?.resolveTopicType(topicName.value) === "DLQ";
});
const showBusinessQueryModes = computed(() => !isDlqTopicSelected.value);
const canViewMessageTrace = computed(() => isRocketMqCluster.value && showBusinessQueryModes.value);
const traceTopicOptions = computed(() => buildRocketMqTraceTopicOptions(availableTopics.value));
const contextPlaceholder = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.connectionNotReady") : t("mqMessages.selectNamespaceOrTopicFirst")));
const selectedTopicRef = computed<TopicRef | null>(() => {
const topic = topicName.value.trim();
if (!topic || !props.tenant || !props.namespace) return null;
const selected = availableTopics.value.find((item) => item.shortName === topic);
return {
tenant: props.tenant,
namespace: props.namespace,
topic,
persistent: selected?.persistent ?? true,
partitioned: selected?.partitioned,
};
});
const detailPayload = computed(() => (selectedMessage.value ? rocketMqMessagePayload(selectedMessage.value) : ""));
const formattedDetailPayload = computed(() => formatRocketMqMessagePayload(detailPayload.value));
const resendTopic = computed(() => selectedMessage.value?.topic?.trim() || selectedTopicRef.value?.topic || topicName.value.trim());
const canResend = computed(() => !props.readOnly && !isDlqTopicSelected.value && !!resendTopic.value && !!detailPayload.value.trim());
const detailHeaders = computed(() => {
const headers = selectedMessage.value?.headers ?? {};
return Object.entries(headers).filter(([key]) => key !== "TAGS");
});
function pad2(value: number): string {
return String(value).padStart(2, "0");
}
function toDatetimeLocalValue(date: Date): string {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
}
function defaultTimeRange(): { begin: string; end: string } {
const begin = new Date();
begin.setHours(0, 0, 0, 0);
const end = new Date(begin);
end.setDate(end.getDate() + 1);
return {
begin: toDatetimeLocalValue(begin),
end: toDatetimeLocalValue(end),
};
}
async function loadTopics() {
await topicSelectRef.value?.loadTopics();
}
function handleTopicsLoaded(topics: TopicInfo[]) {
availableTopics.value = topics;
noDlqTopics.value = !topics.some((topic) => resolveRocketMqMessageType(topic) === "DLQ");
if (props.preferDlqTopic) {
const firstDlq = topics.find((topic) => resolveRocketMqMessageType(topic) === "DLQ");
if (firstDlq) topicName.value = firstDlq.shortName;
} else if (props.topic && !topicName.value) {
topicName.value = props.topic.shortName;
} else if (!topicName.value && topics.length === 1) {
topicName.value = topics[0].shortName;
}
if (!traceTopicOptions.value.includes(traceTopic.value)) {
traceTopic.value = DEFAULT_ROCKETMQ_TRACE_TOPIC;
}
}
async function runQuery() {
const topic = selectedTopicRef.value;
if (!topic) {
queryError.value = t("mqMessages.selectTopicBeforeLoad");
return;
}
queryLoading.value = true;
queryError.value = undefined;
queryMessages.value = [];
selectedMessage.value = undefined;
try {
if (isDlqTopicSelected.value) {
if (dlqUseKeyQuery.value && queryKey.value.trim()) {
const end = Date.now();
const begin = end - 7 * 24 * 60 * 60 * 1000;
const result = await mqQueryMessagesByKey(props.connectionId, topic, queryKey.value.trim(), begin, end, 64);
queryMessages.value = parseRocketMqMessagesFromResult(result);
} else {
const group = consumerGroup.value.trim() || "__dbx_rocketmq_dlq__";
const peeked = await mqPeekMessages(props.connectionId, topic, group, 64);
queryMessages.value = peeked.map((msg) => rocketMqDisplayFromPeeked(msg, topic.topic));
}
} else if (activeQueryMode.value === "msgId") {
const id = queryMsgId.value.trim();
if (!id) throw new Error(t("mqMessages.msgIdRequired"));
const result = await mqViewMessage(props.connectionId, topic, id);
queryMessages.value = parseRocketMqMessagesFromResult(result);
} else {
const begin = queryBeginTime.value ? Date.parse(queryBeginTime.value) : 0;
const end = queryEndTime.value ? Date.parse(queryEndTime.value) : Date.now();
const maxNum = Math.max(1, Math.min(200, Number(queryMaxNum.value) || 32));
if (activeQueryMode.value === "key") {
const key = queryKey.value.trim();
if (!key) throw new Error(t("mqMessages.queryKeyRequired"));
// Dashboard key query only needs topic + key; broker returns up to 64 recent matches.
const result = await mqQueryMessagesByKey(props.connectionId, topic, key, 0, Date.now(), 64);
queryMessages.value = parseRocketMqMessagesFromResult(result);
} else {
const result = await mqQueryMessagesByTopic(props.connectionId, topic, begin, end, maxNum);
queryMessages.value = parseRocketMqMessagesFromResult(result);
}
}
} catch (e: unknown) {
queryError.value = formatError(e);
} finally {
queryLoading.value = false;
}
}
async function viewMessageDetails(message: RocketMqDisplayMessage) {
const topic = selectedTopicRef.value;
const msgId = message.messageId?.trim();
resendError.value = undefined;
resendSuccess.value = undefined;
payloadCopied.value = false;
if (isDlqTopicSelected.value || !topic || !msgId) {
selectedMessage.value = message;
detailError.value = undefined;
return;
}
detailLoading.value = true;
detailError.value = undefined;
try {
const result = await mqViewMessage(props.connectionId, topic, msgId);
const rows = parseRocketMqMessagesFromResult(result);
selectedMessage.value = rows[0] ?? message;
} catch (e: unknown) {
detailError.value = formatError(e);
selectedMessage.value = message;
} finally {
detailLoading.value = false;
}
}
function closeDetails() {
selectedMessage.value = undefined;
detailError.value = undefined;
resendError.value = undefined;
resendSuccess.value = undefined;
payloadCopied.value = false;
}
function openTraceDialog(message: RocketMqDisplayMessage) {
const msgId = message.messageId?.trim();
if (!msgId) return;
traceMsgId.value = msgId;
traceDialogOpen.value = true;
}
function closeTraceDialog() {
traceDialogOpen.value = false;
traceMsgId.value = "";
}
async function copyPayload() {
const text = detailPayload.value;
if (!text) return;
try {
await copyToClipboard(text);
payloadCopied.value = true;
if (payloadCopiedTimer) clearTimeout(payloadCopiedTimer);
payloadCopiedTimer = setTimeout(() => {
payloadCopied.value = false;
}, 2000);
} catch (e: unknown) {
detailError.value = formatError(e);
}
}
function clearResendSuccessLater() {
if (resendSuccessTimer) clearTimeout(resendSuccessTimer);
resendSuccessTimer = setTimeout(() => {
resendSuccess.value = undefined;
}, 4000);
}
async function resendMessage() {
if (props.readOnly) {
resendError.value = t("mqMessages.readOnlyCannotSend");
return;
}
const topic = resendTopic.value;
const payloadText = detailPayload.value.trim();
if (!topic) {
resendError.value = t("mqMessages.selectTargetTopic");
return;
}
if (!payloadText) {
resendError.value = t("mqMessages.messageContentRequired");
return;
}
resendLoading.value = true;
resendError.value = undefined;
resendSuccess.value = undefined;
try {
const message = selectedMessage.value;
const headers: Record<string, string> = { ...(message?.headers ?? {}) };
if (isRocketMqCluster.value && message?.tag?.trim()) {
headers.TAGS = message.tag.trim();
}
const req: SendMessageRequest = {
topic,
key: message?.key?.trim() || undefined,
payloadBase64: btoa(unescape(encodeURIComponent(payloadText))),
payloadText,
headers,
};
resendSuccess.value = await mqSendMessage(props.connectionId, req);
clearResendSuccessLater();
} catch (e: unknown) {
resendError.value = formatError(e);
} finally {
resendLoading.value = false;
}
}
onUnmounted(() => {
if (payloadCopiedTimer) clearTimeout(payloadCopiedTimer);
if (resendSuccessTimer) clearTimeout(resendSuccessTimer);
});
watch(
() => [props.tenant, props.namespace],
() => {
const range = defaultTimeRange();
queryBeginTime.value = range.begin;
queryEndTime.value = range.end;
queryMessages.value = [];
selectedMessage.value = undefined;
},
{ immediate: true },
);
watch(
() => props.topic,
(newTopic) => {
if (newTopic) {
topicName.value = newTopic.shortName;
}
},
);
watch(isDlqTopicSelected, (isDlq) => {
queryError.value = undefined;
queryMessages.value = [];
selectedMessage.value = undefined;
if (isDlq) {
dlqUseKeyQuery.value = false;
}
});
watch(activeQueryMode, () => {
queryError.value = undefined;
queryMessages.value = [];
selectedMessage.value = undefined;
});
watch(topicName, () => {
queryMessages.value = [];
selectedMessage.value = undefined;
});
</script>
<template>
<div class="message-query-panel">
<div v-if="!embedded" class="panel-toolbar">
<h3>{{ tracePanel ? t("mqTrace.title") : t("mqMessages.queryTitle") }}</h3>
<button type="button" class="btn-secondary" :disabled="topicsLoading || !tenant || !namespace" @click="loadTopics">
{{ topicsLoading ? t("mqMessages.querying") : t("mqMessages.refreshTopicList") }}
</button>
</div>
<div v-if="!tenant || !namespace" class="panel-placeholder">{{ contextPlaceholder }}</div>
<div v-else-if="!tracePanel && preferDlqTopic && noDlqTopics" class="panel-placeholder">{{ t("mqRocketmq.noDlqTopics") }}</div>
<div v-else class="query-content">
<div v-if="showBusinessQueryModes" class="query-mode-tabs">
<button type="button" class="query-tab" :class="{ active: activeQueryMode === 'topic' }" @click="activeQueryMode = 'topic'">
{{ t("mqMessages.queryTabTopic") }}
</button>
<button type="button" class="query-tab" :class="{ active: activeQueryMode === 'key' }" @click="activeQueryMode = 'key'">
{{ t("mqMessages.queryTabKey") }}
</button>
<button type="button" class="query-tab" :class="{ active: activeQueryMode === 'msgId' }" @click="activeQueryMode = 'msgId'">
{{ t("mqMessages.queryTabMsgId") }}
</button>
</div>
<div class="query-filters">
<label class="filter-field filter-topic">
<span>{{ t("mqMessages.queryTopicLabel") }}</span>
<RocketMqTopicSelect ref="topicSelectRef" v-model="topicName" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :disabled="queryLoading" :show-type-filter="false" @loaded="handleTopicsLoaded" @select="runQuery" />
</label>
<label v-if="tracePanel" class="filter-field">
<span>{{ t("mqTrace.traceTopic") }}</span>
<select v-model="traceTopic" :disabled="queryLoading">
<option v-for="item in traceTopicOptions" :key="item" :value="item">{{ item }}</option>
</select>
</label>
<template v-if="isDlqTopicSelected">
<label class="filter-field">
<span>{{ t("mqRocketmq.dlqConsumerGroup") }}</span>
<input v-model="consumerGroup" type="text" :placeholder="t('mqMessages.dlqConsumerGroupPlaceholder')" :disabled="queryLoading || dlqUseKeyQuery" />
</label>
<label class="filter-field checkbox-field">
<span class="checkbox-label">
<input v-model="dlqUseKeyQuery" type="checkbox" :disabled="queryLoading" />
{{ t("mqRocketmq.dlqQueryByKey") }}
</span>
</label>
<label v-if="dlqUseKeyQuery" class="filter-field">
<span>{{ t("mqMessages.queryKey") }}</span>
<input v-model="queryKey" type="text" :placeholder="t('mqMessages.queryKeyPlaceholder')" :disabled="queryLoading" />
</label>
</template>
<label v-else-if="activeQueryMode === 'msgId'" class="filter-field">
<span>{{ t("mqMessages.msgId") }}</span>
<input v-model="queryMsgId" type="text" :placeholder="t('mqMessages.msgIdPlaceholder')" :disabled="queryLoading" />
</label>
<label v-else-if="activeQueryMode === 'key'" class="filter-field">
<span>{{ t("mqMessages.queryKey") }}</span>
<input v-model="queryKey" type="text" :placeholder="t('mqMessages.queryKeyPlaceholder')" :disabled="queryLoading" />
</label>
<label v-else-if="activeQueryMode === 'topic'" class="filter-field">
<span>{{ t("mqMessages.beginTime") }}</span>
<input v-model="queryBeginTime" type="datetime-local" :disabled="queryLoading" />
</label>
<label v-else-if="activeQueryMode === 'topic'" class="filter-field">
<span>{{ t("mqMessages.endTime") }}</span>
<input v-model="queryEndTime" type="datetime-local" :disabled="queryLoading" />
</label>
<label v-else-if="activeQueryMode === 'topic'" class="filter-field filter-narrow">
<span>{{ t("mqMessages.maxNum") }}</span>
<input v-model.number="queryMaxNum" type="number" min="1" max="200" :disabled="queryLoading" />
</label>
<div class="filter-actions">
<button type="button" class="btn-primary" :disabled="queryLoading || !selectedTopicRef" @click="runQuery">
{{ queryLoading ? t("mqMessages.querying") : t("mqMessages.query") }}
</button>
</div>
</div>
<div v-if="queryError" class="panel-error">{{ queryError }}</div>
<div class="results-section">
<div v-if="queryLoading" class="results-empty">{{ t("mqMessages.messagesLoading") }}</div>
<div v-else-if="!queryMessages.length" class="results-empty">{{ t("mqMessages.noQueryResults") }}</div>
<div v-else class="results-table-wrap">
<table class="results-table">
<thead>
<tr>
<th>{{ t("mqMessages.tableMessageId") }}</th>
<th>{{ t("mqMessages.tableTag") }}</th>
<th>{{ t("mqMessages.tableKey") }}</th>
<th>{{ t("mqMessages.tableStoreTime") }}</th>
<th>{{ t("mqMessages.tableOperation") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(message, index) in queryMessages" :key="`${message.messageId ?? index}-${message.partition ?? 0}`">
<td class="mono">{{ message.messageId ?? "-" }}</td>
<td>{{ message.tag ?? "-" }}</td>
<td>{{ message.key ?? "-" }}</td>
<td>{{ formatRocketMqTimestamp(message.timestamp) }}</td>
<td class="operation-cell">
<button type="button" class="btn-sm" :disabled="detailLoading" @click="viewMessageDetails(message)">
{{ t("mqMessages.viewDetails") }}
</button>
<button v-if="canViewMessageTrace" type="button" class="btn-sm" :disabled="!message.messageId" @click="openTraceDialog(message)">
{{ t("mqTrace.viewTrace") }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<RocketMqMessageDetailDialog
:message="selectedMessage"
:detail-loading="detailLoading"
:detail-error="detailError"
:resend-loading="resendLoading"
:resend-error="resendError"
:resend-success="resendSuccess"
:payload-copied="payloadCopied"
:resend-topic="resendTopic"
:formatted-detail-payload="formattedDetailPayload"
:detail-headers="detailHeaders"
:can-resend="canResend"
:can-view-trace="canViewMessageTrace"
:read-only="readOnly"
@close="closeDetails"
@copy="copyPayload"
@resend="resendMessage"
@view-trace="selectedMessage && openTraceDialog(selectedMessage)"
/>
<RocketMqTraceDetailDialog :open="traceDialogOpen" :connection-id="connectionId" :msg-id="traceMsgId" :trace-topic="traceTopic" @close="closeTraceDialog" />
</div>
</template>
<style scoped>
.message-query-panel {
height: 100%;
display: flex;
flex-direction: column;
}
.panel-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
}
.panel-toolbar h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.panel-placeholder,
.results-empty {
padding: 40px 24px;
text-align: center;
color: var(--color-text-secondary);
font-size: 14px;
}
.query-content {
flex: 1;
overflow: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.query-mode-tabs {
display: flex;
gap: 24px;
border-bottom: 1px solid var(--color-border);
}
.query-tab {
padding: 0 0 10px;
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--color-text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.query-tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.query-filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: end;
}
.filter-field {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1 1 200px;
min-width: 0;
max-width: 320px;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 500;
}
.filter-topic {
flex: 1 1 280px;
max-width: 360px;
}
.topic-select-row {
display: flex;
align-items: center;
gap: 8px;
}
.topic-select-row select {
flex: 1;
min-width: 0;
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 16px;
transition: all 0.15s;
flex-shrink: 0;
}
.btn-icon:hover:not(:disabled) {
background: var(--color-background-secondary);
color: var(--color-text);
}
.btn-icon:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.spin {
display: inline-block;
animation: spin-anim 0.8s linear infinite;
}
@keyframes spin-anim {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.filter-narrow {
flex: 0 1 120px;
max-width: 120px;
}
.filter-field select,
.filter-field input {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
box-sizing: border-box;
}
.filter-actions {
display: flex;
align-items: end;
flex: 0 0 auto;
}
.checkbox-field {
flex: 0 1 auto;
max-width: none;
}
.checkbox-field .checkbox-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--color-text);
}
.panel-error {
padding: 10px 14px;
border-radius: 6px;
background: var(--color-error-bg);
color: var(--color-error);
font-size: 13px;
}
.results-section {
flex: 1;
min-height: 240px;
}
.results-table-wrap {
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: auto;
}
.results-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.results-table th,
.results-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: top;
}
.results-table th {
background: var(--color-background-secondary);
color: var(--color-text-secondary);
font-weight: 600;
}
.results-table tbody tr:hover {
background: var(--color-hover, color-mix(in srgb, var(--color-primary) 6%, transparent));
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
}
.btn-primary,
.btn-sm {
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.operation-cell {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.btn-sm:hover:not(:disabled) {
background: var(--color-background-secondary);
}
.btn-primary {
padding: 7px 16px;
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
font-weight: 500;
}
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
.dialog {
width: min(720px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
overflow: auto;
border-radius: 10px;
background: var(--color-background);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
}
.dialog-wide {
width: min(860px, calc(100vw - 32px));
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--color-border);
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 14px 16px;
border-top: 1px solid var(--color-border);
}
.btn-close {
border: none;
background: none;
color: var(--color-text-secondary);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.dialog-body {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 18px;
overflow: auto;
}
.detail-section h4,
.section-heading h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.detail-grid {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px 12px;
font-size: 13px;
}
.detail-grid > span:nth-child(odd) {
color: var(--color-text-secondary);
font-weight: 500;
}
.panel-success {
padding: 10px 14px;
border-radius: 6px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
font-size: 13px;
}
.btn-secondary {
padding: 7px 16px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
cursor: pointer;
}
.detail-payload {
margin: 0;
padding: 12px;
max-height: 360px;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-secondary);
color: var(--color-text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
button:disabled,
input:disabled,
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { MqSystemKind, TopicInfo } from "@/types/mq";
import MessageQueryPanel from "./MessageQueryPanel.vue";
interface Props {
connectionId: string;
tenant?: string;
namespace?: string;
topic?: TopicInfo;
readOnly?: boolean;
mqSystemKind?: MqSystemKind;
}
defineProps<Props>();
</script>
<template>
<MessageQueryPanel trace-panel :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :topic="topic" :read-only="readOnly" :mq-system-kind="mqSystemKind" />
</template>
@@ -8,7 +8,7 @@ import { LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
import VChart from "vue-echarts";
import { Activity, AlertTriangle, BarChart3, Boxes, CheckCircle2, Database, Download, Gauge, Hash, HardDrive, Layers3, Loader2, Package, RadioTower, RefreshCw, Send, ShieldCheck, Table2, Upload, Users } from "@lucide/vue";
import type { TopicRef, TopicInfo, TopicStats, BacklogStats, PeekedMessage } from "@/types/mq";
import type { MqSystemKind, TopicRef, TopicInfo, TopicStats, BacklogStats, PeekedMessage } from "@/types/mq";
import { mqGetTopicStats, mqGetBacklog, mqPeekMessages } from "@/lib/backend/api";
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]);
@@ -18,6 +18,7 @@ interface Props {
topic?: TopicInfo;
tenant?: string;
namespace?: string;
mqSystemKind?: MqSystemKind;
}
interface MetricPoint {
@@ -44,6 +45,14 @@ interface PartitionStatsRow {
raw: Record<string, unknown>;
}
interface RocketMqPartitionStatsRow {
partition: number;
brokerName: string;
beginOffset: number;
endOffset: number;
messageCount: number;
}
interface KafkaPartitionStatsRow {
partition: number;
beginOffset: number;
@@ -75,7 +84,19 @@ const MAX_HISTORY_POINTS = 60;
const partitionRows = computed(() => extractPartitionRows(stats.value?.raw));
const kafkaPartitionRows = computed(() => extractKafkaPartitionRows(stats.value?.raw));
const isKafkaStats = computed(() => isKafkaStatsPayload(stats.value?.raw));
const rocketMqPartitionRows = computed(() => extractRocketMqPartitionRows(stats.value?.raw));
const isRocketMqStats = computed(() => props.mqSystemKind === "rocketmq" && rocketMqPartitionRows.value.length > 0);
const isKafkaStats = computed(() => props.mqSystemKind === "kafka" && kafkaPartitionRows.value.length > 0);
const rocketMqOverview = computed(() => {
const raw = objectRecord(stats.value?.raw);
const rows = rocketMqPartitionRows.value;
const totalMessages = numberField(raw.totalMessages) ?? rows.reduce((sum, row) => sum + row.messageCount, 0);
return {
queueCount: numberField(raw.partitions) ?? rows.length,
totalMessages,
brokerCount: new Set(rows.map((row) => row.brokerName).filter(Boolean)).size,
};
});
const kafkaOverview = computed(() => {
const raw = objectRecord(stats.value?.raw);
const rows = kafkaPartitionRows.value;
@@ -178,7 +199,7 @@ async function loadStats(options: { skipWhenHidden?: boolean } = {}) {
try {
const statsData = await mqGetTopicStats(props.connectionId, topicRef);
if (isKafkaStatsPayload(statsData.raw)) {
if (props.mqSystemKind === "rocketmq" || (props.mqSystemKind === "kafka" && isKafkaStatsPayload(statsData.raw))) {
stats.value = statsData;
backlog.value = undefined;
appendHistoryPoint(statsData);
@@ -217,6 +238,11 @@ function parseKafkaMessageSql(sql: string): { topic: string; partition?: number;
return { topic, partition, offset, limit };
}
function flatMqMonitorGroupName(): string {
if (props.mqSystemKind === "rocketmq") return "__dbx_rocketmq_monitor__";
return "__dbx_kafka_monitor__";
}
async function runKafkaMessageSql() {
if (!props.tenant || !props.namespace) return;
kafkaMessageLoading.value = true;
@@ -236,7 +262,7 @@ async function runKafkaMessageSql() {
persistent: selected?.persistent ?? true,
partitioned: selected?.partitioned,
},
"__dbx_kafka_monitor__",
flatMqMonitorGroupName(),
parsed.limit,
options,
);
@@ -309,6 +335,18 @@ function extractPartitionRows(raw: unknown): PartitionStatsRow[] {
});
}
function extractRocketMqPartitionRows(raw: unknown): RocketMqPartitionStatsRow[] {
return arrayObjects(objectRecord(raw).partitionStats)
.map((body) => ({
partition: numberField(body.partition) ?? 0,
brokerName: stringField(body.brokerName),
beginOffset: numberField(body.beginOffset) ?? 0,
endOffset: numberField(body.endOffset) ?? 0,
messageCount: numberField(body.messageCount) ?? 0,
}))
.sort((a, b) => a.partition - b.partition || a.brokerName.localeCompare(b.brokerName));
}
function extractKafkaPartitionRows(raw: unknown): KafkaPartitionStatsRow[] {
return arrayObjects(objectRecord(raw).partitionStats)
.map((body) => ({
@@ -324,6 +362,7 @@ function extractKafkaPartitionRows(raw: unknown): KafkaPartitionStatsRow[] {
}
function isKafkaStatsPayload(raw: unknown): boolean {
if (props.mqSystemKind === "rocketmq") return false;
const root = objectRecord(raw);
return Array.isArray(root.partitionStats) || (numberField(root.partitions) !== undefined && numberField(root.replicationFactor) !== undefined && numberField(root.totalMessages) !== undefined);
}
@@ -494,6 +533,62 @@ onUnmounted(() => {
</div>
</div>
<div v-else-if="stats && isRocketMqStats" class="stats-container">
<div class="stats-section">
<h4>{{ t("mqMonitoring.rocketmqTopicOverview") }}</h4>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon"><Layers3 :size="21" /></div>
<div class="stat-content">
<div class="stat-label">{{ t("mqMonitoring.queueCount") }}</div>
<div class="stat-value">{{ rocketMqOverview.queueCount }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><Hash :size="21" /></div>
<div class="stat-content">
<div class="stat-label">{{ t("mqMonitoring.messageCount") }}</div>
<div class="stat-value">{{ formatNumber(rocketMqOverview.totalMessages) }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><HardDrive :size="21" /></div>
<div class="stat-content">
<div class="stat-label">{{ t("mqMonitoring.brokerCount") }}</div>
<div class="stat-value">{{ rocketMqOverview.brokerCount }}</div>
</div>
</div>
</div>
</div>
<div class="stats-section">
<h4>{{ t("mqMonitoring.rocketmqQueueDetails") }}</h4>
<div v-if="rocketMqPartitionRows.length" class="partition-table-wrap">
<table class="partition-table">
<thead>
<tr>
<th>{{ t("mqMonitoring.tableQueue") }}</th>
<th>{{ t("mqMonitoring.tableBroker") }}</th>
<th>{{ t("mqMonitoring.tableBeginOffset") }}</th>
<th>{{ t("mqMonitoring.tableLogEndOffset") }}</th>
<th>{{ t("mqMonitoring.tableMessageCount") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rocketMqPartitionRows" :key="`${row.brokerName}-${row.partition}`">
<td>{{ row.partition }}</td>
<td>{{ row.brokerName || "-" }}</td>
<td>{{ formatNumber(row.beginOffset) }}</td>
<td>{{ formatNumber(row.endOffset) }}</td>
<td>{{ formatNumber(row.messageCount) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-state compact">{{ t("mqMonitoring.noRocketMqQueueMetrics") }}</div>
</div>
</div>
<div v-else-if="stats && isKafkaStats" class="stats-container">
<div class="stats-section">
<h4>{{ t("mqMonitoring.kafkaTopicOverview") }}</h4>
+201 -71
View File
@@ -2,10 +2,11 @@
import { formatError } from "@/lib/backend/errorUtils";
import { ref, computed, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { MqClusterInfo, TopicInfo } from "@/types/mq";
import type { MqAdminConfig, MqClusterInfo, MqSystemKind, TopicInfo } from "@/types/mq";
import { mqTestConnection } from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { mqClusterOptionsFromExtra } from "@/lib/mq/mqTenantForm";
import { defaultMqCapabilitiesForSystemKind, isFlatMqSystemKind, normalizeMqTabForSystemKind, resolveAvailableMqTabs, resolveInitialMqTab, resolveMqSystemKindFromConnection, type MqTab } from "@/lib/mq/mqConsoleDefaults";
import TenantsPanel from "./TenantsPanel.vue";
import NamespacesPanel from "./NamespacesPanel.vue";
import TopicsPanel from "./TopicsPanel.vue";
@@ -15,11 +16,12 @@ 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";
type MqTab = "tenants" | "namespaces" | "topics" | "subscriptions" | "monitoring" | "clients" | "policies" | "permissions" | "messages" | "raw" | "broker";
interface Props {
connectionId: string;
initialTenant?: string;
@@ -30,58 +32,104 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
const configuredSystemKind = computed(() => resolveMqSystemKindFromConnection(connectionStore.getConfig(props.connectionId)));
const FLAT_MQ_CONTEXT = "_flat_mq";
function normalizeFlatMqTenant(tenant?: string): string | undefined {
if (tenant === "_kafka" || tenant === FLAT_MQ_CONTEXT) return FLAT_MQ_CONTEXT;
return tenant;
}
// State
const activeTab = ref<MqTab>(props.initialTab || (props.initialTenant ? "namespaces" : "tenants"));
const selectedTenant = ref<string | undefined>(props.initialTenant);
const selectedNamespace = ref<string>();
const activeTab = ref<MqTab>(
resolveInitialMqTab({
initialTab: props.initialTab,
initialTenant: props.initialTenant,
systemKind: configuredSystemKind.value,
}),
);
const selectedTenant = ref<string | undefined>(normalizeFlatMqTenant(props.initialTenant));
const selectedNamespace = ref<string | undefined>(isFlatMqSystemKind(configuredSystemKind.value) ? FLAT_MQ_CONTEXT : undefined);
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 KAFKA_CONTEXT = "_kafka";
const preferDlqTopic = ref(props.initialTab === "dlq");
// Computed
const isKafkaCluster = computed(() => clusterInfo.value?.systemKind === "kafka");
const effectiveTenant = computed(() => (isKafkaCluster.value ? selectedTenant.value || KAFKA_CONTEXT : selectedTenant.value));
const effectiveNamespace = computed(() => (isKafkaCluster.value ? selectedNamespace.value || KAFKA_CONTEXT : selectedNamespace.value));
const canManageTenants = computed(() => capabilities.value?.supportsTenants ?? true);
const canManageNamespaces = computed(() => capabilities.value?.supportsNamespaces ?? true);
const canManagePartitionedTopics = computed(() => capabilities.value?.supportsPartitionedTopics ?? true);
const canManageSubscriptions = computed(() => capabilities.value?.supportsSubscriptions ?? true);
const canCreateSubscription = computed(() => capabilities.value?.supportsCreateSubscription ?? true);
const canResetCursor = computed(() => capabilities.value?.supportsResetCursor ?? true);
const canSkipMessages = computed(() => capabilities.value?.supportsSkipMessages ?? true);
const canClearBacklog = computed(() => capabilities.value?.supportsClearBacklog ?? true);
const canPeekMessages = computed(() => capabilities.value?.supportsPeekMessages ?? false);
const canExpireMessages = computed(() => capabilities.value?.supportsExpireMessages ?? true);
const canManageRateLimits = computed(() => capabilities.value?.supportsRateLimits ?? true);
const canManageBacklogQuota = computed(() => capabilities.value?.supportsBacklogQuota ?? true);
const canManageRetention = computed(() => capabilities.value?.supportsRetention ?? true);
const mqSystemKind = computed<MqSystemKind | undefined>(() => clusterInfo.value?.systemKind ?? configuredSystemKind.value);
const isFlatMqCluster = computed(() => isFlatMqSystemKind(mqSystemKind.value));
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(() => (isFlatMqCluster.value ? normalizeFlatMqTenant(selectedTenant.value) || FLAT_MQ_CONTEXT : selectedTenant.value));
const effectiveNamespace = computed(() => (isFlatMqCluster.value ? selectedNamespace.value || FLAT_MQ_CONTEXT : selectedNamespace.value));
const breadcrumbTenant = computed(() => (isFlatMqCluster.value ? undefined : selectedTenant.value));
const breadcrumbNamespace = computed(() => (isFlatMqCluster.value ? undefined : selectedNamespace.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(() => capabilities.value?.supportsPermissions ?? true);
const canSendMessage = computed(() => capabilities.value?.supportsSendMessage ?? false);
const canUseRawApi = computed(() => capabilities.value?.supportsRawAdminApi ?? true);
const canManagePermissions = computed(() => effectiveCapabilities.value.supportsPermissions);
const canSendMessage = computed(() => effectiveCapabilities.value.supportsSendMessage ?? 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[]>(() => {
const tabs: MqTab[] = [];
if (canManageTenants.value) tabs.push("tenants");
if (canManageNamespaces.value) tabs.push("namespaces");
tabs.push("topics");
if (canManageSubscriptions.value) tabs.push("subscriptions");
tabs.push("monitoring");
tabs.push("clients");
if (canSendMessage.value) tabs.push("messages");
tabs.push("broker");
if (canManagePolicies.value) tabs.push("policies");
if (canManagePermissions.value) tabs.push("permissions");
if (canUseRawApi.value) tabs.push("raw");
return tabs;
});
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() {
@@ -100,7 +148,7 @@ async function loadClusterInfo() {
function selectTenant(tenant: string) {
selectedTenant.value = tenant;
selectedNamespace.value = undefined;
selectedNamespace.value = isFlatMqCluster.value ? FLAT_MQ_CONTEXT : undefined;
selectedTopic.value = undefined;
selectedSubscriptionName.value = undefined;
if (canManageNamespaces.value) {
@@ -131,12 +179,36 @@ function handleNamespaceRolesSelected(namespace: string) {
function handleTopicSelected(topic: TopicInfo) {
selectedTopic.value = topic;
selectedSubscriptionName.value = undefined;
activeTab.value = isKafkaCluster.value ? "monitoring" : canManageSubscriptions.value ? "subscriptions" : "monitoring";
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 = "clients";
activeTab.value = isRocketMqCluster.value ? "producers" : "clients";
}
function handleProducerTopicSelected(topic: TopicInfo | undefined) {
selectedTopic.value = topic;
selectedSubscriptionName.value = undefined;
}
function goToTenantLevel() {
@@ -158,7 +230,8 @@ function goToTopicLevel() {
}
function setActiveTab(tab: MqTab) {
activeTab.value = availableTabs.value.includes(tab) ? tab : firstAvailableTab();
const normalized = normalizeMqTabForSystemKind(tab, mqSystemKind.value);
activeTab.value = availableTabs.value.includes(normalized) ? normalized : firstAvailableTab();
}
function firstAvailableTab(): MqTab {
@@ -175,15 +248,19 @@ watch(availableTabs, reconcileActiveTab);
watch(
() => props.initialTenant,
(tenant) => {
if (tenant && tenant !== selectedTenant.value) {
selectTenant(tenant);
const normalized = normalizeFlatMqTenant(tenant);
if (normalized && normalized !== selectedTenant.value) {
selectTenant(normalized);
}
},
);
watch(
() => props.initialTab,
(tab) => {
if (tab) setActiveTab(tab);
if (tab) {
if (tab === "dlq") preferDlqTopic.value = true;
setActiveTab(tab);
}
},
);
@@ -203,11 +280,14 @@ onMounted(async () => {
<!-- Top Toolbar -->
<div class="mq-toolbar">
<div class="mq-breadcrumb">
<span v-if="clusterInfo" class="cluster-info"> {{ clusterInfo.systemKind.toUpperCase() }} {{ clusterInfo.serverVersion || "" }} </span>
<span v-if="selectedTenant" class="breadcrumb-separator">›</span>
<button v-if="selectedTenant" class="breadcrumb-button" @click="goToTenantLevel" :title="t('mqAdmin.viewTenant')">{{ selectedTenant }}</button>
<span v-if="selectedNamespace" class="breadcrumb-separator">›</span>
<button v-if="selectedNamespace" class="breadcrumb-button" @click="goToNamespaceLevel" :title="t('mqAdmin.viewNamespace')">{{ selectedNamespace }}</button>
<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')">{{ breadcrumbNamespace }}</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>
@@ -219,24 +299,27 @@ onMounted(async () => {
<!-- Tab Bar -->
<div class="mq-tabs">
<button v-if="canManageTenants" :class="{ active: activeTab === 'tenants' }" @click="setActiveTab('tenants')">{{ t("mqAdmin.tabTenants") }}</button>
<button v-if="canManageNamespaces" :class="{ active: activeTab === 'namespaces' }" @click="setActiveTab('namespaces')">{{ t("mqAdmin.tabNamespaces") }}</button>
<button :class="{ active: activeTab === 'topics' }" @click="setActiveTab('topics')">{{ t("mqAdmin.tabTopics") }}</button>
<button v-if="canManageSubscriptions" :class="{ active: activeTab === 'subscriptions' }" @click="setActiveTab('subscriptions')">{{ t("mqAdmin.tabSubscriptions") }}</button>
<button :class="{ active: activeTab === 'monitoring' }" @click="setActiveTab('monitoring')">{{ t("mqAdmin.tabMonitoring") }}</button>
<button :class="{ active: activeTab === 'clients' }" @click="setActiveTab('clients')">{{ t("mqAdmin.tabClients") }}</button>
<button v-if="canSendMessage" :class="{ active: activeTab === 'messages' }" @click="setActiveTab('messages')">{{ t("mqAdmin.tabMessages") }}</button>
<button :class="{ active: activeTab === 'broker' }" @click="setActiveTab('broker')">{{ t("mqAdmin.tabBroker") }}</button>
<button v-if="canManagePolicies" :class="{ active: activeTab === 'policies' }" @click="setActiveTab('policies')">{{ t("mqAdmin.tabPolicies") }}</button>
<button v-if="canManagePermissions" :class="{ active: activeTab === 'permissions' }" @click="setActiveTab('permissions')">{{ t("mqAdmin.tabPermissions") }}</button>
<button v-if="canUseRawApi" :class="{ active: activeTab === 'raw' }" @click="setActiveTab('raw')">{{ t("mqAdmin.tabRawApi") }}</button>
<button v-for="tab in availableTabs" :key="tab" :class="{ active: activeTab === tab }" @click="setActiveTab(tab)">
{{ t(tabLabelKey(tab)) }}
</button>
</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="selectedTenant" :supports-namespaces="canManageNamespaces" :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-kafka-cluster="isKafkaCluster" @topic-selected="handleTopicSelected" />
<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"
@topic-selected="handleTopicSelected"
@navigate-tab="handleNavigateTab"
/>
<SubscriptionsPanel
v-else-if="activeTab === 'subscriptions' && canManageSubscriptions"
:connection-id="connectionId"
@@ -244,6 +327,8 @@ onMounted(async () => {
: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"
@@ -252,10 +337,55 @@ onMounted(async () => {
:supports-expire-messages="canExpireMessages"
@subscription-selected="handleSubscriptionSelected"
/>
<MonitoringPanel v-else-if="activeTab === 'monitoring'" :connection-id="connectionId" :topic="selectedTopic" :tenant="effectiveTenant" :namespace="effectiveNamespace" />
<ProducerConsumerPanel v-else-if="activeTab === 'clients'" :connection-id="connectionId" :topic="selectedTopic" :tenant="effectiveTenant" :namespace="effectiveNamespace" :read-only="readOnly" :selected-subscription="selectedSubscriptionName" :is-kafka-cluster="isKafkaCluster" />
<SendMessagePanel v-else-if="activeTab === 'messages' && canSendMessage" :connection-id="connectionId" :tenant="effectiveTenant" :namespace="effectiveNamespace" :topic="selectedTopic" :read-only="readOnly" :is-kafka-cluster="isKafkaCluster" :supports-peek-messages="canPeekMessages" />
<BrokerPanel v-else-if="activeTab === 'broker'" :connection-id="connectionId" :read-only="readOnly" />
<MonitoringPanel v-else-if="activeTab === 'monitoring'" :connection-id="connectionId" :topic="selectedTopic" :tenant="effectiveTenant" :namespace="effectiveNamespace" :mq-system-kind="mqSystemKind" />
<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" />
<PoliciesPanel
v-else-if="activeTab === 'policies' && canManagePolicies"
:connection-id="connectionId"
@@ -263,12 +393,12 @@ onMounted(async () => {
:tenant="effectiveTenant"
:namespace="effectiveNamespace"
:read-only="readOnly"
:is-kafka-cluster="isKafkaCluster"
:is-flat-mq-cluster="isFlatMqCluster"
:supports-rate-limits="canManageRateLimits"
:supports-backlog-quota="canManageBacklogQuota"
:supports-retention="canManageRetention"
/>
<PermissionsPanel v-else-if="activeTab === 'permissions' && canManagePermissions" :connection-id="connectionId" :topic="selectedTopic" :tenant="effectiveTenant" :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>
</div>
@@ -2,7 +2,7 @@
import { formatError } from "@/lib/backend/errorUtils";
import { computed, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { AuthAction, MqIssuedToken, MqTokenRecord, PermissionMap, PolicyScope, TopicInfo } from "@/types/mq";
import type { AuthAction, MqIssuedToken, MqSystemKind, MqTokenRecord, PermissionMap, PolicyScope, TopicInfo } from "@/types/mq";
import { mqGrantPermission, mqIssueToken, mqListPermissions, mqListTokenRecords, mqRevokePermission } from "@/lib/backend/api";
import { formatMqTokenIssueError, type MqTokenIssueErrorView } from "@/lib/mq/mqTokenErrors";
@@ -12,6 +12,7 @@ interface Props {
namespace?: string;
topic?: TopicInfo;
readOnly?: boolean;
mqSystemKind?: MqSystemKind;
}
const props = defineProps<Props>();
@@ -19,6 +20,11 @@ const { t } = useI18n();
const actionOptions: AuthAction[] = ["produce", "consume", "functions", "sources", "sinks", "packages"];
const isFlatMqCluster = computed(() => props.mqSystemKind === "kafka" || props.mqSystemKind === "rocketmq");
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
const panelTitle = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.aclTitle") : t("mqPermissions.title")));
const visibleActionOptions = computed(() => (isFlatMqCluster.value ? (["produce", "consume"] as AuthAction[]) : actionOptions));
const permissions = ref<PermissionMap>({});
const loading = ref(false);
const error = ref<string>();
@@ -81,6 +87,14 @@ function guardWritable() {
return true;
}
function formatActionLabel(action: AuthAction): string {
if (isRocketMqCluster.value) {
if (action === "produce") return t("mqRocketmq.actionProduce");
if (action === "consume") return t("mqRocketmq.actionConsume");
}
return action;
}
async function loadPermissions() {
const current = scope.value;
permissions.value = {};
@@ -309,10 +323,10 @@ watch(
<div class="permissions-panel">
<div class="panel-toolbar">
<div>
<h3>{{ t("mqPermissions.title") }}</h3>
<h3>{{ panelTitle }}</h3>
<div v-if="scopeLabel" class="scope-label">{{ scopeLabel }}</div>
</div>
<button @click="loadPermissions" :disabled="loading || !scope" class="btn-sm">
<button @click="loadPermissions" :disabled="loading || !scope" class="btn-secondary">
{{ loading ? t("mqPermissions.refreshing") : t("mqPermissions.refresh") }}
</button>
</div>
@@ -334,9 +348,9 @@ watch(
</label>
<div class="actions-group" :class="{ invalid: actionsError }">
<span>{{ t("mqPermissions.actions") }}</span>
<label v-for="action in actionOptions" :key="action" class="checkbox-label">
<label v-for="action in visibleActionOptions" :key="action" class="checkbox-label">
<input v-model="selectedActions" type="checkbox" :value="action" :disabled="readOnly" />
{{ action }}
{{ formatActionLabel(action) }}
</label>
<span v-if="actionsError" class="field-error actions-error">{{ actionsError }}</span>
</div>
@@ -359,11 +373,11 @@ watch(
<td class="role-cell">{{ row.role }}</td>
<td>
<span class="tag-list">
<span v-for="action in row.actions" :key="action" class="tag">{{ action }}</span>
<span v-for="action in row.actions" :key="action" class="tag">{{ formatActionLabel(action) }}</span>
</span>
</td>
<td class="row-actions">
<button @click="openTokenDialog(row.role)" class="btn-sm">{{ t("mqPermissions.token") }}</button>
<button v-if="!isFlatMqCluster" @click="openTokenDialog(row.role)" class="btn-sm">{{ t("mqPermissions.token") }}</button>
<button @click="revokePermission(row.role)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqPermissions.revoke") }}</button>
</td>
</tr>
@@ -411,9 +425,9 @@ watch(
</div>
<div class="actions-group token-actions">
<span>{{ t("mqPermissions.actions") }}</span>
<label v-for="action in actionOptions" :key="action" class="checkbox-label">
<label v-for="action in visibleActionOptions" :key="action" class="checkbox-label">
<input v-model="tokenActions" type="checkbox" :value="action" />
{{ action }}
{{ formatActionLabel(action) }}
</label>
</div>
<label>
@@ -12,7 +12,7 @@ interface Props {
namespace?: string;
topic?: TopicInfo;
readOnly?: boolean;
isKafkaCluster?: boolean;
isFlatMqCluster?: boolean;
supportsRateLimits?: boolean;
supportsBacklogQuota?: boolean;
supportsRetention?: boolean;
@@ -40,7 +40,7 @@ const retentionForm = ref<RetentionPolicy>({ ...defaultForms.retentionForm });
const scope = computed<PolicyScope | null>(() => {
if (!props.tenant || !props.namespace) return null;
if (props.isKafkaCluster && !props.topic) return null;
if (props.isFlatMqCluster && !props.topic) return null;
if (props.topic) {
return {
level: "topic",
@@ -57,7 +57,7 @@ const scope = computed<PolicyScope | null>(() => {
};
});
const scopePlaceholderMessage = computed(() => (props.isKafkaCluster ? t("mqPolicies.selectTopicFirst") : t("mqPolicies.selectNamespaceOrTopic")));
const scopePlaceholderMessage = computed(() => (props.isFlatMqCluster ? t("mqPolicies.selectTopicFirst") : t("mqPolicies.selectNamespaceOrTopic")));
const scopeLabel = computed(() => {
const current = scope.value;
@@ -2,8 +2,9 @@
import { formatError } from "@/lib/backend/errorUtils";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { ConsumerInfo, ProducerInfo, SubscriptionInfo, TopicInfo, TopicRef, TopicStats } from "@/types/mq";
import type { ConsumerInfo, ProducerInfo, SubscriptionInfo, TopicInfo, TopicRef, TopicStats, MqSystemKind } from "@/types/mq";
import { mqGetTopicStats, mqListConsumers, mqListProducers, mqListSubscriptions, mqUnloadTopic } from "@/lib/backend/api";
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
interface Props {
connectionId: string;
@@ -12,10 +13,17 @@ interface Props {
namespace?: string;
readOnly?: boolean;
selectedSubscription?: string;
isKafkaCluster?: boolean;
isFlatMqCluster?: boolean;
mqSystemKind?: MqSystemKind;
viewMode?: "all" | "producers" | "consumers";
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
viewMode: "all",
});
const emit = defineEmits<{
topicSelected: [topic: TopicInfo | undefined];
}>();
const { t } = useI18n();
interface PartitionClientRow {
@@ -48,21 +56,141 @@ const selectedPartitionName = ref("");
const loading = ref(false);
const unloading = ref(false);
const error = ref<string>();
const availableTopics = ref<TopicInfo[]>([]);
const topicsLoading = ref(false);
const selectedTopicName = ref("");
const topicSelectRef = ref<InstanceType<typeof RocketMqTopicSelect>>();
let loadedTopicsKey = "";
let syncingTopicFromProps = false;
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
const isProducersOnly = computed(() => props.viewMode === "producers");
const isConsumersOnly = computed(() => props.viewMode === "consumers");
const requiresTopicForRocketMq = computed(() => isRocketMqCluster.value && isProducersOnly.value);
const showConsumerSections = computed(() => !isProducersOnly.value);
const showProducerSections = computed(() => !isConsumersOnly.value);
const panelTitle = computed(() => {
if (isProducersOnly.value) return t("mqAdmin.tabProducers");
if (isConsumersOnly.value) return t("mqClients.activeConsumers");
return t("mqClients.title");
});
const isClusterWideMode = computed(() => isRocketMqCluster.value && !props.topic && !!props.tenant && !!props.namespace && !requiresTopicForRocketMq.value);
let runtimeLoadSeq = 0;
let consumerLoadSeq = 0;
let scheduledRuntimeLoad: ReturnType<typeof setTimeout> | undefined;
let scheduledRuntimeLoadKey = "";
const topicRef = computed<TopicRef | null>(() => {
if (!props.topic || !props.tenant || !props.namespace) return null;
function buildTopicRefFromName(name: string): TopicRef | null {
const topic = name.trim();
if (!topic || !props.tenant || !props.namespace) return null;
const matched = availableTopics.value.find((item) => item.shortName === topic);
return {
tenant: props.tenant,
namespace: props.namespace,
topic: props.topic.shortName,
persistent: props.topic.persistent,
partitioned: props.topic.partitioned,
topic,
persistent: matched?.persistent ?? true,
partitioned: matched?.partitioned ?? false,
};
}
const topicRef = computed<TopicRef | null>(() => {
if (!props.tenant || !props.namespace) return null;
if (requiresTopicForRocketMq.value) {
return buildTopicRefFromName(selectedTopicName.value);
}
if (isClusterWideMode.value) {
return {
tenant: props.tenant,
namespace: props.namespace,
topic: "",
persistent: true,
partitioned: false,
};
}
const topic = props.topic;
if (!topic) return null;
return {
tenant: props.tenant,
namespace: props.namespace,
topic: topic.shortName,
persistent: topic.persistent,
partitioned: topic.partitioned,
};
});
function runtimeWatchKey(): string {
if (requiresTopicForRocketMq.value) {
return [props.connectionId, props.tenant, props.namespace, selectedTopicName.value.trim()].join("\0");
}
const current = topicRef.value;
return [props.connectionId, props.tenant, props.namespace, props.mqSystemKind, current ? topicRefKey(current) : ""].join("\0");
}
function scheduleLoadRuntimeClients() {
const key = runtimeWatchKey();
if (scheduledRuntimeLoad && scheduledRuntimeLoadKey === key) return;
scheduledRuntimeLoadKey = key;
if (scheduledRuntimeLoad) clearTimeout(scheduledRuntimeLoad);
scheduledRuntimeLoad = setTimeout(() => {
scheduledRuntimeLoad = undefined;
void loadRuntimeClients();
}, 0);
}
async function loadTopics(force = false) {
if (!requiresTopicForRocketMq.value || !props.tenant || !props.namespace) return;
const key = [props.connectionId, props.tenant, props.namespace].join("\0");
if (!force && (topicsLoading.value || (loadedTopicsKey === key && availableTopics.value.length > 0))) return;
loadedTopicsKey = key;
topicsLoading.value = true;
try {
await topicSelectRef.value?.loadTopics();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
topicsLoading.value = false;
}
}
function handleTopicsLoaded(topics: TopicInfo[]) {
availableTopics.value = topics;
if (props.topic && !selectedTopicName.value) {
selectedTopicName.value = props.topic.shortName;
} else if (!selectedTopicName.value && topics.length === 1) {
selectedTopicName.value = topics[0].shortName;
}
syncProducerTopicToParent(selectedTopicName.value);
}
function syncProducerTopicToParent(name: string) {
if (!requiresTopicForRocketMq.value) return;
if (!name) {
emit("topicSelected", undefined);
return;
}
const topic = availableTopics.value.find((item) => item.shortName === name) ?? (props.topic?.shortName === name ? props.topic : undefined);
if (topic) {
emit("topicSelected", topic);
return;
}
emit("topicSelected", {
name,
shortName: name,
persistent: true,
partitioned: false,
});
}
async function loadRocketMqProducers(current: TopicRef, loadSeq: number, currentKey: string) {
producers.value = await mqListProducers(props.connectionId, current);
if (!isRuntimeLoadCurrent(loadSeq, currentKey)) return;
stats.value = undefined;
subscriptions.value = [];
selectedSubscription.value = "";
selectedPartitionName.value = "";
}
const partitionRows = computed(() => extractPartitionClientRows(stats.value?.raw));
const kafkaPartitionRows = computed(() => extractKafkaPartitionRows(stats.value?.raw));
const isKafkaStats = computed(() => kafkaPartitionRows.value.length > 0);
@@ -94,11 +222,25 @@ async function loadRuntimeClients() {
error.value = undefined;
return;
}
const currentKey = topicRefKey(current);
const currentKey = activeTopicKey(current);
loading.value = true;
error.value = undefined;
try {
if (isClusterWideMode.value) {
producers.value = await mqListProducers(props.connectionId, current);
subscriptions.value = await mqListSubscriptions(props.connectionId, current);
stats.value = undefined;
selectedSubscription.value = props.selectedSubscription ?? subscriptions.value[0]?.name ?? "";
selectedPartitionName.value = "";
return;
}
if (requiresTopicForRocketMq.value) {
await loadRocketMqProducers(current, loadSeq, currentKey);
return;
}
const statsData = await mqGetTopicStats(props.connectionId, current);
if (!isRuntimeLoadCurrent(loadSeq, currentKey)) return;
stats.value = statsData;
@@ -207,7 +349,7 @@ async function loadSelectedSubscriptionConsumers(options: LoadConsumersOptions =
if (!current || !subscriptionName) return [];
const loadSeq = ++consumerLoadSeq;
const currentKey = topicRefKey(current);
const currentKey = activeTopicKey(current);
let consumers = await mqListConsumers(props.connectionId, current, subscriptionName);
if (!isConsumerLoadCurrent(loadSeq, currentKey, subscriptionName)) return [];
applySubscriptionConsumers(subscriptionName, consumers);
@@ -235,6 +377,13 @@ function topicRefKey(topic: TopicRef): string {
return [topic.tenant, topic.namespace, topic.topic, topic.persistent ? "1" : "0", topic.partitioned ? "1" : "0"].join("|");
}
function activeTopicKey(topic: TopicRef): string {
if (requiresTopicForRocketMq.value) {
return [topic.tenant, topic.namespace, topic.topic].join("|");
}
return topicRefKey(topic);
}
function isRuntimeLoadCurrent(loadSeq: number, topicKey: string): boolean {
return loadSeq === runtimeLoadSeq && isCurrentTopic(topicKey);
}
@@ -245,7 +394,7 @@ function isConsumerLoadCurrent(loadSeq: number, topicKey: string, subscriptionNa
function isCurrentTopic(topicKey: string): boolean {
const current = topicRef.value;
return !!current && topicRefKey(current) === topicKey;
return !!current && activeTopicKey(current) === topicKey;
}
function sleep(ms: number): Promise<void> {
@@ -387,14 +536,44 @@ function stringField(value: unknown): string {
}
watch(
() => [props.connectionId, props.topic?.name, props.topic?.partitioned, props.tenant, props.namespace],
runtimeWatchKey,
() => {
void loadRuntimeClients();
scheduleLoadRuntimeClients();
},
{ immediate: true },
);
watch(
() => props.topic?.shortName ?? "",
(name) => {
if (!requiresTopicForRocketMq.value) return;
if (name === selectedTopicName.value) return;
syncingTopicFromProps = true;
selectedTopicName.value = name;
syncingTopicFromProps = false;
},
{ immediate: true },
);
watch(selectedTopicName, (name) => {
if (syncingTopicFromProps) return;
syncProducerTopicToParent(name);
});
watch(
() => (requiresTopicForRocketMq.value ? [props.connectionId, props.tenant, props.namespace].join("\0") : ""),
(key) => {
if (!key) {
loadedTopicsKey = "";
return;
}
void loadTopics();
},
{ immediate: true },
);
watch(selectedSubscription, () => {
if (isProducersOnly.value) return;
syncSelectedPartition();
void loadSelectedSubscriptionConsumers({ retryIfEmpty: true }).catch((e: unknown) => {
error.value = formatError(e) || String(e);
@@ -418,156 +597,166 @@ watch(
<template>
<div class="producer-consumer-panel">
<div class="panel-toolbar">
<h3>{{ t("mqClients.title") }}</h3>
<h3>{{ panelTitle }}</h3>
<div class="toolbar-actions">
<button v-if="!isKafkaCluster" class="btn-sm danger" :disabled="readOnly || !topic || unloading" @click="unloadTopic">
<button v-if="!isFlatMqCluster" class="btn-sm danger" :disabled="readOnly || !topicRef || unloading" @click="unloadTopic">
{{ unloading ? t("mqClients.unloading") : t("mqClients.unloadTopic") }}
</button>
<button class="btn-sm" :disabled="loading || !topic" @click="loadRuntimeClients">
<button class="btn-secondary" :disabled="loading || !topicRef" @click="loadRuntimeClients">
{{ loading ? t("mqClients.refreshing") : t("mqClients.refresh") }}
</button>
</div>
</div>
<div v-if="!topic" class="panel-placeholder">{{ t("mqClients.selectTopicFirst") }}</div>
<div v-else-if="error" class="panel-error">{{ error }}</div>
<div v-else class="runtime-content">
<section v-if="isKafkaStats" class="runtime-section">
<div class="section-heading">
<h4>{{ t("mqClients.kafkaPartitionStatus") }}</h4>
<span>{{ t("mqClients.partitionCount", { count: kafkaPartitionRows.length }) }}</span>
</div>
<table class="runtime-table partition-table">
<thead>
<tr>
<th>{{ t("mqClients.partition") }}</th>
<th>{{ t("mqClients.beginOffset") }}</th>
<th>{{ t("mqClients.latestOffset") }}</th>
<th>{{ t("mqClients.messageCount") }}</th>
<th>{{ t("mqClients.leader") }}</th>
<th>{{ t("mqClients.replicas") }}</th>
<th>{{ t("mqClients.isr") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="partition in kafkaPartitionRows" :key="partition.partition">
<td>{{ partition.partition }}</td>
<td>{{ partition.beginOffset }}</td>
<td>{{ partition.endOffset }}</td>
<td>{{ partition.messageCount }}</td>
<td>{{ partition.leader }}</td>
<td>{{ partition.replicas.join(", ") || "-" }}</td>
<td>{{ partition.isr.join(", ") || "-" }}</td>
</tr>
</tbody>
</table>
</section>
<section v-else-if="partitionRows.length" class="runtime-section">
<div class="section-heading">
<h4>{{ t("mqClients.partitionClients") }}</h4>
<span>{{ t("mqClients.partitionCount", { count: partitionRows.length }) }}</span>
</div>
<table class="runtime-table partition-table">
<thead>
<tr>
<th>{{ t("mqClients.partition") }}</th>
<th>{{ t("mqClients.inboundRate") }}</th>
<th>{{ t("mqClients.outboundRate") }}</th>
<th>{{ t("mqClients.producers") }}</th>
<th>{{ t("mqClients.subscriptions") }}</th>
<th>{{ t("mqClients.consumers") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="partition in partitionRows" :key="partition.name" :class="{ selected: selectedPartition?.name === partition.name }" @click="selectedPartitionName = partition.name">
<td :title="partition.name">{{ partition.shortName }}</td>
<td>{{ formatRate(partition.msgRateIn) }}</td>
<td>{{ formatRate(partition.msgRateOut) }}</td>
<td>{{ partition.producerCount }}</td>
<td>{{ partition.subscriptionCount }}</td>
<td>{{ partition.consumerCount }}</td>
</tr>
</tbody>
</table>
</section>
<section class="runtime-section">
<div class="section-heading">
<h4>{{ t("mqClients.activeProducers") }}</h4>
<div class="heading-meta">
<span v-if="selectedPartition" class="scope-chip">{{ selectedScopeLabel }}</span>
<span>{{ displayedProducers.length }}</span>
</div>
</div>
<div v-if="!displayedProducers.length && !loading" class="empty-state">{{ t("mqClients.noActiveProducers") }}</div>
<table v-else class="runtime-table">
<thead>
<tr>
<th>{{ t("mqClients.name") }}</th>
<th>{{ t("mqClients.id") }}</th>
<th>{{ t("mqClients.address") }}</th>
<th>{{ t("mqClients.version") }}</th>
<th>{{ t("mqClients.rate") }}</th>
<th>{{ t("mqClients.throughput") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="producer in displayedProducers" :key="`${selectedScopeLabel}-${producer.producerId}-${producer.producerName}`">
<td>{{ producer.producerName || "-" }}</td>
<td>{{ producer.producerId }}</td>
<td>{{ producer.address || "-" }}</td>
<td>{{ producer.clientVersion || "-" }}</td>
<td>{{ formatOptionalRate(producer.msgRateIn) }}</td>
<td>{{ formatOptionalBytes(producer.msgThroughputIn) }}</td>
</tr>
</tbody>
</table>
</section>
<section class="runtime-section">
<div class="section-heading">
<h4>{{ t("mqClients.activeConsumers") }}</h4>
<div class="subscription-selector">
<span v-if="selectedPartition" class="scope-chip">{{ selectedScopeLabel }}</span>
<span>{{ displayedConsumers.length }}</span>
<select v-if="!isKafkaStats && partitionRows.length" v-model="selectedPartitionName" :disabled="loading">
<option v-for="partition in partitionRows" :key="partition.name" :value="partition.name">{{ partition.shortName }}</option>
</select>
<select v-model="selectedSubscription" :disabled="loading || !subscriptionOptions.length">
<option v-for="sub in subscriptionOptions" :key="sub.name" :value="sub.name">{{ sub.name }}</option>
</select>
</div>
</div>
<div v-if="!subscriptionOptions.length && !loading" class="empty-state">{{ t("mqClients.noSubscriptions") }}</div>
<div v-else-if="!displayedConsumers.length && !loading" class="empty-state">
{{ selectedPartition ? t("mqClients.noConsumersOnPartition") : t("mqClients.noConsumersOnSubscription") }}
</div>
<table v-else class="runtime-table">
<thead>
<tr>
<th>{{ t("mqClients.name") }}</th>
<th>{{ t("mqClients.address") }}</th>
<th>{{ t("mqClients.version") }}</th>
<th>{{ t("mqClients.rate") }}</th>
<th>{{ t("mqClients.throughput") }}</th>
<th>{{ t("mqClients.permits") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="consumer in displayedConsumers" :key="`${selectedScopeLabel}-${selectedSubscription}-${consumer.consumerName}-${consumer.address}`">
<td>{{ consumer.consumerName || "-" }}</td>
<td>{{ consumer.address || "-" }}</td>
<td>{{ consumer.clientVersion || "-" }}</td>
<td>{{ formatOptionalRate(consumer.msgRateOut) }}</td>
<td>{{ formatOptionalBytes(consumer.msgThroughputOut) }}</td>
<td>{{ consumer.availablePermits }}</td>
</tr>
</tbody>
</table>
</section>
<div v-if="requiresTopicForRocketMq" class="topic-picker">
<label>{{ t("mqMessages.targetTopic") }}</label>
<RocketMqTopicSelect ref="topicSelectRef" v-model="selectedTopicName" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" grouping="business" :show-type-filter="false" :disabled="topicsLoading" @loaded="handleTopicsLoaded" />
</div>
<div v-if="!topicRef && !isClusterWideMode && !requiresTopicForRocketMq" class="panel-placeholder">
{{ t("mqClients.selectTopicFirst") }}
</div>
<template v-else-if="topicRef || isClusterWideMode">
<div v-if="isClusterWideMode" class="panel-hint">{{ t("mqClients.rocketmqClusterHint") }}</div>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-if="!error" class="runtime-content">
<section v-if="isKafkaStats" class="runtime-section">
<div class="section-heading">
<h4>{{ t("mqClients.kafkaPartitionStatus") }}</h4>
<span>{{ t("mqClients.partitionCount", { count: kafkaPartitionRows.length }) }}</span>
</div>
<table class="runtime-table partition-table">
<thead>
<tr>
<th>{{ t("mqClients.partition") }}</th>
<th>{{ t("mqClients.beginOffset") }}</th>
<th>{{ t("mqClients.latestOffset") }}</th>
<th>{{ t("mqClients.messageCount") }}</th>
<th>{{ t("mqClients.leader") }}</th>
<th>{{ t("mqClients.replicas") }}</th>
<th>{{ t("mqClients.isr") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="partition in kafkaPartitionRows" :key="partition.partition">
<td>{{ partition.partition }}</td>
<td>{{ partition.beginOffset }}</td>
<td>{{ partition.endOffset }}</td>
<td>{{ partition.messageCount }}</td>
<td>{{ partition.leader }}</td>
<td>{{ partition.replicas.join(", ") || "-" }}</td>
<td>{{ partition.isr.join(", ") || "-" }}</td>
</tr>
</tbody>
</table>
</section>
<section v-else-if="partitionRows.length" class="runtime-section">
<div class="section-heading">
<h4>{{ t("mqClients.partitionClients") }}</h4>
<span>{{ t("mqClients.partitionCount", { count: partitionRows.length }) }}</span>
</div>
<table class="runtime-table partition-table">
<thead>
<tr>
<th>{{ t("mqClients.partition") }}</th>
<th>{{ t("mqClients.inboundRate") }}</th>
<th>{{ t("mqClients.outboundRate") }}</th>
<th>{{ t("mqClients.producers") }}</th>
<th>{{ t("mqClients.subscriptions") }}</th>
<th>{{ t("mqClients.consumers") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="partition in partitionRows" :key="partition.name" :class="{ selected: selectedPartition?.name === partition.name }" @click="selectedPartitionName = partition.name">
<td :title="partition.name">{{ partition.shortName }}</td>
<td>{{ formatRate(partition.msgRateIn) }}</td>
<td>{{ formatRate(partition.msgRateOut) }}</td>
<td>{{ partition.producerCount }}</td>
<td>{{ partition.subscriptionCount }}</td>
<td>{{ partition.consumerCount }}</td>
</tr>
</tbody>
</table>
</section>
<section v-if="showProducerSections" class="runtime-section">
<div class="section-heading">
<h4>{{ t("mqClients.activeProducers") }}</h4>
<div class="heading-meta">
<span v-if="selectedPartition" class="scope-chip">{{ selectedScopeLabel }}</span>
<span>{{ displayedProducers.length }}</span>
</div>
</div>
<div v-if="!displayedProducers.length && !loading" class="empty-state">{{ t("mqClients.noActiveProducers") }}</div>
<table v-else class="runtime-table">
<thead>
<tr>
<th>{{ t("mqClients.name") }}</th>
<th>{{ t("mqClients.id") }}</th>
<th>{{ t("mqClients.address") }}</th>
<th>{{ t("mqClients.version") }}</th>
<th>{{ t("mqClients.rate") }}</th>
<th>{{ t("mqClients.throughput") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="producer in displayedProducers" :key="`${selectedScopeLabel}-${producer.producerId}-${producer.producerName}`">
<td>{{ producer.producerName || "-" }}</td>
<td>{{ producer.producerId }}</td>
<td>{{ producer.address || "-" }}</td>
<td>{{ producer.clientVersion || "-" }}</td>
<td>{{ formatOptionalRate(producer.msgRateIn) }}</td>
<td>{{ formatOptionalBytes(producer.msgThroughputIn) }}</td>
</tr>
</tbody>
</table>
</section>
<section v-if="showConsumerSections" class="runtime-section">
<div class="section-heading">
<h4>{{ t("mqClients.activeConsumers") }}</h4>
<div class="subscription-selector">
<span v-if="selectedPartition" class="scope-chip">{{ selectedScopeLabel }}</span>
<span>{{ displayedConsumers.length }}</span>
<select v-if="!isKafkaStats && partitionRows.length" v-model="selectedPartitionName" :disabled="loading">
<option v-for="partition in partitionRows" :key="partition.name" :value="partition.name">{{ partition.shortName }}</option>
</select>
<select v-model="selectedSubscription" :disabled="loading || !subscriptionOptions.length">
<option v-for="sub in subscriptionOptions" :key="sub.name" :value="sub.name">{{ sub.name }}</option>
</select>
</div>
</div>
<div v-if="!subscriptionOptions.length && !loading" class="empty-state">{{ t("mqClients.noSubscriptions") }}</div>
<div v-else-if="!displayedConsumers.length && !loading" class="empty-state">
{{ selectedPartition ? t("mqClients.noConsumersOnPartition") : t("mqClients.noConsumersOnSubscription") }}
</div>
<table v-else class="runtime-table">
<thead>
<tr>
<th>{{ t("mqClients.name") }}</th>
<th>{{ t("mqClients.address") }}</th>
<th>{{ t("mqClients.version") }}</th>
<th>{{ t("mqClients.rate") }}</th>
<th>{{ t("mqClients.throughput") }}</th>
<th>{{ t("mqClients.permits") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="consumer in displayedConsumers" :key="`${selectedScopeLabel}-${selectedSubscription}-${consumer.consumerName}-${consumer.address}`">
<td>{{ consumer.consumerName || "-" }}</td>
<td>{{ consumer.address || "-" }}</td>
<td>{{ consumer.clientVersion || "-" }}</td>
<td>{{ formatOptionalRate(consumer.msgRateOut) }}</td>
<td>{{ formatOptionalBytes(consumer.msgThroughputOut) }}</td>
<td>{{ consumer.availablePermits }}</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
</div>
</template>
@@ -592,12 +781,66 @@ watch(
font-weight: 600;
}
.panel-hint {
padding: 10px 16px;
font-size: 13px;
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-light);
background: var(--color-background-secondary);
}
.topic-picker {
padding: 12px 16px;
border-bottom: 1px solid var(--color-border-light);
background: var(--color-background-secondary);
}
.topic-picker label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
}
.topic-select-row {
display: flex;
align-items: center;
gap: 8px;
}
.topic-select-row select {
min-width: 240px;
max-width: 420px;
flex: 1;
height: 32px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
padding: 0 10px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.btn-secondary {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { MqSystemKind, TopicInfo } from "@/types/mq";
import MessageQueryPanel from "./MessageQueryPanel.vue";
interface Props {
connectionId: string;
tenant?: string;
namespace?: string;
topic?: TopicInfo;
readOnly?: boolean;
mqSystemKind?: MqSystemKind;
preferDlqTopic?: boolean;
}
withDefaults(defineProps<Props>(), {
preferDlqTopic: false,
});
</script>
<template>
<MessageQueryPanel embedded :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :topic="topic" :read-only="readOnly" :mq-system-kind="mqSystemKind" :prefer-dlq-topic="preferDlqTopic" />
</template>
@@ -0,0 +1,258 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { mqQueryMessageTrace } from "@/lib/backend/api";
import { formatRocketMqTraceError } from "@/lib/mq/rocketmqTraceUtils";
import { formatRocketMqTimestamp, parseRocketMqMessagesFromResult, rocketMqMessagePayload, type RocketMqDisplayMessage } from "@/lib/mq/rocketmqMessageUtils";
import { DEFAULT_ROCKETMQ_TRACE_TOPIC } from "@/lib/mq/rocketmqTopicTypes";
interface Props {
open: boolean;
connectionId: string;
msgId: string;
traceTopic?: string;
}
const props = withDefaults(defineProps<Props>(), {
traceTopic: DEFAULT_ROCKETMQ_TRACE_TOPIC,
});
const emit = defineEmits<{
close: [];
}>();
const { t } = useI18n();
const loading = ref(false);
const error = ref<string>();
const messages = ref<RocketMqDisplayMessage[]>([]);
async function loadTrace() {
const msgId = props.msgId.trim();
if (!msgId) {
error.value = t("mqTrace.msgIdRequired");
messages.value = [];
return;
}
loading.value = true;
error.value = undefined;
messages.value = [];
try {
const traceTopic = props.traceTopic.trim() || DEFAULT_ROCKETMQ_TRACE_TOPIC;
const result = await mqQueryMessageTrace(props.connectionId, msgId, traceTopic);
messages.value = parseRocketMqMessagesFromResult(result);
} catch (e: unknown) {
error.value = formatRocketMqTraceError(e, t("mqTrace.traceTopicRouteMissing"));
} finally {
loading.value = false;
}
}
watch(
() => [props.open, props.connectionId, props.msgId, props.traceTopic] as const,
([open]) => {
if (open) {
void loadTrace();
} else {
loading.value = false;
error.value = undefined;
messages.value = [];
}
},
);
</script>
<template>
<div v-if="open" class="dialog-overlay" @click="emit('close')">
<div class="dialog dialog-wide" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqTrace.detailTitle") }}</h3>
<button type="button" class="btn-close" @click="emit('close')">×</button>
</div>
<div class="dialog-body">
<div class="detail-grid">
<span>{{ t("mqMessages.tableMessageId") }}</span>
<span class="mono">{{ msgId || "-" }}</span>
<span>{{ t("mqTrace.traceTopic") }}</span>
<span class="mono">{{ traceTopic || DEFAULT_ROCKETMQ_TRACE_TOPIC }}</span>
</div>
<div v-if="loading" class="panel-placeholder">{{ t("mqTrace.querying") }}</div>
<div v-else-if="error" class="panel-error">{{ error }}</div>
<div v-else-if="!messages.length" class="panel-placeholder">{{ t("mqTrace.noTrace") }}</div>
<div v-else class="message-list">
<article v-for="(message, index) in messages" :key="`${message.messageId ?? index}-${message.timestamp ?? 0}`" class="message-row">
<div class="message-meta">
<span>#{{ index + 1 }}</span>
<span v-if="message.topic">{{ message.topic }}</span>
<span v-if="message.partition != null">{{ t("mqMessages.metaPartition", { partition: message.partition }) }}</span>
<span>{{ formatRocketMqTimestamp(message.timestamp) }}</span>
</div>
<pre class="message-payload">{{ rocketMqMessagePayload(message) }}</pre>
<div v-if="message.headers && Object.keys(message.headers).length" class="message-headers">
<span v-for="(value, key) in message.headers" :key="key">{{ key }}: {{ value }}</span>
</div>
</article>
</div>
</div>
<div class="dialog-footer">
<button type="button" class="btn-secondary" @click="emit('close')">{{ t("common.close") }}</button>
</div>
</div>
</div>
</template>
<style scoped>
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
.dialog {
width: min(860px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
overflow: auto;
border-radius: 10px;
background: var(--color-background);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--color-border);
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 14px 16px;
border-top: 1px solid var(--color-border);
}
.btn-close {
border: none;
background: none;
color: var(--color-text-secondary);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.dialog-body {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 16px;
overflow: auto;
}
.detail-grid {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px 12px;
font-size: 13px;
}
.detail-grid > span:nth-child(odd) {
color: var(--color-text-secondary);
font-weight: 500;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
}
.panel-placeholder {
padding: 24px;
text-align: center;
color: var(--color-text-secondary);
font-size: 14px;
}
.panel-error {
padding: 12px 14px;
border-radius: 6px;
background: var(--color-error-bg);
color: var(--color-error);
font-size: 13px;
white-space: pre-wrap;
}
.message-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.message-row {
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-secondary);
}
.message-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 12px;
color: var(--color-text-tertiary);
}
.message-payload {
margin: 8px 0 0;
padding: 10px;
max-height: 240px;
overflow: auto;
border-radius: 6px;
background: var(--color-background);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.message-headers {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.message-headers span {
padding: 2px 6px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 12px;
color: var(--color-text-secondary);
}
.btn-secondary {
padding: 7px 16px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
cursor: pointer;
}
</style>
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { PeekedMessage, TopicInfo, TopicRef, SendMessageRequest, SendMessageResponse } from "@/types/mq";
import type { MqSystemKind, PeekedMessage, TopicInfo, TopicRef, SendMessageRequest, SendMessageResponse } from "@/types/mq";
import { mqSendMessage, mqListTopics, mqPeekMessages } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
import { parseNonNegativeSafeInteger } from "@/lib/mq/mqPeekFilters";
import RocketMqTopicSelect from "./shared/RocketMqTopicSelect.vue";
interface Props {
connectionId: string;
@@ -12,8 +13,11 @@ interface Props {
namespace?: string;
topic?: TopicInfo;
readOnly?: boolean;
isKafkaCluster?: boolean;
mqSystemKind?: MqSystemKind;
isFlatMqCluster?: boolean;
supportsPeekMessages?: boolean;
/** Hide outer toolbar when embedded in a dialog. */
embedded?: boolean;
}
const props = defineProps<Props>();
@@ -21,6 +25,7 @@ const { t } = useI18n();
const topicName = ref("");
const messageKey = ref("");
const messageTag = ref("");
const messageValue = ref("");
const headersText = ref("");
const loading = ref(false);
@@ -28,6 +33,7 @@ const error = ref<string>();
const success = ref<SendMessageResponse>();
const availableTopics = ref<TopicInfo[]>([]);
const topicsLoading = ref(false);
const rocketMqTopicSelectRef = ref<InstanceType<typeof RocketMqTopicSelect>>();
const headersExpanded = ref(false);
const peekAdvancedExpanded = ref(false);
const peekLoading = ref(false);
@@ -40,6 +46,7 @@ const peekCount = ref(20);
let successTimer: ReturnType<typeof setTimeout> | undefined;
const readOnlyMessage = computed(() => t("mqMessages.readOnlyCannotSend"));
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
const topicOptions = computed(() => {
return availableTopics.value.map((t) => ({
@@ -62,7 +69,12 @@ const selectedTopicRef = computed<TopicRef | null>(() => {
};
});
const canBrowseMessages = computed(() => props.isKafkaCluster === true && props.supportsPeekMessages !== false);
const canBrowseMessages = computed(() => props.isFlatMqCluster === true && props.supportsPeekMessages !== false && !isRocketMqCluster.value);
function flatMqPeekGroupName(): string {
if (props.mqSystemKind === "rocketmq") return "__dbx_rocketmq_viewer__";
return "__dbx_kafka_viewer__";
}
const topicListId = computed(() => `mq-topic-options-${props.connectionId}`);
function clearSuccessLater() {
@@ -85,6 +97,10 @@ function guardWritable() {
}
async function loadTopics() {
if (isRocketMqCluster.value) {
await rocketMqTopicSelectRef.value?.loadTopics();
return;
}
if (!props.tenant || !props.namespace) return;
topicsLoading.value = true;
try {
@@ -106,6 +122,13 @@ async function loadTopics() {
}
}
function handleRocketMqTopicsLoaded(topics: TopicInfo[]) {
availableTopics.value = topics;
if (props.topic && !topicName.value) {
topicName.value = props.topic.shortName;
}
}
function parseHeaders(): Record<string, string> {
const result: Record<string, string> = {};
if (!headersText.value.trim()) return result;
@@ -138,6 +161,9 @@ async function sendMessage() {
loading.value = true;
try {
const headers = parseHeaders();
if (isRocketMqCluster.value && messageTag.value.trim()) {
headers.TAGS = messageTag.value.trim();
}
const payloadBase64 = btoa(unescape(encodeURIComponent(messageValue.value)));
const req: SendMessageRequest = {
topic,
@@ -192,7 +218,7 @@ async function loadMessages() {
options.offset = offset;
peekOffset.value = String(offset);
}
peekMessages.value = await mqPeekMessages(props.connectionId, topic, "__dbx_kafka_viewer__", count, options);
peekMessages.value = await mqPeekMessages(props.connectionId, topic, flatMqPeekGroupName(), count, options);
} catch (e: unknown) {
peekError.value = formatError(e);
} finally {
@@ -253,13 +279,15 @@ watch(
</script>
<template>
<div class="send-message-panel">
<div class="panel-toolbar">
<div class="send-message-panel" :class="{ embedded: embedded }">
<div v-if="!embedded" class="panel-toolbar">
<h3>{{ t("mqMessages.title") }}</h3>
<button @click="clearForm" :disabled="loading" class="btn-sm">{{ t("mqMessages.clear") }}</button>
</div>
<div v-if="!tenant || !namespace" class="panel-placeholder">{{ t("mqMessages.selectNamespaceOrTopicFirst") }}</div>
<div v-if="!tenant || !namespace" class="panel-placeholder">
{{ isRocketMqCluster ? t("mqRocketmq.connectionNotReady") : t("mqMessages.selectNamespaceOrTopicFirst") }}
</div>
<div v-else class="send-form">
<div v-if="readOnly" class="readonly-hint">{{ readOnlyMessage }}</div>
@@ -269,21 +297,23 @@ watch(
<span>{{ t("mqMessages.sendSuccess", { partition: success.partition, offset: success.offset }) }}</span>
</div>
<!-- 主题选择 -->
<div class="form-group">
<label>{{ t("mqMessages.targetTopic") }} <span class="required">*</span></label>
<div class="topic-select-row">
<input v-model="topicName" :list="topicListId" :disabled="readOnly || topicsLoading" class="topic-input" :placeholder="topicsLoading ? t('mqMessages.topicLoading') : t('mqMessages.topicSearchPlaceholder')" autocomplete="off" />
<datalist :id="topicListId">
<option v-for="t in topicOptions" :key="t.value" :value="t.value" :label="t.partitions != null ? $t('mqMessages.topicOptionWithPartitions', { label: t.label, partitions: t.partitions }) : t.label" />
</datalist>
<button @click="loadTopics" :disabled="topicsLoading" class="btn-icon" :title="t('mqMessages.refreshTopicList')">
<span v-if="topicsLoading" class="spin">⟳</span>
<span v-else>⟳</span>
</button>
</div>
<div v-if="!availableTopics.length && !topicsLoading" class="form-hint">{{ t("mqMessages.noTopicsAvailable") }}</div>
<div v-else class="form-hint">{{ t("mqMessages.topicSearchHint") }}</div>
<RocketMqTopicSelect v-if="isRocketMqCluster" ref="rocketMqTopicSelectRef" v-model="topicName" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" grouping="business" :show-type-filter="false" :disabled="readOnly" @loaded="handleRocketMqTopicsLoaded" />
<template v-else>
<div class="topic-select-row">
<input v-model="topicName" :list="topicListId" :disabled="readOnly || topicsLoading" class="topic-input" :placeholder="topicsLoading ? t('mqMessages.topicLoading') : t('mqMessages.topicSearchPlaceholder')" autocomplete="off" />
<datalist :id="topicListId">
<option v-for="t in topicOptions" :key="t.value" :value="t.value" :label="t.partitions != null ? $t('mqMessages.topicOptionWithPartitions', { label: t.label, partitions: t.partitions }) : t.label" />
</datalist>
<button @click="loadTopics" :disabled="topicsLoading" class="btn-icon" :title="t('mqMessages.refreshTopicList')">
<span v-if="topicsLoading" class="spin">⟳</span>
<span v-else>⟳</span>
</button>
</div>
<div v-if="!availableTopics.length && !topicsLoading" class="form-hint">{{ t("mqMessages.noTopicsAvailable") }}</div>
<div v-else class="form-hint">{{ t("mqMessages.topicSearchHint") }}</div>
</template>
</div>
<!-- 消息键 -->
@@ -292,6 +322,11 @@ watch(
<input v-model="messageKey" type="text" :placeholder="t('mqMessages.optional')" :disabled="readOnly" />
</div>
<div v-if="isRocketMqCluster" class="form-group">
<label>{{ t("mqMessages.messageTag") }}</label>
<input v-model="messageTag" type="text" :placeholder="t('mqMessages.optional')" :disabled="readOnly" />
</div>
<!-- 消息内容 -->
<div class="form-group">
<div class="label-row">
@@ -383,6 +418,10 @@ watch(
flex-direction: column;
}
.send-message-panel.embedded {
height: auto;
}
.panel-toolbar {
display: flex;
justify-content: space-between;
@@ -662,6 +701,19 @@ input[type="number"]:focus {
opacity: 0.9;
}
.query-mode-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.query-mode-tabs .btn-sm.active {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-primary-alpha);
}
.message-browser {
margin-top: 4px;
padding: 14px;
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { formatError } from "@/lib/backend/errorUtils";
import { ref, watch } from "vue";
import { ref, watch, computed } from "vue";
import { useI18n } from "vue-i18n";
import type { TopicRef, TopicInfo, SubscriptionInfo, ResetPosition, SkipCount, PeekedMessage } from "@/types/mq";
import type { TopicRef, TopicInfo, SubscriptionInfo, ResetPosition, SkipCount, PeekedMessage, MqSystemKind } from "@/types/mq";
import { mqListSubscriptions, mqCreateSubscription, mqDeleteSubscription, mqResetCursor, mqSkipMessages, mqClearBacklog, mqPeekMessages, mqExpireMessages } from "@/lib/backend/api";
import RocketMqConsumerGroupDialogs, { type RocketMqConsumerGroupDialogKind } from "./rocketmq/RocketMqConsumerGroupDialogs.vue";
import MqTypeFilterBar from "./shared/MqTypeFilterBar.vue";
import { DEFAULT_ROCKETMQ_CONSUMER_GROUP_TYPE_FILTERS, matchesRocketMqConsumerGroupTypeFilters, resolveRocketMqConsumerGroupMessageModel, resolveRocketMqConsumerGroupType, ROCKETMQ_CONSUMER_GROUP_TYPES, type RocketMqConsumerGroupType } from "@/lib/mq/rocketmqConsumerGroupTypes";
interface Props {
connectionId: string;
@@ -17,6 +20,8 @@ interface Props {
supportsClearBacklog?: boolean;
supportsPeekMessages?: boolean;
supportsExpireMessages?: boolean;
mqSystemKind?: MqSystemKind;
isFlatMqCluster?: boolean;
}
const props = defineProps<Props>();
@@ -55,6 +60,45 @@ const skipFormData = ref({
});
const expireSeconds = ref(3600);
const searchKeyword = ref("");
const activeRocketMqDialog = ref<RocketMqConsumerGroupDialogKind | null>(null);
const consumerGroupTypeFilters = ref<Record<RocketMqConsumerGroupType, boolean>>({
...DEFAULT_ROCKETMQ_CONSUMER_GROUP_TYPE_FILTERS,
});
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
// RocketMQ consumer tab always lists cluster-wide groups (Dashboard groupList.query), regardless of breadcrumb topic.
const isClusterWideMode = computed(() => isRocketMqCluster.value && !!props.tenant && !!props.namespace);
const canShowPanel = computed(() => isClusterWideMode.value || !!props.topic);
const rocketMqConsumerGroupTypeOptions = ROCKETMQ_CONSUMER_GROUP_TYPES;
const panelTitle = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.consumerGroupTitle") : t("mqSubscriptions.title")));
const searchPlaceholder = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.searchConsumerGroup") : t("mqSubscriptions.searchPlaceholder")));
const filteredSubscriptions = computed(() => {
let rows = subscriptions.value;
if (isRocketMqCluster.value) {
rows = rows.filter((sub) => matchesRocketMqConsumerGroupTypeFilters(sub, consumerGroupTypeFilters.value));
}
const keyword = searchKeyword.value.trim().toLowerCase();
if (!keyword) return rows;
return rows.filter((sub) => sub.name.toLowerCase().includes(keyword));
});
function consumerGroupTypeLabel(sub: SubscriptionInfo): string {
const type = resolveRocketMqConsumerGroupType(sub);
return t(`mqSubscriptions.rocketmqGroupType.${type.toLowerCase()}`);
}
function consumerGroupTypeBadgeClass(sub: SubscriptionInfo): string {
const type = resolveRocketMqConsumerGroupType(sub);
if (type === "FIFO") return "badge badge-info";
if (type === "SYSTEM") return "badge badge-muted";
return "badge";
}
function consumerGroupModeLabel(sub: SubscriptionInfo): string {
const mode = resolveRocketMqConsumerGroupMessageModel(sub);
return t(`mqSubscriptions.rocketmqGroupMode.${mode.toLowerCase()}`);
}
function guardWritable() {
if (props.readOnly) {
@@ -64,8 +108,29 @@ function guardWritable() {
return true;
}
function getTopicRef(): TopicRef | null {
if (!props.topic || !props.tenant || !props.namespace) return null;
function getListTopicRef(): TopicRef | null {
if (!props.tenant || !props.namespace) return null;
if (isClusterWideMode.value) {
return {
tenant: props.tenant,
namespace: props.namespace,
topic: "",
persistent: true,
partitioned: false,
};
}
if (!props.topic) return null;
return {
tenant: props.tenant,
namespace: props.namespace,
topic: props.topic.shortName,
persistent: props.topic.persistent,
partitioned: props.topic.partitioned,
};
}
function getPulsarTopicRef(): TopicRef | null {
if (!props.tenant || !props.namespace || !props.topic) return null;
return {
tenant: props.tenant,
namespace: props.namespace,
@@ -76,7 +141,7 @@ function getTopicRef(): TopicRef | null {
}
async function loadSubscriptions() {
const topicRef = getTopicRef();
const topicRef = getListTopicRef();
if (!topicRef) {
subscriptions.value = [];
return;
@@ -101,6 +166,21 @@ function openCreateDialog() {
showCreateDialog.value = true;
}
function openRocketMqDetail(sub: SubscriptionInfo) {
selectedSub.value = sub;
activeRocketMqDialog.value = "detail";
}
function openRocketMqConfig(sub: SubscriptionInfo) {
if (!guardWritable()) return;
selectedSub.value = sub;
activeRocketMqDialog.value = "config";
}
function closeRocketMqDialog() {
activeRocketMqDialog.value = null;
}
function openResetDialog(sub: SubscriptionInfo) {
if (!guardWritable()) return;
selectedSub.value = sub;
@@ -121,12 +201,12 @@ function openSkipDialog(sub: SubscriptionInfo) {
showSkipDialog.value = true;
}
async function openPeekDialog(sub: SubscriptionInfo) {
function openPeekDialog(sub: SubscriptionInfo) {
selectedSub.value = sub;
peekCount.value = 5;
peekedMessages.value = [];
showPeekDialog.value = true;
await handlePeekMessages();
void handlePeekMessages();
}
function openExpireDialog(sub: SubscriptionInfo) {
@@ -137,13 +217,17 @@ function openExpireDialog(sub: SubscriptionInfo) {
}
function selectSubscription(sub: SubscriptionInfo) {
if (isRocketMqCluster.value) {
openRocketMqDetail(sub);
return;
}
selectedSub.value = sub;
emit("subscriptionSelected", sub.name);
}
async function handleCreate() {
if (!guardWritable()) return;
const topicRef = getTopicRef();
const topicRef = getPulsarTopicRef();
if (!formData.value.subName.trim() || !topicRef) {
error.value = t("mqSubscriptions.subscriptionNameRequired");
return;
@@ -165,7 +249,7 @@ async function handleCreate() {
async function handleDelete(sub: SubscriptionInfo) {
if (!guardWritable()) return;
if (!confirm(t("mqSubscriptions.confirmDelete", { name: sub.name }))) return;
const topicRef = getTopicRef();
const topicRef = getListTopicRef();
if (!topicRef) return;
loading.value = true;
error.value = undefined;
@@ -181,7 +265,7 @@ async function handleDelete(sub: SubscriptionInfo) {
async function handleResetCursor() {
if (!guardWritable()) return;
const topicRef = getTopicRef();
const topicRef = getPulsarTopicRef();
if (!selectedSub.value || !topicRef) return;
loading.value = true;
error.value = undefined;
@@ -204,7 +288,7 @@ async function handleResetCursor() {
async function handleSkipMessages() {
if (!guardWritable()) return;
const topicRef = getTopicRef();
const topicRef = getPulsarTopicRef();
if (!selectedSub.value || !topicRef) return;
loading.value = true;
error.value = undefined;
@@ -223,7 +307,7 @@ async function handleSkipMessages() {
async function handleClearBacklog(sub: SubscriptionInfo) {
if (!guardWritable()) return;
if (!confirm(t("mqSubscriptions.confirmClearBacklog", { name: sub.name }))) return;
const topicRef = getTopicRef();
const topicRef = getPulsarTopicRef();
if (!topicRef) return;
loading.value = true;
error.value = undefined;
@@ -238,7 +322,7 @@ async function handleClearBacklog(sub: SubscriptionInfo) {
}
async function handlePeekMessages() {
const topicRef = getTopicRef();
const topicRef = getPulsarTopicRef();
if (!selectedSub.value || !topicRef) return;
const count = Math.max(1, Math.min(100, Number(peekCount.value) || 1));
peekCount.value = count;
@@ -255,7 +339,7 @@ async function handlePeekMessages() {
async function handleExpireMessages() {
if (!guardWritable()) return;
const topicRef = getTopicRef();
const topicRef = getPulsarTopicRef();
if (!selectedSub.value || !topicRef) return;
loading.value = true;
error.value = undefined;
@@ -271,7 +355,7 @@ async function handleExpireMessages() {
}
watch(
() => props.topic,
() => [props.topic, props.tenant, props.namespace, props.mqSystemKind],
() => {
selectedSub.value = undefined;
loadSubscriptions();
@@ -283,55 +367,94 @@ watch(
<template>
<div class="subscriptions-panel">
<div class="panel-toolbar">
<h3>{{ t("mqSubscriptions.title") }}</h3>
<button v-if="supportsCreateSubscription !== false" @click="openCreateDialog" :disabled="loading || readOnly || !topic" class="btn-primary">+ {{ t("mqSubscriptions.createSubscription") }}</button>
<div class="toolbar-left">
<h3>{{ panelTitle }}</h3>
<input v-if="isClusterWideMode" v-model="searchKeyword" type="search" class="topic-search" :placeholder="searchPlaceholder" />
<span v-if="isClusterWideMode && subscriptions.length" class="topic-count"> {{ filteredSubscriptions.length }} / {{ subscriptions.length }} </span>
</div>
<div class="toolbar-actions">
<button v-if="isClusterWideMode" class="btn-secondary" :disabled="loading" @click="loadSubscriptions">
{{ loading ? t("mqSubscriptions.refreshing") : t("mqSubscriptions.refresh") }}
</button>
<button v-if="supportsCreateSubscription !== false && !isRocketMqCluster" @click="openCreateDialog" :disabled="loading || readOnly || !topic" class="btn-primary">+ {{ t("mqSubscriptions.createSubscription") }}</button>
</div>
</div>
<div v-if="!topic" class="panel-placeholder">{{ t("mqSubscriptions.selectTopicFirst") }}</div>
<MqTypeFilterBar v-if="isRocketMqCluster && tenant && namespace" :label="t('mqSubscriptions.typeFilter')">
<label v-for="type in rocketMqConsumerGroupTypeOptions" :key="type" class="checkbox-label compact">
<input v-model="consumerGroupTypeFilters[type]" type="checkbox" />
{{ t(`mqSubscriptions.rocketmqGroupType.${type.toLowerCase()}`) }}
</label>
</MqTypeFilterBar>
<div v-else-if="error" class="panel-error">{{ error }}</div>
<div v-if="!canShowPanel" class="panel-placeholder">{{ t("mqSubscriptions.selectTopicFirst") }}</div>
<div v-else-if="loading && !subscriptions.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
<template v-else>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-else-if="!subscriptions.length" class="panel-placeholder">{{ t("mqSubscriptions.noSubscriptions") }}</div>
<div v-else-if="loading && !subscriptions.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
<div v-else class="subscriptions-table">
<table>
<thead>
<tr>
<th>{{ t("mqSubscriptions.subscriptionName") }}</th>
<th>{{ t("mqSubscriptions.type") }}</th>
<th>{{ t("mqSubscriptions.backlog") }}</th>
<th>{{ t("mqSubscriptions.consumeRate") }}</th>
<th>{{ t("mqSubscriptions.consumers") }}</th>
<th>{{ t("mqSubscriptions.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="sub in subscriptions" :key="sub.name" :class="{ selected: selectedSub?.name === sub.name }" @click="selectSubscription(sub)">
<td class="sub-name">{{ sub.name }}</td>
<td>
<span class="badge">{{ sub.subType }}</span>
</td>
<td>
<span :class="{ 'text-warning': sub.msgBacklog > 1000 }">
{{ sub.msgBacklog.toLocaleString() }}
</span>
</td>
<td>{{ t("mqSubscriptions.msgRate", { rate: sub.msgRateOut.toFixed(2) }) }}</td>
<td>{{ t("mqSubscriptions.consumerCount", { count: sub.consumers.length }) }}</td>
<td class="actions">
<button v-if="supportsResetCursor !== false" @click.stop="openResetDialog(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.resetCursor") }}</button>
<button v-if="supportsSkipMessages !== false" @click.stop="openSkipDialog(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.skipMessages") }}</button>
<button v-if="supportsClearBacklog !== false" @click.stop="handleClearBacklog(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.clearBacklog") }}</button>
<button v-if="supportsPeekMessages" @click.stop="openPeekDialog(sub)" class="btn-sm">{{ t("mqSubscriptions.peek") }}</button>
<button v-if="supportsExpireMessages !== false" @click.stop="openExpireDialog(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.expireMessages") }}</button>
<button @click.stop="handleDelete(sub)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqSubscriptions.delete") }}</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else-if="!filteredSubscriptions.length" class="panel-placeholder">
{{ isClusterWideMode ? t("mqSubscriptions.noConsumerGroups") : t("mqSubscriptions.noSubscriptions") }}
</div>
<div v-else class="subscriptions-table">
<table>
<thead>
<tr>
<th>{{ t("mqSubscriptions.subscriptionName") }}</th>
<th>{{ t("mqSubscriptions.type") }}</th>
<th v-if="isRocketMqCluster">{{ t("mqSubscriptions.mode") }}</th>
<th v-if="isClusterWideMode">{{ t("mqSubscriptions.subscribedTopics") }}</th>
<th v-if="!isClusterWideMode">{{ t("mqSubscriptions.backlog") }}</th>
<th v-if="!isClusterWideMode">{{ t("mqSubscriptions.consumeRate") }}</th>
<th>{{ t("mqSubscriptions.consumers") }}</th>
<th>{{ t("mqSubscriptions.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="sub in filteredSubscriptions" :key="sub.name" :class="{ selected: selectedSub?.name === sub.name }" @click="selectSubscription(sub)">
<td class="sub-name">{{ sub.name }}</td>
<td>
<span v-if="isRocketMqCluster" :class="consumerGroupTypeBadgeClass(sub)">{{ consumerGroupTypeLabel(sub) }}</span>
<span v-else class="badge">{{ sub.subType }}</span>
</td>
<td v-if="isRocketMqCluster">
<span class="badge badge-outline">{{ consumerGroupModeLabel(sub) }}</span>
</td>
<td v-if="isClusterWideMode" class="topics-cell">
<span v-if="sub.topics?.length">{{ sub.topics.join(", ") }}</span>
<span v-else class="text-muted">-</span>
</td>
<td v-if="!isClusterWideMode">
<span :class="{ 'text-warning': sub.msgBacklog > 1000 }">
{{ sub.msgBacklog.toLocaleString() }}
</span>
</td>
<td v-if="!isClusterWideMode">{{ t("mqSubscriptions.msgRate", { rate: sub.msgRateOut.toFixed(2) }) }}</td>
<td>{{ isClusterWideMode ? (sub.onlineMembers ?? 0) : t("mqSubscriptions.consumerCount", { count: sub.consumers.length }) }}</td>
<td class="actions">
<template v-if="isRocketMqCluster">
<button @click.stop="openRocketMqDetail(sub)" class="btn-sm">{{ t("mqSubscriptions.viewDetail") }}</button>
<button @click.stop="openRocketMqConfig(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.editConfig") }}</button>
<button @click.stop="handleDelete(sub)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqSubscriptions.delete") }}</button>
</template>
<template v-else>
<button v-if="supportsResetCursor !== false" @click.stop="openResetDialog(sub)" :disabled="readOnly || !topic" class="btn-sm">{{ t("mqSubscriptions.resetCursor") }}</button>
<button v-if="supportsSkipMessages !== false" @click.stop="openSkipDialog(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.skipMessages") }}</button>
<button v-if="supportsClearBacklog !== false" @click.stop="handleClearBacklog(sub)" :disabled="readOnly || !topic" class="btn-sm">{{ t("mqSubscriptions.clearBacklog") }}</button>
<button v-if="supportsPeekMessages" @click.stop="openPeekDialog(sub)" :disabled="!topic" class="btn-sm">{{ t("mqSubscriptions.peek") }}</button>
<button v-if="supportsExpireMessages !== false" @click.stop="openExpireDialog(sub)" :disabled="readOnly" class="btn-sm">{{ t("mqSubscriptions.expireMessages") }}</button>
<button @click.stop="handleDelete(sub)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqSubscriptions.delete") }}</button>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<RocketMqConsumerGroupDialogs v-if="isRocketMqCluster" :connection-id="connectionId" :tenant="tenant" :namespace="namespace" :group="selectedSub" :dialog="activeRocketMqDialog" :read-only="readOnly" @close="closeRocketMqDialog" @refreshed="loadSubscriptions" />
<!-- Create Dialog -->
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
@@ -372,7 +495,7 @@ watch(
</div>
<!-- Reset Cursor Dialog -->
<div v-if="showResetDialog" class="dialog-overlay" @click="showResetDialog = false">
<div v-if="!isRocketMqCluster && showResetDialog" class="dialog-overlay" @click="showResetDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqSubscriptions.resetDialogTitle", { name: selectedSub?.name }) }}</h3>
@@ -411,7 +534,7 @@ watch(
</div>
<!-- Skip Messages Dialog -->
<div v-if="showSkipDialog" class="dialog-overlay" @click="showSkipDialog = false">
<div v-if="!isRocketMqCluster && showSkipDialog" class="dialog-overlay" @click="showSkipDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqSubscriptions.skipDialogTitle", { name: selectedSub?.name }) }}</h3>
@@ -445,7 +568,7 @@ watch(
</div>
<!-- Peek Messages Dialog -->
<div v-if="showPeekDialog" class="dialog-overlay" @click="showPeekDialog = false">
<div v-if="!isRocketMqCluster && showPeekDialog" class="dialog-overlay" @click="showPeekDialog = false">
<div class="dialog dialog-wide" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqSubscriptions.peekDialogTitle", { name: selectedSub?.name }) }}</h3>
@@ -485,7 +608,7 @@ watch(
</div>
<!-- Expire Messages Dialog -->
<div v-if="showExpireDialog" class="dialog-overlay" @click="showExpireDialog = false">
<div v-if="!isRocketMqCluster && showExpireDialog" class="dialog-overlay" @click="showExpireDialog = false">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqSubscriptions.expireDialogTitle", { name: selectedSub?.name }) }}</h3>
@@ -519,16 +642,131 @@ watch(
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.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: 6px;
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;
}
.btn-secondary {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hover);
}
.subscription-type-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
padding: 10px 16px;
border-bottom: 1px solid var(--color-border-light, var(--color-border));
background: var(--color-background-secondary);
}
.subscription-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;
}
.badge-outline {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.badge-info {
background: color-mix(in srgb, #3b82f6 18%, transparent);
color: #2563eb;
}
.badge-muted {
background: color-mix(in srgb, var(--color-text-secondary) 12%, transparent);
color: var(--color-text-secondary);
}
.topics-cell {
max-width: 280px;
word-break: break-all;
}
.panel-toolbar h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.search-input {
min-width: 220px;
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 13px;
background: var(--color-background);
}
.topics-cell {
max-width: 280px;
word-break: break-word;
}
.panel-placeholder,
.panel-error,
.panel-loading {
@@ -722,13 +960,16 @@ button:disabled {
}
.form-group input[type="text"],
.form-group input[type="number"] {
.form-group input[type="number"],
.form-group select.topic-select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
background: var(--color-background);
color: var(--color-text);
}
.form-group input:disabled {
+337 -24
View File
@@ -1,9 +1,15 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { useI18n } from "vue-i18n";
import type { NamespaceRef, TopicRef, TopicInfo, ListTopicsOpts } from "@/types/mq";
import { mqListTopics, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions } from "@/lib/backend/api";
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 MqTypeFilterBar from "./shared/MqTypeFilterBar.vue";
import type { MqTab } from "@/lib/mq/mqConsoleDefaults";
import { formatError } from "@/lib/backend/errorUtils";
import { DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS, isProtectedRocketMqTopic, isRocketMqBusinessMessageType, matchesRocketMqTypeFilters, resolveRocketMqMessageType, ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES, ROCKETMQ_TOPIC_MESSAGE_TYPES } from "@/lib/mq/rocketmqTopicTypes";
interface Props {
connectionId: string;
@@ -11,12 +17,14 @@ interface Props {
namespace?: string;
readOnly?: boolean;
supportsPartitionedTopics?: boolean;
isKafkaCluster?: boolean;
isFlatMqCluster?: boolean;
mqSystemKind?: MqSystemKind;
}
const props = defineProps<Props>();
const emit = defineEmits<{
topicSelected: [topic: TopicInfo];
navigateTab: [payload: { tab: MqTab; topic?: TopicInfo; subscription?: string; preferDlqTopic?: boolean }];
}>();
const { t } = useI18n();
@@ -31,24 +39,96 @@ const selectedTopic = ref<TopicInfo>();
const editingTopic = ref<TopicInfo>();
const topicSearch = ref("");
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 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 topics.value;
return topics.value.filter((topic) => {
if (!query) return typeFilteredTopics.value;
return typeFilteredTopics.value.filter((topic) => {
return topic.name.toLowerCase().includes(query) || topic.shortName.toLowerCase().includes(query);
});
});
const userTopicCount = computed(() => {
if (isRocketMqCluster.value) {
return topics.value.filter((topic) => isRocketMqBusinessMessageType(resolveRocketMqMessageType(topic))).length;
}
return topics.value.filter((topic) => !topic.internal).length;
});
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;
@@ -86,18 +166,88 @@ async function loadTopics() {
}
}
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 (!guardWritable()) return;
dialogError.value = undefined;
const defaultBroker = rocketMqMasterBrokers.value[0]?.brokerName ?? rocketMqBrokerOptions.value[0]?.brokerName ?? "";
formData.value = {
topicName: "",
persistent: true,
partitioned: props.isKafkaCluster ?? false,
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 (!guardWritable()) return;
dialogError.value = undefined;
@@ -125,6 +275,13 @@ async function handleCreate() {
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;
@@ -212,6 +369,15 @@ watch(
() => {
selectedTopic.value = undefined;
loadTopics();
if (isRocketMqCluster.value) void loadClusterInfo();
},
{ immediate: true },
);
watch(
() => props.mqSystemKind,
() => {
if (isRocketMqCluster.value) void loadClusterInfo();
},
{ immediate: true },
);
@@ -233,9 +399,13 @@ watch(newPartitions, () => {
<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 }} / {{ topics.length }}</span>
<label class="checkbox-label">
<input type="checkbox" v-model="includeNonPersistent" />
<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>
@@ -247,6 +417,13 @@ watch(newPartitions, () => {
</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>
@@ -255,7 +432,9 @@ watch(newPartitions, () => {
<div v-else-if="!topics.length" class="panel-placeholder">{{ t("mqTopics.noTopics") }}</div>
<div v-else-if="!filteredTopics.length" class="panel-placeholder">{{ t("mqTopics.noMatches") }}</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">
<table>
@@ -263,7 +442,7 @@ watch(newPartitions, () => {
<tr>
<th>{{ t("mqTopics.name") }}</th>
<th>{{ t("mqTopics.type") }}</th>
<th>{{ t("mqTopics.partitions") }}</th>
<th v-if="!isRocketMqCluster">{{ t("mqTopics.partitions") }}</th>
<th>{{ t("mqTopics.actions") }}</th>
</tr>
</thead>
@@ -276,19 +455,35 @@ watch(newPartitions, () => {
</div>
</td>
<td>
<span class="badge" :class="topic.partitioned ? 'badge-info' : 'badge-default'">
{{ topic.partitioned ? t("mqTopics.partitionedTopic") : t("mqTopics.normalTopic") }}
<span class="badge" :class="topicTypeBadgeClass(topic)">
{{ topicTypeLabel(topic) }}
</span>
</td>
<td>
<td v-if="!isRocketMqCluster">
<span v-if="topic.partitioned">{{ topic.partitions ? t("mqTopics.partitionCount", { count: topic.partitions }) : t("mqTopics.partitionsUnknown") }}</span>
<span v-else class="text-muted">-</span>
</td>
<td class="actions">
<button v-if="topic.partitioned && supportsPartitionedTopics !== false" @click.stop="openPartitionsDialog(topic)" :disabled="readOnly || !topic.partitions" class="btn-sm">
{{ t("mqTopics.adjustPartitions") }}
</button>
<button @click.stop="handleDelete(topic)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqTopics.delete") }}</button>
<td class="actions" @click.stop>
<template v-if="isRocketMqCluster">
<button class="btn-sm" @click="openRocketMqDialog('status', topic)">{{ t("mqTopics.actionStatus") }}</button>
<button class="btn-sm" @click="openRocketMqDialog('route', topic)">{{ t("mqTopics.actionRoute") }}</button>
<button class="btn-sm" @click="openRocketMqDialog('consumers', topic)">{{ t("mqTopics.actionConsumers") }}</button>
<button v-if="isDlqTopic(topic)" class="btn-sm" @click="navigateToMessageQuery(topic, true)">{{ t("mqRocketmq.viewDlqMessages") }}</button>
<template v-else>
<button class="btn-sm" @click="navigateToMessageQuery(topic)">{{ t("mqRocketmq.actionMessageQuery") }}</button>
<button class="btn-sm" :disabled="readOnly" @click="navigateToMessages(topic)">{{ t("mqRocketmq.actionSendMessage") }}</button>
</template>
<button class="btn-sm" @click="openRocketMqDialog('config', topic)">{{ t("mqTopics.actionConfig") }}</button>
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('reset', topic)">{{ t("mqTopics.actionReset") }}</button>
<button class="btn-sm" :disabled="readOnly || isTopicProtected(topic)" @click="openRocketMqDialog('skip', topic)">{{ t("mqTopics.actionSkip") }}</button>
<button class="btn-sm btn-danger" :disabled="readOnly || isTopicProtected(topic)" @click="handleDelete(topic)">{{ t("mqTopics.delete") }}</button>
</template>
<template v-else>
<button v-if="topic.partitioned && supportsPartitionedTopics !== false && !isTopicProtected(topic)" @click="openPartitionsDialog(topic)" :disabled="readOnly || !topic.partitions" class="btn-sm">
{{ t("mqTopics.adjustPartitions") }}
</button>
<button @click="handleDelete(topic)" :disabled="readOnly || isTopicProtected(topic)" class="btn-sm btn-danger">{{ t("mqTopics.delete") }}</button>
</template>
</td>
</tr>
</tbody>
@@ -303,22 +498,60 @@ watch(newPartitions, () => {
<button @click="showCreateDialog = false" class="btn-close">×</button>
</div>
<div class="dialog-body">
<div v-if="!isKafkaCluster" class="form-group">
<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 class="form-group">
<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="supportsPartitionedTopics !== false" class="form-group">
<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") }}
@@ -363,6 +596,32 @@ watch(newPartitions, () => {
</div>
</div>
</div>
<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>
</div>
</template>
@@ -380,11 +639,34 @@ watch(newPartitions, () => {
.panel-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
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;
@@ -572,7 +854,27 @@ td {
.actions {
display: flex;
gap: 8px;
gap: 6px;
flex-wrap: wrap;
align-items: center;
min-width: 420px;
}
.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: 4px;
font-size: 14px;
box-sizing: border-box;
background: var(--color-background);
color: var(--color-text);
}
.btn-primary,
@@ -723,4 +1025,15 @@ button:disabled {
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>
@@ -0,0 +1,488 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { ConsumerInfo, RocketMqConsumerGroupConfig, SubscriptionInfo, TopicRef } from "@/types/mq";
import { mqAlterConsumerGroupConfig, mqGetBacklog, mqGetConsumerGroupConfig, mqListConsumers } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
import { resolveRocketMqConsumerGroupMessageModel, resolveRocketMqConsumerGroupType } from "@/lib/mq/rocketmqConsumerGroupTypes";
export type RocketMqConsumerGroupDialogKind = "detail" | "config";
interface Props {
connectionId: string;
tenant?: string;
namespace?: string;
group?: SubscriptionInfo;
dialog?: RocketMqConsumerGroupDialogKind | null;
readOnly?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
close: [];
refreshed: [];
}>();
const { t } = useI18n();
const loading = ref(false);
const dialogError = ref<string>();
const terminals = ref<ConsumerInfo[]>([]);
const topicBacklogs = ref<Array<{ topic: string; backlog: number }>>([]);
const configForm = ref<RocketMqConsumerGroupConfig>({
groupName: "",
consumeEnable: true,
consumeFromMinEnable: false,
consumeBroadcastEnable: false,
consumeMessageOrderly: false,
retryQueueNums: 1,
retryMaxTimes: 16,
brokerId: 0,
whichBrokerWhenConsumeSlowly: 0,
});
const subscribedTopics = computed(() => [...new Set((props.group?.topics ?? []).map((topic) => topic.trim()).filter(Boolean))]);
const groupTypeLabel = computed(() => {
if (!props.group) return "-";
return t(`mqSubscriptions.rocketmqGroupType.${resolveRocketMqConsumerGroupType(props.group).toLowerCase()}`);
});
const groupModeLabel = computed(() => {
if (!props.group) return "-";
return t(`mqSubscriptions.rocketmqGroupMode.${resolveRocketMqConsumerGroupMessageModel(props.group).toLowerCase()}`);
});
function buildTopicRef(topicName: string): TopicRef | null {
if (!props.tenant || !props.namespace) return null;
return {
tenant: props.tenant,
namespace: props.namespace,
topic: topicName,
persistent: true,
partitioned: false,
};
}
function closeDialog() {
dialogError.value = undefined;
emit("close");
}
async function loadDetail() {
if (!props.group || !props.tenant || !props.namespace) return;
loading.value = true;
dialogError.value = undefined;
terminals.value = [];
topicBacklogs.value = [];
try {
const topicRef = buildTopicRef("");
if (topicRef) {
terminals.value = await mqListConsumers(props.connectionId, topicRef, props.group.name);
}
const topics = subscribedTopics.value;
const backlogRows = await Promise.all(
topics.map(async (topic) => {
const ref = buildTopicRef(topic);
if (!ref) return { topic, backlog: 0 };
try {
const stats = await mqGetBacklog(props.connectionId, ref, props.group!.name);
return { topic, backlog: stats.msgBacklog };
} catch {
return { topic, backlog: 0 };
}
}),
);
topicBacklogs.value = backlogRows;
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
loading.value = false;
}
}
function resetConfigForm(groupName: string) {
configForm.value = {
groupName,
consumeEnable: true,
consumeFromMinEnable: false,
consumeBroadcastEnable: false,
consumeMessageOrderly: false,
retryQueueNums: 1,
retryMaxTimes: 16,
brokerId: 0,
whichBrokerWhenConsumeSlowly: 0,
};
}
async function loadConfig() {
if (!props.group) return;
resetConfigForm(props.group.name);
loading.value = true;
dialogError.value = undefined;
try {
const config = await mqGetConsumerGroupConfig(props.connectionId, props.group.name);
configForm.value = {
groupName: config.groupName || props.group.name,
consumeEnable: config.consumeEnable ?? true,
consumeFromMinEnable: config.consumeFromMinEnable ?? false,
consumeBroadcastEnable: config.consumeBroadcastEnable ?? false,
consumeMessageOrderly: config.consumeMessageOrderly ?? false,
retryQueueNums: config.retryQueueNums ?? 1,
retryMaxTimes: config.retryMaxTimes ?? 16,
brokerId: config.brokerId ?? 0,
whichBrokerWhenConsumeSlowly: config.whichBrokerWhenConsumeSlowly ?? 0,
};
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
loading.value = false;
}
}
async function saveConfig() {
if (!props.group || props.readOnly) return;
loading.value = true;
dialogError.value = undefined;
try {
await mqAlterConsumerGroupConfig(props.connectionId, props.group.name, {
consumeEnable: configForm.value.consumeEnable,
consumeFromMinEnable: configForm.value.consumeFromMinEnable,
consumeBroadcastEnable: configForm.value.consumeBroadcastEnable,
consumeMessageOrderly: configForm.value.consumeMessageOrderly,
retryQueueNums: configForm.value.retryQueueNums,
retryMaxTimes: configForm.value.retryMaxTimes,
brokerId: configForm.value.brokerId,
whichBrokerWhenConsumeSlowly: configForm.value.whichBrokerWhenConsumeSlowly,
});
emit("refreshed");
closeDialog();
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
loading.value = false;
}
}
watch(
() => [props.dialog, props.group?.name],
() => {
if (props.dialog === "detail") {
void loadDetail();
} else if (props.dialog === "config") {
void loadConfig();
}
},
{ immediate: true },
);
</script>
<template>
<div v-if="dialog && group" class="dialog-overlay" @click="closeDialog">
<div class="dialog" :class="{ 'dialog-wide': dialog === 'detail' }" @click.stop>
<div class="dialog-header">
<h3>
{{ dialog === "detail" ? t("mqSubscriptions.consumerDetailTitle", { name: group.name }) : t("mqSubscriptions.consumerConfigTitle", { name: group.name }) }}
</h3>
<button class="btn-close" @click="closeDialog">×</button>
</div>
<div class="dialog-body">
<div v-if="dialog === 'detail'" class="detail-sections">
<section class="detail-section">
<h4>{{ t("mqSubscriptions.consumerOverview") }}</h4>
<div class="detail-grid">
<span>{{ t("mqSubscriptions.type") }}</span
><span>{{ groupTypeLabel }}</span> <span>{{ t("mqSubscriptions.mode") }}</span
><span>{{ groupModeLabel }}</span> <span>{{ t("mqSubscriptions.consumers") }}</span
><span>{{ group.onlineMembers ?? 0 }}</span>
<span>{{ t("mqSubscriptions.subscribedTopics") }}</span>
<span>{{ subscribedTopics.length ? subscribedTopics.join(", ") : "-" }}</span>
</div>
</section>
<section class="detail-section">
<div class="section-heading">
<h4>{{ t("mqSubscriptions.consumerTerminals") }}</h4>
<button class="btn-sm" :disabled="loading" @click="loadDetail">
{{ loading ? t("mqSubscriptions.loading") : t("mqSubscriptions.refresh") }}
</button>
</div>
<div v-if="loading && !terminals.length" class="panel-loading">{{ t("mqSubscriptions.loading") }}</div>
<div v-else-if="!terminals.length" class="panel-placeholder">{{ t("mqSubscriptions.noOnlineConsumers") }}</div>
<table v-else class="detail-table">
<thead>
<tr>
<th>{{ t("mqClients.name") }}</th>
<th>{{ t("mqClients.address") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="terminal in terminals" :key="`${terminal.consumerName}-${terminal.address}`">
<td>{{ terminal.consumerName }}</td>
<td>{{ terminal.address || "-" }}</td>
</tr>
</tbody>
</table>
</section>
<section class="detail-section">
<h4>{{ t("mqSubscriptions.consumerConsumeDetail") }}</h4>
<div v-if="!topicBacklogs.length" class="panel-placeholder">{{ t("mqSubscriptions.noConsumeDetail") }}</div>
<table v-else class="detail-table">
<thead>
<tr>
<th>{{ t("mqSubscriptions.operationTopic") }}</th>
<th>{{ t("mqSubscriptions.backlog") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in topicBacklogs" :key="row.topic">
<td>{{ row.topic }}</td>
<td>{{ row.backlog.toLocaleString() }}</td>
</tr>
</tbody>
</table>
</section>
</div>
<div v-else class="config-form">
<div class="form-group">
<label>{{ t("mqSubscriptions.subscriptionName") }}</label>
<input type="text" :value="configForm.groupName || group.name" disabled />
</div>
<div class="form-group checkbox-row">
<label class="checkbox-label">
<input v-model="configForm.consumeEnable" type="checkbox" :disabled="readOnly" />
{{ t("mqSubscriptions.consumeEnable") }}
</label>
<label class="checkbox-label">
<input v-model="configForm.consumeBroadcastEnable" type="checkbox" :disabled="readOnly" />
{{ t("mqSubscriptions.consumeBroadcastEnable") }}
</label>
<label class="checkbox-label">
<input v-model="configForm.consumeFromMinEnable" type="checkbox" :disabled="readOnly" />
{{ t("mqSubscriptions.consumeFromMinEnable") }}
</label>
<label class="checkbox-label">
<input v-model="configForm.consumeMessageOrderly" type="checkbox" :disabled="readOnly" />
{{ t("mqSubscriptions.consumeMessageOrderly") }}
</label>
</div>
<div class="form-row">
<div class="form-group">
<label>{{ t("mqSubscriptions.retryQueueNums") }}</label>
<input v-model.number="configForm.retryQueueNums" type="number" min="1" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqSubscriptions.retryMaxTimes") }}</label>
<input v-model.number="configForm.retryMaxTimes" type="number" min="0" :disabled="readOnly" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>{{ t("mqSubscriptions.brokerId") }}</label>
<input v-model.number="configForm.brokerId" type="number" min="0" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqSubscriptions.whichBrokerWhenConsumeSlowly") }}</label>
<input v-model.number="configForm.whichBrokerWhenConsumeSlowly" type="number" min="0" :disabled="readOnly" />
</div>
</div>
</div>
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
</div>
<div class="dialog-footer">
<button class="btn-secondary" @click="closeDialog">{{ t("mqSubscriptions.close") }}</button>
<button v-if="dialog === 'config'" class="btn-primary" :disabled="loading || readOnly" @click="saveConfig">
{{ t("mqSubscriptions.save") }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.dialog-overlay {
position: fixed;
inset: 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: 8px;
width: 92%;
max-width: 560px;
max-height: 86vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dialog-wide {
max-width: 860px;
}
.dialog-header,
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
}
.dialog-footer {
justify-content: flex-end;
border-bottom: 0;
border-top: 1px solid var(--color-border);
}
.dialog-header h3 {
margin: 0;
font-size: 18px;
}
.dialog-body {
padding: 20px;
overflow: auto;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
}
.detail-sections {
display: grid;
gap: 18px;
}
.detail-section h4,
.section-heading h4 {
margin: 0 0 10px;
font-size: 14px;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.detail-grid {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px 12px;
font-size: 13px;
}
.detail-table {
width: 100%;
border-collapse: collapse;
}
.detail-table th,
.detail-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--color-border-light);
text-align: left;
font-size: 13px;
}
.detail-table th {
color: var(--color-text-secondary);
font-weight: 600;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="number"] {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
box-sizing: border-box;
background: var(--color-background);
color: var(--color-text);
}
.form-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.checkbox-row {
display: grid;
gap: 8px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.panel-loading,
.panel-placeholder {
padding: 16px;
text-align: center;
color: var(--color-text-secondary);
font-size: 13px;
}
.form-error {
margin-top: 12px;
padding: 8px 12px;
background: var(--color-error-bg);
color: var(--color-error);
border-radius: 4px;
font-size: 13px;
}
.btn-primary,
.btn-secondary,
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,705 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { BrokerNode, ResetPosition, SubscriptionInfo, TopicInfo, TopicRef } from "@/types/mq";
import { mqAlterTopicConfig, mqGetTopicInternalStats, mqGetTopicRoute, mqGetTopicStats, mqListSubscriptions, mqResetCursor, mqSkipTopicAccumulation } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
export type RocketMqTopicDialogKind = "status" | "route" | "consumers" | "config" | "reset" | "skip";
interface PartitionStatRow {
partition: unknown;
brokerName: unknown;
beginOffset: unknown;
endOffset: unknown;
messageCount: unknown;
}
interface QueueDataRow {
brokerName: unknown;
readQueueNums: unknown;
writeQueueNums: unknown;
perm: unknown;
}
interface BrokerDataRow {
brokerName: unknown;
cluster: unknown;
brokerAddrs: unknown;
}
interface Props {
connectionId: string;
tenant?: string;
namespace?: string;
topic?: TopicInfo;
dialog?: RocketMqTopicDialogKind | null;
readOnly?: boolean;
brokerOptions?: BrokerNode[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
close: [];
navigate: [payload: { tab: "subscriptions" | "messages"; subscription?: string }];
refreshed: [];
}>();
const { t } = useI18n();
const loading = ref(false);
const dialogError = ref<string>();
const statsRaw = ref<unknown>();
const routeRaw = ref<unknown>();
const configForm = ref({
readQueueNums: 8,
writeQueueNums: 8,
perm: 6,
});
const subscriptions = ref<SubscriptionInfo[]>([]);
const selectedSubscription = ref("");
const resetMode = ref<"earliest" | "latest" | "timestamp">("latest");
const resetTimestamp = ref("");
const topicRef = computed<TopicRef | null>(() => {
if (!props.topic || !props.tenant || !props.namespace) return null;
return {
tenant: props.tenant,
namespace: props.namespace,
topic: props.topic.shortName,
persistent: props.topic.persistent,
partitioned: props.topic.partitioned,
};
});
function mapPartitionStatRows(list: unknown): PartitionStatRow[] {
if (!Array.isArray(list)) return [];
return list.map((row) => {
const item = row && typeof row === "object" ? (row as Record<string, unknown>) : {};
return {
partition: item.partition,
brokerName: item.brokerName,
beginOffset: item.beginOffset,
endOffset: item.endOffset,
messageCount: item.messageCount,
};
});
}
function mapQueueDataRows(list: unknown): QueueDataRow[] {
if (!Array.isArray(list)) return [];
return list.map((row) => {
const item = row && typeof row === "object" ? (row as Record<string, unknown>) : {};
return {
brokerName: item.brokerName,
readQueueNums: item.readQueueNums,
writeQueueNums: item.writeQueueNums,
perm: item.perm,
};
});
}
function mapBrokerDataRows(list: unknown): BrokerDataRow[] {
if (!Array.isArray(list)) return [];
return list.map((row) => {
const item = row && typeof row === "object" ? (row as Record<string, unknown>) : {};
return {
brokerName: item.brokerName,
cluster: item.cluster,
brokerAddrs: item.brokerAddrs,
};
});
}
const partitionStats = computed(() => {
const raw = statsRaw.value;
if (!raw || typeof raw !== "object") return [];
return mapPartitionStatRows((raw as Record<string, unknown>).partitionStats);
});
const queueDatas = computed(() => {
const raw = routeRaw.value;
if (!raw || typeof raw !== "object") return [];
return mapQueueDataRows((raw as Record<string, unknown>).queueDatas);
});
const brokerDatas = computed(() => {
const raw = routeRaw.value;
if (!raw || typeof raw !== "object") return [];
return mapBrokerDataRows((raw as Record<string, unknown>).brokerDatas);
});
const dialogTitle = computed(() => {
const name = props.topic?.shortName ?? "";
switch (props.dialog) {
case "status":
return t("mqTopics.topicStatusTitle", { name });
case "route":
return t("mqTopics.topicRouteTitle", { name });
case "consumers":
return t("mqTopics.topicConsumersTitle", { name });
case "config":
return t("mqTopics.topicConfigTitle", { name });
case "reset":
return t("mqTopics.topicResetTitle", { name });
case "skip":
return t("mqTopics.topicSkipTitle", { name });
default:
return "";
}
});
function closeDialog() {
dialogError.value = undefined;
emit("close");
}
function parseConfigEntries(raw: unknown): Array<{ key: string; value: string }> {
if (!raw || typeof raw !== "object") return [];
const configs = (raw as Record<string, unknown>).configs;
if (!configs || typeof configs !== "object") return [];
const editableKeys = new Set(["readQueueNums", "writeQueueNums", "perm"]);
return Object.entries(configs as Record<string, unknown>)
.filter(([key]) => editableKeys.has(key))
.map(([key, val]) => {
if (val && typeof val === "object" && "value" in (val as object)) {
return { key, value: String((val as Record<string, unknown>).value ?? "") };
}
return { key, value: String(val ?? "") };
});
}
function applyConfigEntriesToForm(entries: Array<{ key: string; value: string }>) {
for (const entry of entries) {
const numeric = Number(entry.value);
if (!Number.isFinite(numeric)) continue;
if (entry.key === "readQueueNums") configForm.value.readQueueNums = numeric;
if (entry.key === "writeQueueNums") configForm.value.writeQueueNums = numeric;
if (entry.key === "perm") configForm.value.perm = numeric;
}
}
function sortResetSubscriptions(subs: SubscriptionInfo[], topicName: string): SubscriptionInfo[] {
return [...subs].sort((left, right) => {
const leftSubscribed = left.topics?.includes(topicName) ? 0 : 1;
const rightSubscribed = right.topics?.includes(topicName) ? 0 : 1;
if (leftSubscribed !== rightSubscribed) return leftSubscribed - rightSubscribed;
return left.name.localeCompare(right.name);
});
}
async function loadResetSubscriptions(topic: TopicRef): Promise<SubscriptionInfo[]> {
const topicSpecific = await mqListSubscriptions(props.connectionId, topic);
if (topicSpecific.length > 0) {
return sortResetSubscriptions(topicSpecific, topic.topic);
}
// Reset offset uses force=true; fall back to all cluster groups when the topic has no committed offsets yet.
const clusterWide: TopicRef = { ...topic, topic: "" };
return sortResetSubscriptions(await mqListSubscriptions(props.connectionId, clusterWide), topic.topic);
}
function formatDateTimeLocal(date: Date): string {
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
async function loadDialogData() {
const topic = topicRef.value;
if (!topic || !props.dialog) return;
loading.value = true;
dialogError.value = undefined;
try {
if (props.dialog === "status") {
const stats = await mqGetTopicStats(props.connectionId, topic);
statsRaw.value = stats.raw ?? stats;
} else if (props.dialog === "route") {
routeRaw.value = await mqGetTopicRoute(props.connectionId, topic);
} else if (props.dialog === "consumers") {
subscriptions.value = await mqListSubscriptions(props.connectionId, topic);
selectedSubscription.value = subscriptions.value[0]?.name ?? "";
} else if (props.dialog === "reset") {
subscriptions.value = await loadResetSubscriptions(topic);
selectedSubscription.value = subscriptions.value[0]?.name ?? "";
if (resetMode.value === "timestamp" && !resetTimestamp.value) {
resetTimestamp.value = formatDateTimeLocal(new Date());
}
} else if (props.dialog === "config") {
const internal = await mqGetTopicInternalStats(props.connectionId, topic);
applyConfigEntriesToForm(parseConfigEntries(internal));
}
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
loading.value = false;
}
}
async function saveConfig() {
const topic = topicRef.value;
if (!topic || props.readOnly) return;
loading.value = true;
dialogError.value = undefined;
try {
const configs = [
{ key: "readQueueNums", value: String(configForm.value.readQueueNums), op: "set" },
{ key: "writeQueueNums", value: String(configForm.value.writeQueueNums), op: "set" },
{ key: "perm", value: String(configForm.value.perm), op: "set" },
];
await mqAlterTopicConfig(props.connectionId, topic, configs);
emit("refreshed");
closeDialog();
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
loading.value = false;
}
}
async function confirmSkip() {
const topic = topicRef.value;
if (!topic || props.readOnly) return;
if (!confirm(t("mqTopics.confirmSkip", { name: topic.topic }))) return;
loading.value = true;
dialogError.value = undefined;
try {
await mqSkipTopicAccumulation(props.connectionId, topic);
emit("refreshed");
closeDialog();
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
loading.value = false;
}
}
async function confirmReset() {
const topic = topicRef.value;
if (!topic || props.readOnly || !selectedSubscription.value) return;
loading.value = true;
dialogError.value = undefined;
try {
let pos: ResetPosition;
if (resetMode.value === "earliest") {
pos = { kind: "earliest" };
} else if (resetMode.value === "timestamp") {
const ts = Date.parse(resetTimestamp.value);
if (!Number.isFinite(ts)) {
dialogError.value = t("mqTopics.invalidTimestamp");
return;
}
pos = { kind: "timestamp", timestampMs: ts };
} else {
pos = { kind: "latest" };
}
await mqResetCursor(props.connectionId, topic, selectedSubscription.value, pos);
emit("refreshed");
closeDialog();
} catch (e: unknown) {
dialogError.value = formatError(e);
} finally {
loading.value = false;
}
}
function navigateToSubscriptions(sub?: string) {
emit("navigate", { tab: "subscriptions", subscription: sub });
closeDialog();
}
watch(resetMode, (mode) => {
if (mode === "timestamp" && !resetTimestamp.value) {
resetTimestamp.value = formatDateTimeLocal(new Date());
}
});
watch(
() => [props.dialog, props.topic?.name],
() => {
statsRaw.value = undefined;
routeRaw.value = undefined;
configForm.value = { readQueueNums: 8, writeQueueNums: 8, perm: 6 };
subscriptions.value = [];
selectedSubscription.value = "";
resetMode.value = "latest";
resetTimestamp.value = "";
if (props.dialog) void loadDialogData();
},
{ immediate: true },
);
</script>
<template>
<div v-if="dialog && topic" class="dialog-overlay" @click="closeDialog">
<div class="dialog dialog-wide" @click.stop>
<div class="dialog-header">
<h3>{{ dialogTitle }}</h3>
<button class="btn-close" @click="closeDialog">×</button>
</div>
<div class="dialog-body">
<div v-if="loading" class="dialog-loading">{{ t("mqTopics.loading") }}</div>
<div v-else-if="dialogError" class="form-error">{{ dialogError }}</div>
<template v-else-if="dialog === 'status'">
<div v-if="!partitionStats.length" class="dialog-empty">{{ t("mqTopics.noQueueStats") }}</div>
<table v-else class="data-table">
<thead>
<tr>
<th>{{ t("mqTopics.queueId") }}</th>
<th>{{ t("mqTopics.brokerName") }}</th>
<th>{{ t("mqTopics.beginOffset") }}</th>
<th>{{ t("mqTopics.endOffset") }}</th>
<th>{{ t("mqTopics.messageCount") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in partitionStats" :key="idx">
<td>{{ row.partition }}</td>
<td>{{ row.brokerName }}</td>
<td>{{ row.beginOffset }}</td>
<td>{{ row.endOffset }}</td>
<td>{{ row.messageCount }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="dialog === 'route'">
<h4>{{ t("mqTopics.routeQueues") }}</h4>
<table v-if="queueDatas.length" class="data-table">
<thead>
<tr>
<th>{{ t("mqTopics.brokerName") }}</th>
<th>{{ t("mqTopics.readQueues") }}</th>
<th>{{ t("mqTopics.writeQueues") }}</th>
<th>{{ t("mqTopics.perm") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in queueDatas" :key="idx">
<td>{{ row.brokerName }}</td>
<td>{{ row.readQueueNums }}</td>
<td>{{ row.writeQueueNums }}</td>
<td>{{ row.perm }}</td>
</tr>
</tbody>
</table>
<div v-else class="dialog-empty">{{ t("mqTopics.noRouteData") }}</div>
<h4>{{ t("mqTopics.routeBrokers") }}</h4>
<table v-if="brokerDatas.length" class="data-table">
<thead>
<tr>
<th>{{ t("mqTopics.brokerName") }}</th>
<th>{{ t("mqTopics.clusterName") }}</th>
<th>{{ t("mqTopics.brokerAddrs") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in brokerDatas" :key="`b-${idx}`">
<td>{{ row.brokerName }}</td>
<td>{{ row.cluster }}</td>
<td class="mono">{{ JSON.stringify(row.brokerAddrs ?? {}) }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="dialog === 'consumers'">
<div v-if="!subscriptions.length" class="dialog-empty">{{ t("mqTopics.noConsumers") }}</div>
<table v-else class="data-table">
<thead>
<tr>
<th>{{ t("mqSubscriptions.subscriptionName") }}</th>
<th>{{ t("mqSubscriptions.type") }}</th>
<th>{{ t("mqSubscriptions.backlog") }}</th>
<th>{{ t("mqTopics.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="sub in subscriptions" :key="sub.name">
<td>{{ sub.name }}</td>
<td>{{ sub.subType || "-" }}</td>
<td>{{ sub.msgBacklog }}</td>
<td>
<button class="btn-sm" @click="navigateToSubscriptions(sub.name)">{{ t("mqTopics.manageConsumers") }}</button>
</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="dialog === 'config'">
<p class="config-hint">{{ t("mqTopics.topicConfigHint") }}</p>
<div class="form-group">
<label>{{ t("mqTopics.readQueues") }}</label>
<input v-model.number="configForm.readQueueNums" type="number" min="1" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqTopics.writeQueues") }}</label>
<input v-model.number="configForm.writeQueueNums" type="number" min="1" :disabled="readOnly" />
</div>
<div class="form-group">
<label>{{ t("mqTopics.perm") }}</label>
<select v-model.number="configForm.perm" :disabled="readOnly">
<option :value="6">{{ t("mqTopics.permReadWrite") }}</option>
<option :value="4">{{ t("mqTopics.permRead") }}</option>
<option :value="2">{{ t("mqTopics.permWrite") }}</option>
</select>
</div>
</template>
<template v-else-if="dialog === 'reset'">
<div class="form-group">
<label>{{ t("mqSubscriptions.subscriptionName") }}</label>
<select v-model="selectedSubscription" :disabled="readOnly || !subscriptions.length">
<option v-if="!subscriptions.length" disabled value="">{{ t("mqTopics.noConsumerGroups") }}</option>
<option v-for="sub in subscriptions" :key="sub.name" :value="sub.name">{{ sub.name }}</option>
</select>
</div>
<div class="form-group">
<label>{{ t("mqTopics.resetPosition") }}</label>
<select v-model="resetMode" :disabled="readOnly">
<option value="latest">{{ t("mqTopics.positionLatest") }}</option>
<option value="earliest">{{ t("mqTopics.positionEarliest") }}</option>
<option value="timestamp">{{ t("mqTopics.positionTimestamp") }}</option>
</select>
</div>
<div v-if="resetMode === 'timestamp'" class="form-group">
<label>{{ t("mqTopics.timestamp") }}</label>
<input v-model="resetTimestamp" class="datetime-input" type="datetime-local" step="60" :disabled="readOnly" />
<div v-if="resetTimestamp" class="form-hint">
{{ t("mqTopics.resetTimePreview", { time: new Date(resetTimestamp).toLocaleString() }) }}
</div>
</div>
</template>
<template v-else-if="dialog === 'skip'">
<p>{{ t("mqTopics.skipHint") }}</p>
</template>
</div>
<div class="dialog-footer">
<button class="btn-secondary" @click="closeDialog">{{ t("mqTopics.cancel") }}</button>
<button v-if="dialog === 'config' && !readOnly" class="btn-primary" :disabled="loading" @click="saveConfig">
{{ t("mqTopics.saveConfig") }}
</button>
<button v-else-if="dialog === 'reset' && !readOnly" class="btn-primary" :disabled="loading || !selectedSubscription" @click="confirmReset">
{{ t("mqTopics.actionReset") }}
</button>
<button v-else-if="dialog === 'skip' && !readOnly" class="btn-primary" :disabled="loading" @click="confirmSkip">
{{ t("mqTopics.actionSkip") }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.dialog-overlay {
position: fixed;
inset: 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: 8px;
width: 90%;
max-width: 520px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dialog-wide {
max-width: 760px;
}
.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;
}
.btn-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-secondary);
}
.dialog-body {
padding: 20px;
overflow-y: auto;
}
.dialog-body h4 {
margin: 16px 0 8px;
font-size: 14px;
color: var(--color-text-secondary);
}
.dialog-body h4:first-child {
margin-top: 0;
}
.dialog-loading,
.dialog-empty {
padding: 16px;
text-align: center;
color: var(--color-text-secondary);
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin-bottom: 12px;
}
.data-table th,
.data-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--color-border);
text-align: left;
}
.data-table th {
color: var(--color-text-secondary);
font-weight: 600;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
word-break: break-all;
}
.config-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.config-row {
display: grid;
grid-template-columns: 1fr 1.5fr;
gap: 8px;
}
.config-key,
.config-value {
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 13px;
}
.form-group {
margin-bottom: 14px;
}
.config-hint {
margin: 0 0 14px;
color: var(--color-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
}
.form-group select,
.form-group input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
box-sizing: border-box;
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
}
.form-group select {
appearance: auto;
}
.datetime-input {
color-scheme: light dark;
}
.datetime-input::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0.75;
}
.form-hint {
margin-top: 6px;
color: var(--color-text-secondary);
font-size: 12px;
}
.form-error {
padding: 8px 12px;
background: var(--color-error-bg);
color: var(--color-error);
border-radius: 4px;
font-size: 13px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--color-border);
}
.btn-primary,
.btn-secondary,
.btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
cursor: pointer;
font-size: 13px;
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
button:disabled,
input:disabled,
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,21 @@
<script setup lang="ts">
defineProps<{
title: string;
}>();
</script>
<template>
<div class="mq-panel-toolbar">
<div class="mq-toolbar-left">
<h3>{{ title }}</h3>
<slot name="left-extra" />
</div>
<div v-if="$slots.actions" class="mq-toolbar-actions">
<slot name="actions" />
</div>
</div>
</template>
<style scoped>
@import "./mqPanel.css";
</style>
@@ -0,0 +1,19 @@
<script setup lang="ts">
defineProps<{
modelValue: string;
placeholder?: string;
disabled?: boolean;
}>();
defineEmits<{
"update:modelValue": [value: string];
}>();
</script>
<template>
<input type="search" class="mq-search-input" :value="modelValue" :placeholder="placeholder" :disabled="disabled" @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)" />
</template>
<style scoped>
@import "./mqPanel.css";
</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
defineProps<{
label: string;
}>();
</script>
<template>
<div class="mq-type-filter-bar">
<span class="mq-type-filter-bar-label">{{ label }}</span>
<slot />
</div>
</template>
<style scoped>
.mq-type-filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
padding: 10px 16px;
border-bottom: 1px solid var(--color-border-light, var(--color-border));
background: var(--color-background-secondary);
}
.mq-type-filter-bar-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
margin-right: 4px;
}
</style>
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import type { SendMessageResponse } from "@/types/mq";
import { formatRocketMqTimestamp } from "@/lib/mq/rocketmqMessageUtils";
import type { RocketMqDisplayMessage } from "@/lib/mq/rocketmqMessageUtils";
interface Props {
message?: RocketMqDisplayMessage;
detailLoading?: boolean;
detailError?: string;
resendLoading?: boolean;
resendError?: string;
resendSuccess?: SendMessageResponse;
payloadCopied?: boolean;
resendTopic?: string;
formattedDetailPayload?: string;
detailHeaders?: Array<[string, string]>;
canResend?: boolean;
canViewTrace?: boolean;
readOnly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
detailLoading: false,
payloadCopied: false,
canResend: false,
canViewTrace: false,
readOnly: false,
});
const emit = defineEmits<{
close: [];
copy: [];
resend: [];
viewTrace: [];
}>();
const { t } = useI18n();
</script>
<template>
<div v-if="message" class="dialog-overlay" @click="emit('close')">
<div class="dialog dialog-wide" @click.stop>
<div class="dialog-header">
<h3>{{ t("mqMessages.messageDetails") }}</h3>
<button type="button" class="btn-close" @click="emit('close')">×</button>
</div>
<div class="dialog-body">
<div v-if="detailLoading" class="results-empty">{{ t("mqMessages.messagesLoading") }}</div>
<template v-else>
<div v-if="detailError" class="panel-error">{{ detailError }}</div>
<div v-if="resendSuccess" class="panel-success">
{{ t("mqMessages.sendSuccess", { partition: resendSuccess.partition, offset: resendSuccess.offset }) }}
</div>
<div v-if="resendError" class="panel-error">{{ resendError }}</div>
<section class="detail-section">
<h4>{{ t("mqMessages.messageOverview") }}</h4>
<div class="detail-grid">
<span>{{ t("mqMessages.queryTopicLabel") }}</span>
<span class="mono">{{ resendTopic || "-" }}</span>
<span>{{ t("mqMessages.tableMessageId") }}</span>
<span class="mono">{{ message.messageId ?? "-" }}</span>
<span>{{ t("mqMessages.tableTag") }}</span>
<span>{{ message.tag ?? "-" }}</span>
<span>{{ t("mqMessages.tableKey") }}</span>
<span>{{ message.key ?? "-" }}</span>
<span>{{ t("mqMessages.tableStoreTime") }}</span>
<span>{{ formatRocketMqTimestamp(message.timestamp) }}</span>
<span v-if="message.partition != null">{{ t("mqMessages.partition") }}</span>
<span v-if="message.partition != null">{{ message.partition }}</span>
</div>
</section>
<section v-if="detailHeaders?.length" class="detail-section">
<h4>{{ t("mqMessages.messageHeaders") }}</h4>
<div class="detail-grid">
<template v-for="[headerKey, headerValue] in detailHeaders" :key="headerKey">
<span>{{ headerKey }}</span>
<span class="mono">{{ headerValue }}</span>
</template>
</div>
</section>
<section class="detail-section">
<div class="section-heading">
<h4>{{ t("mqMessages.messageBody") }}</h4>
<button type="button" class="mq-btn-sm" :disabled="!formattedDetailPayload" @click="emit('copy')">
{{ payloadCopied ? t("mqMessages.payloadCopied") : t("common.copy") }}
</button>
</div>
<pre class="detail-payload">{{ formattedDetailPayload || "-" }}</pre>
</section>
</template>
</div>
<div class="dialog-footer">
<button type="button" class="mq-btn-secondary" @click="emit('close')">{{ t("common.close") }}</button>
<button v-if="canResend" type="button" class="mq-btn-primary" :disabled="detailLoading || resendLoading || !canResend" :title="readOnly ? t('mqMessages.readOnlyCannotSend') : undefined" @click="emit('resend')">
{{ resendLoading ? t("mqMessages.sending") : t("mqMessages.resendMessage") }}
</button>
<button v-if="canViewTrace && message.messageId" type="button" class="mq-btn-secondary" @click="emit('viewTrace')">
{{ t("mqTrace.viewTrace") }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
@import "./mqPanel.css";
.results-empty {
padding: 24px;
text-align: center;
color: var(--color-text-secondary);
}
.detail-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.detail-section h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(120px, 160px) 1fr;
gap: 8px 16px;
font-size: 13px;
}
.detail-grid > span:nth-child(odd) {
color: var(--color-text-secondary);
}
.mono {
font-family: var(--font-mono, ui-monospace, monospace);
word-break: break-all;
}
.detail-payload {
margin: 0;
padding: 12px;
border-radius: 6px;
background: var(--color-background-secondary);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
max-height: 320px;
overflow: auto;
}
.panel-error {
padding: 10px 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--color-error) 12%, transparent);
color: var(--color-error);
font-size: 13px;
}
.panel-success {
padding: 10px 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--color-success, #22c55e) 12%, transparent);
color: var(--color-success, #16a34a);
font-size: 13px;
}
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
.dialog {
width: min(720px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.dialog-wide {
width: min(860px, calc(100vw - 32px));
}
.dialog-header,
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--color-border);
}
.dialog-footer {
border-bottom: none;
border-top: 1px solid var(--color-border);
justify-content: flex-end;
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.dialog-body {
padding: 16px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.btn-close {
border: none;
background: none;
font-size: 22px;
line-height: 1;
cursor: pointer;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,485 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Search } from "@lucide/vue";
import type { TopicInfo } from "@/types/mq";
import { mqListTopics } from "@/lib/backend/api";
import { isRocketMqBusinessMessageType, resolveRocketMqMessageType } from "@/lib/mq/rocketmqTopicTypes";
type TopicGrouping = "business" | "dlq" | "all";
type TopicTypeFilter = "all" | "business" | "dlq";
interface Props {
connectionId: string;
tenant?: string;
namespace?: string;
modelValue: string;
grouping?: TopicGrouping;
disabled?: boolean;
placeholder?: string;
showTypeFilter?: boolean;
showHint?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
grouping: "all",
disabled: false,
placeholder: undefined,
showTypeFilter: true,
showHint: false,
});
const emit = defineEmits<{
"update:modelValue": [value: string];
loaded: [topics: TopicInfo[]];
/** Fired when the user picks a topic from the dropdown list. */
select: [value: string];
}>();
const { t } = useI18n();
const topics = ref<TopicInfo[]>([]);
const loading = ref(false);
const typeFilter = ref<TopicTypeFilter>("all");
const searchText = ref("");
const dropdownOpen = ref(false);
const highlightIndex = ref(-1);
const rootRef = ref<HTMLElement>();
const inputRef = ref<HTMLInputElement>();
const listboxId = computed(() => `mq-topic-listbox-${props.connectionId}-${props.tenant ?? ""}-${props.namespace ?? ""}`);
const effectiveTypeFilter = computed<TopicTypeFilter>(() => {
if (props.grouping === "business") return "business";
if (props.grouping === "dlq") return "dlq";
return typeFilter.value;
});
const filteredTopics = computed(() => {
if (effectiveTypeFilter.value === "business") {
return topics.value.filter((topic) => isRocketMqBusinessMessageType(resolveRocketMqMessageType(topic)));
}
if (effectiveTypeFilter.value === "dlq") {
return topics.value.filter((topic) => resolveRocketMqMessageType(topic) === "DLQ");
}
return topics.value;
});
const topicOptions = computed(() => filteredTopics.value.map((topic) => topic.shortName));
const visibleOptions = computed(() => {
const keyword = searchText.value.trim().toLowerCase();
if (!keyword) return topicOptions.value;
return topicOptions.value.filter((option) => option.toLowerCase().includes(keyword));
});
const inputPlaceholder = computed(() => props.placeholder ?? (loading.value ? t("mqMessages.topicLoading") : t("mqMessages.selectTopicPlaceholder")));
const showCommittedOverlay = computed(() => dropdownOpen.value && !!props.modelValue);
const activeOptionId = computed(() => (highlightIndex.value >= 0 ? `${listboxId.value}-option-${highlightIndex.value}` : undefined));
function topicTypeLabel(topicName: string): string | undefined {
const topic = topics.value.find((item) => item.shortName === topicName);
if (!topic) return undefined;
const type = resolveRocketMqMessageType(topic);
return t(`mqTopics.rocketmqType.${type.toLowerCase()}`);
}
function isKnownTopic(topicName: string): boolean {
const name = topicName.trim();
if (!name) return false;
return topicOptions.value.includes(name);
}
function syncHighlightToSelection() {
const selectedIndex = visibleOptions.value.findIndex((option) => option === props.modelValue);
highlightIndex.value = selectedIndex >= 0 ? selectedIndex : visibleOptions.value.length ? 0 : -1;
}
async function focusSearchInput() {
await nextTick();
const input = inputRef.value;
if (!input) return;
input.focus();
input.setSelectionRange(0, 0);
}
function openDropdown() {
if (props.disabled || loading.value) return;
searchText.value = "";
dropdownOpen.value = true;
syncHighlightToSelection();
void focusSearchInput();
}
function closeDropdown() {
dropdownOpen.value = false;
highlightIndex.value = -1;
searchText.value = "";
}
function selectOption(option: string) {
emit("update:modelValue", option);
emit("select", option);
closeDropdown();
}
function handleInput(event: Event) {
searchText.value = (event.target as HTMLInputElement).value;
if (!dropdownOpen.value) dropdownOpen.value = true;
highlightIndex.value = visibleOptions.value.length ? 0 : -1;
}
function handleWrapMouseDown(event: MouseEvent) {
if (props.disabled || loading.value) return;
if ((event.target as HTMLElement).closest(".mq-btn-icon")) return;
if (!dropdownOpen.value) {
event.preventDefault();
openDropdown();
}
}
function handleBlur() {
window.setTimeout(() => {
if (rootRef.value?.contains(document.activeElement)) return;
closeDropdown();
}, 0);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault();
if (!dropdownOpen.value) openDropdown();
if (!visibleOptions.value.length) return;
highlightIndex.value = highlightIndex.value < visibleOptions.value.length - 1 ? highlightIndex.value + 1 : 0;
} else if (event.key === "ArrowUp") {
event.preventDefault();
if (!dropdownOpen.value) openDropdown();
if (!visibleOptions.value.length) return;
highlightIndex.value = highlightIndex.value > 0 ? highlightIndex.value - 1 : visibleOptions.value.length - 1;
} else if (event.key === "Enter") {
if (!dropdownOpen.value || highlightIndex.value < 0 || highlightIndex.value >= visibleOptions.value.length) return;
event.preventDefault();
selectOption(visibleOptions.value[highlightIndex.value]);
} else if (event.key === "Escape") {
event.preventDefault();
closeDropdown();
inputRef.value?.blur();
} else if (event.key === "Backspace" && showCommittedOverlay.value && !searchText.value) {
event.preventDefault();
}
}
function onDocumentPointerDown(event: PointerEvent) {
if (!dropdownOpen.value) return;
const root = rootRef.value;
if (root && !root.contains(event.target as Node)) {
closeDropdown();
}
}
async function loadTopics() {
if (!props.tenant || !props.namespace) {
topics.value = [];
emit("loaded", []);
return;
}
loading.value = true;
try {
topics.value = await mqListTopics(
props.connectionId,
{
tenant: props.tenant,
namespace: props.namespace,
},
{ includeNonPersistent: false },
);
emit("loaded", topics.value);
} catch (e: unknown) {
console.warn("[DBX] Failed to load RocketMQ topics:", e);
topics.value = [];
emit("loaded", []);
} finally {
loading.value = false;
}
}
function setTypeFilter(filter: TopicTypeFilter) {
if (props.grouping !== "all") return;
typeFilter.value = filter;
}
watch(filteredTopics, (nextTopics) => {
if (!props.modelValue) return;
if (!nextTopics.some((topic) => topic.shortName === props.modelValue)) {
emit("update:modelValue", "");
}
});
watch(dropdownOpen, (open) => {
if (open) {
document.addEventListener("pointerdown", onDocumentPointerDown, true);
return;
}
document.removeEventListener("pointerdown", onDocumentPointerDown, true);
});
watch(
() => [props.tenant, props.namespace, props.connectionId],
() => {
void loadTopics();
},
{ immediate: true },
);
onUnmounted(() => {
document.removeEventListener("pointerdown", onDocumentPointerDown, true);
});
defineExpose({
loadTopics,
topics,
loading,
isKnownTopic,
topicTypeLabel,
resolveTopicType: (topicName: string) => {
const topic = topics.value.find((item) => item.shortName === topicName.trim());
return topic ? resolveRocketMqMessageType(topic) : undefined;
},
});
</script>
<template>
<div class="rocketmq-topic-select">
<div v-if="showTypeFilter && grouping === 'all'" class="mq-topic-type-chips">
<button type="button" class="mq-topic-type-chip" :class="{ active: typeFilter === 'all' }" @click="setTypeFilter('all')">
{{ t("mqRocketmq.topicFilterAll") }}
</button>
<button type="button" class="mq-topic-type-chip" :class="{ active: typeFilter === 'business' }" @click="setTypeFilter('business')">
{{ t("mqRocketmq.topicFilterBusiness") }}
</button>
<button type="button" class="mq-topic-type-chip" :class="{ active: typeFilter === 'dlq' }" @click="setTypeFilter('dlq')">
{{ t("mqRocketmq.topicFilterDlq") }}
</button>
</div>
<div class="mq-topic-select-row">
<div ref="rootRef" class="topic-combobox">
<div class="topic-combobox-input-wrap" :class="{ open: dropdownOpen, disabled: disabled || loading }" @mousedown="handleWrapMouseDown">
<template v-if="dropdownOpen">
<div class="topic-combobox-field">
<span v-if="showCommittedOverlay && !searchText" class="committed-overlay" aria-hidden="true">{{ modelValue }}</span>
<input
ref="inputRef"
class="topic-combobox-input"
:value="searchText"
:placeholder="showCommittedOverlay ? '' : inputPlaceholder"
:disabled="disabled || loading"
role="combobox"
aria-autocomplete="list"
:aria-expanded="dropdownOpen"
:aria-controls="listboxId"
:aria-activedescendant="activeOptionId"
autocomplete="off"
@blur="handleBlur"
@input="handleInput"
@keydown="handleKeydown"
/>
</div>
</template>
<button v-else type="button" class="topic-combobox-trigger" :disabled="disabled || loading" :title="modelValue || inputPlaceholder">
<span v-if="modelValue" class="committed-value">{{ modelValue }}</span>
<span v-else class="committed-placeholder">{{ inputPlaceholder }}</span>
</button>
<Search class="topic-combobox-search-icon" aria-hidden="true" />
</div>
<div v-if="dropdownOpen" :id="listboxId" class="topic-combobox-dropdown" role="listbox">
<div v-if="loading" class="topic-combobox-empty">{{ t("mqMessages.topicLoading") }}</div>
<template v-else-if="visibleOptions.length">
<button
v-for="(option, index) in visibleOptions"
:id="`${listboxId}-option-${index}`"
:key="option"
type="button"
role="option"
:aria-selected="option === modelValue"
class="topic-combobox-option"
:class="{ active: index === highlightIndex }"
@mousedown.prevent="selectOption(option)"
@mouseenter="highlightIndex = index"
>
{{ option }}
</button>
</template>
<div v-else class="topic-combobox-empty">{{ t("mqTopics.noMatches") }}</div>
</div>
</div>
<button type="button" class="mq-btn-icon" :disabled="loading || disabled || !tenant || !namespace" :title="t('mqMessages.refreshTopicList')" @click="loadTopics">
<span :class="{ 'mq-spin': loading }">⟳</span>
</button>
</div>
<template v-if="showHint">
<div v-if="!filteredTopics.length && !loading" class="topic-hint">{{ t("mqMessages.noTopicsAvailable") }}</div>
<div v-else class="topic-hint">{{ t("mqMessages.topicSearchHint") }}</div>
</template>
</div>
</template>
<style scoped>
@import "./mqPanel.css";
.rocketmq-topic-select {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
min-width: 0;
}
.mq-topic-select-row {
width: 100%;
min-width: 0;
}
.topic-combobox {
position: relative;
flex: 1;
min-width: 0;
}
.topic-combobox-input-wrap {
position: relative;
display: flex;
align-items: center;
min-height: 34px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.topic-combobox-input-wrap.open,
.topic-combobox-input-wrap:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
.topic-combobox-input-wrap.disabled {
opacity: 0.6;
}
.topic-combobox-field {
position: relative;
flex: 1;
min-width: 0;
}
.committed-overlay {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
max-width: calc(100% - 44px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-tertiary);
font-size: 13px;
line-height: 1;
user-select: none;
pointer-events: none;
}
.topic-combobox-trigger {
flex: 1;
min-width: 0;
padding: 7px 34px 7px 10px;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
}
.committed-value {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
font-size: 13px;
}
.committed-placeholder {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-tertiary);
font-size: 13px;
}
.topic-combobox-input {
display: block;
width: 100%;
min-width: 0;
padding: 7px 34px 7px 10px;
border: none;
background: transparent;
color: var(--color-text);
font-size: 13px;
outline: none;
}
.topic-combobox-search-icon {
position: absolute;
right: 10px;
width: 14px;
height: 14px;
color: var(--color-text-tertiary);
pointer-events: none;
}
.topic-combobox-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 30;
max-height: 240px;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.topic-combobox-option {
display: block;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--color-text);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.topic-combobox-option:hover,
.topic-combobox-option.active {
background: var(--color-background-secondary);
}
.topic-combobox-empty {
padding: 10px 12px;
color: var(--color-text-secondary);
font-size: 13px;
}
.topic-hint {
font-size: 12px;
color: var(--color-text-tertiary);
}
</style>
@@ -0,0 +1,189 @@
.mq-panel-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
}
.mq-panel-toolbar h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.mq-toolbar-left {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
flex: 1;
}
.mq-toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.mq-search-input {
width: min(320px, 32vw);
min-width: 180px;
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
}
.mq-search-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
.mq-result-count {
flex: 0 0 auto;
color: var(--color-text-tertiary);
font-size: 12px;
}
.mq-btn-primary,
.mq-btn-secondary,
.mq-btn-sm {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.mq-btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.mq-btn-secondary:hover:not(:disabled),
.mq-btn-sm:hover:not(:disabled) {
background: var(--color-hover);
}
.mq-btn-primary:hover:not(:disabled) {
filter: brightness(1.05);
}
.mq-btn-primary:disabled,
.mq-btn-secondary:disabled,
.mq-btn-sm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mq-btn-sm.btn-danger,
.mq-btn-sm.mq-btn-danger {
color: var(--color-error);
border-color: var(--color-error);
}
.mq-query-mode-tabs {
display: flex;
gap: 24px;
border-bottom: 1px solid var(--color-border);
}
.mq-query-tab {
padding: 0 0 10px;
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--color-text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.mq-query-tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.mq-query-filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
align-items: end;
}
.mq-topic-select-row {
display: flex;
align-items: center;
gap: 8px;
}
.mq-topic-select-row input {
flex: 1;
min-width: 0;
}
.mq-topic-type-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mq-topic-type-chip {
padding: 4px 10px;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-background);
color: var(--color-text-secondary);
font-size: 12px;
cursor: pointer;
}
.mq-topic-type-chip.active {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-primary-alpha);
}
.mq-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 16px;
flex-shrink: 0;
}
.mq-btn-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mq-spin {
display: inline-block;
animation: mq-spin-anim 0.8s linear infinite;
}
@keyframes mq-spin-anim {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}