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);
}
}
+228 -4
View File
@@ -337,6 +337,16 @@ export default {
mqSystem: "System",
mqSystemPulsar: "Apache Pulsar",
mqSystemKafka: "Apache Kafka",
mqSystemRocketMq: "Apache RocketMQ",
rocketmqNamesrvAddr: "NameServer Address",
rocketmqNamesrvAddrPlaceholder: "127.0.0.1:9876",
rocketmqNamesrvAddrRequired: "RocketMQ NameServer address is required",
rocketmqNamesrvAddrInvalid: "RocketMQ NameServer address is invalid",
rocketmqClusterName: "Cluster Name",
rocketmqClusterNamePlaceholder: "Optional, e.g. DefaultCluster",
rocketmqAccessKey: "Access Key",
rocketmqSecretKey: "Secret Key",
rocketmqAclAuth: "ACL",
mqBootstrapServers: "Bootstrap Servers",
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Kafka bootstrap servers are required",
@@ -4284,6 +4294,22 @@ export default {
nonPersistent: "Non-persistent",
partitionedTopic: "Partitioned topic",
normalTopic: "Normal topic",
systemTopic: "System topic",
includeSystemTopics: "Include system topics",
noUserTopics: "No user topics. Enable type filters to inspect internal, retry, or DLQ topics.",
typeFilter: "Type filters",
messageType: "Message type",
messageTypeHint: "Persists RocketMQ message.type on the broker topic config, matching RocketMQ Dashboard.",
rocketmqType: {
normal: "Normal",
delay: "Delay",
fifo: "Ordered",
transaction: "Transaction",
unspecified: "Unspecified",
retry: "Retry",
dlq: "Dead letter",
system: "System",
},
partitionCount: "{count} partitions",
partitionsUnknown: "Unknown partitions",
adjustPartitions: "Adjust partitions",
@@ -4308,6 +4334,79 @@ export default {
currentPartitionsUnknown: "Current partition count is unknown, so partitions cannot be adjusted safely.",
partitionMustIncrease: "New partition count must be greater than the current partition count.",
confirmDelete: 'Delete topic "{name}"? This action cannot be undone.',
queues: "Queues",
queueCount: "{count} queues",
clusterName: "Cluster",
brokerName: "Broker",
allBrokers: "All master brokers",
readQueues: "Read queues",
writeQueues: "Write queues",
perm: "Permission",
permReadWrite: "Read + Write (6)",
permRead: "Read only (4)",
permWrite: "Write only (2)",
actionStatus: "Status",
actionRoute: "Route",
actionConsumers: "Consumers",
actionConfig: "Config",
actionSend: "Send",
actionMessageQuery: "Query messages",
actionReset: "Reset offset",
actionSkip: "Skip backlog",
topicStatusTitle: "Topic status: {name}",
topicRouteTitle: "Topic route: {name}",
topicConsumersTitle: "Consumers: {name}",
topicConfigTitle: "Topic config: {name}",
topicConfigHint: "Same as RocketMQ Dashboard: only read/write queue counts and permissions are editable. Message type is set when creating the topic.",
topicResetTitle: "Reset offset: {name}",
topicSkipTitle: "Skip accumulation: {name}",
confirmSkip: 'Skip backlog for all consumer groups on topic "{name}"?',
skipHint: "Resets all subscribed consumer groups to the latest offset for this topic.",
saveConfig: "Save config",
resetPosition: "Reset position",
positionEarliest: "Earliest",
positionLatest: "Latest",
positionTimestamp: "By time",
timestamp: "Time",
invalidTimestamp: "Enter a valid time",
resetTimePreview: "Selected time: {time}",
noConsumerGroups: "No consumer groups",
noConsumers: "No consumer groups subscribed to this topic",
manageConsumers: "Manage",
routeQueues: "Queue data",
routeBrokers: "Broker data",
brokerAddrs: "Broker addresses",
noRouteData: "No route data",
noQueueStats: "No queue statistics",
noConfig: "No topic configuration returned",
queueId: "Queue",
beginOffset: "Min offset",
endOffset: "Max offset",
messageCount: "Messages",
},
mqRocketmq: {
consumerGroupTitle: "Consumer groups",
searchConsumerGroup: "Search consumer group",
connectionNotReady: "Connection is not ready. Refresh cluster info.",
tabSend: "Send message",
tabQuery: "Message query",
noDlqTopics: "No DLQ topics found. Enable the DLQ type filter on the Topics tab and refresh.",
topicFilterAll: "All",
topicFilterBusiness: "Business",
topicFilterDlq: "DLQ",
actionProduce: "Produce",
actionConsume: "Consume",
aclTitle: "Access control",
actionMessageQuery: "Query messages",
actionSendMessage: "Send message",
actionMore: "More",
actionDetailGroup: "Details",
actionResetOffset: "Reset offset",
dlqConsumerGroup: "Consumer group",
dlqConsumerGroupPlaceholder: "Consumer group (optional)",
dlqQueryByKey: "Query by key",
viewDlqMessages: "View DLQ messages",
namesrvAddr: "NameServer",
},
mqAdmin: {
viewTenant: "View tenant",
@@ -4322,6 +4421,12 @@ export default {
tabClients: "Clients",
tabMessages: "Messages",
tabBroker: "Broker",
tabCluster: "Cluster",
tabConsumers: "Consumers",
tabProducers: "Producers",
tabDlq: "DLQ",
tabTrace: "Trace",
tabAcl: "ACL",
tabPolicies: "Policies",
tabPermissions: "Permissions",
tabRawApi: "Raw API",
@@ -4336,12 +4441,13 @@ export default {
sendSuccess: "Message sent — partition: {partition}, offset: {offset}",
targetTopic: "Target topic",
topicLoading: "Loading...",
topicSearchPlaceholder: "Type or search topics...",
topicSearchPlaceholder: "Select a topic",
refreshTopicList: "Refresh topic list",
topicOptionWithPartitions: "{label} ({partitions} partitions)",
noTopicsAvailable: "No topics available",
topicSearchHint: "Search by keyword, or paste a topic name.",
topicSearchHint: "Type to filter the list. Selecting a topic runs the query. Click again to search with the current topic shown in gray.",
messageKey: "Message key",
messageTag: "Message tag",
optional: "Optional",
messageContent: "Message content",
formatJson: "Format JSON",
@@ -4368,6 +4474,41 @@ export default {
metaOffset: "offset {offset}",
metaKey: "key {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
queryTitle: "Message query",
queryTabTopic: "Topic",
queryTabKey: "Message Key",
queryTabMsgId: "Message ID",
queryTopicLabel: "Topic",
selectTopicPlaceholder: "Select a topic",
noQueryResults: "No results found matching the criteria",
tableMessageId: "Message ID",
tableTag: "Tag",
tableKey: "Key",
tableStoreTime: "StoreTime",
tableOperation: "Operation",
viewDetails: "View",
messageDetails: "Message details",
search: "Search",
messageQuery: "Message query",
queryByMsgId: "By message ID",
queryByKey: "By key",
queryByTopic: "By topic & time",
msgId: "Message ID",
msgIdPlaceholder: "e.g. 7F000001...",
msgIdRequired: "Message ID is required",
queryKey: "Message key",
queryKeyPlaceholder: "Business key",
queryKeyRequired: "Message key is required",
beginTime: "Begin time",
endTime: "End time",
maxNum: "Max messages",
query: "Query",
querying: "Querying...",
messageOverview: "Overview",
messageBody: "Body",
resendMessage: "Resend",
payloadCopied: "Copied",
dlqConsumerGroupPlaceholder: "Consumer group (optional)",
},
mqMonitoring: {
title: "Monitoring",
@@ -4474,6 +4615,13 @@ export default {
metaPartition: "partition {partition}",
metaOffset: "offset {offset}",
metaKey: "key {key}",
rocketmqTopicOverview: "RocketMQ topic overview",
rocketmqQueueDetails: "Queue offset details",
queueCount: "Queues",
brokerCount: "Brokers",
tableQueue: "Queue",
tableBroker: "Broker",
noRocketMqQueueMetrics: "No queue metrics returned",
},
mqTenants: {
title: "Tenant Management",
@@ -4532,11 +4680,46 @@ export default {
title: "Subscription Management",
createSubscription: "Create Subscription",
selectTopicFirst: "Select a topic first",
searchPlaceholder: "Search subscription name",
subscribedTopics: "Subscribed topics",
readOnly: "This connection is read-only and cannot perform write operations.",
loading: "Loading...",
refresh: "Refresh",
refreshing: "Refreshing...",
noSubscriptions: "No subscriptions for this topic",
noConsumerGroups: "No consumer groups",
viewDetail: "Details",
editConfig: "Config",
save: "Save",
consumerDetailTitle: "Consumer group details: {name}",
consumerConfigTitle: "Consumer group config: {name}",
consumerOverview: "Overview",
consumerTerminals: "Online terminals",
consumerConsumeDetail: "Consumption details",
noOnlineConsumers: "No online consumers",
noConsumeDetail: "No consumption details",
operationTopic: "Topic",
consumeEnable: "Consume enabled",
consumeBroadcastEnable: "Broadcast consume enabled",
consumeFromMinEnable: "Consume from min offset",
consumeMessageOrderly: "Orderly consume",
retryQueueNums: "Retry queue count",
retryMaxTimes: "Max retry times",
brokerId: "Broker ID",
whichBrokerWhenConsumeSlowly: "Slow-consume broker ID",
subscriptionName: "Subscription name",
type: "Type",
mode: "Mode",
typeFilter: "Type filter",
rocketmqGroupType: {
normal: "Normal",
fifo: "Ordered",
system: "System",
},
rocketmqGroupMode: {
clustering: "Cluster",
broadcasting: "Broadcast",
},
backlog: "Backlog",
consumeRate: "Consume rate",
consumers: "Consumers",
@@ -4546,7 +4729,7 @@ export default {
resetCursor: "Reset cursor",
skipMessages: "Skip messages",
clearBacklog: "Clear backlog",
peek: "Peek",
peek: "View messages",
expireMessages: "Expire messages",
delete: "Delete",
createDialogTitle: "Create Subscription",
@@ -4572,7 +4755,6 @@ export default {
skip: "Skip",
peekDialogTitle: "Peek messages: {name}",
count: "Count",
refresh: "Refresh",
noPeekMessages: "No messages to view",
close: "Close",
expireDialogTitle: "Expire messages: {name}",
@@ -4593,6 +4775,7 @@ export default {
refresh: "Refresh",
refreshing: "Refreshing...",
selectTopicFirst: "Select a topic first",
rocketmqClusterHint: "Shows all online producers and consumer group terminals on the broker. Select a topic for topic-scoped client details.",
confirmUnload: "Unload this topic? Active producers and consumers will reconnect.",
aggregateTopic: "Aggregate topic",
kafkaPartitionStatus: "Kafka partition status",
@@ -4726,6 +4909,8 @@ export default {
controller: "Controller",
controllerNode: "Node {id} · {host}",
brokerNodes: "Broker nodes",
brokerName: "Broker name",
brokerEndpoints: "Broker endpoints",
nodeId: "Node ID",
host: "Host",
port: "Port",
@@ -4733,7 +4918,46 @@ export default {
role: "Role",
roleController: "Controller",
roleFollower: "Follower",
roleMaster: "Master",
roleSlave: "Slave",
noBrokerNodes: "No broker node information",
clusterTitle: "Cluster",
},
mqDlq: {
title: "Dead letter queue",
refresh: "Refresh",
refreshing: "Refreshing...",
loading: "Loading...",
selectContext: "Select a namespace context first",
selectTopic: "Select a DLQ topic",
topic: "DLQ topic",
consumerGroup: "Consumer group",
groupPlaceholder: "Optional peek group",
queryByKey: "Query by message key",
queryKey: "Message key",
queryKeyPlaceholder: "Key to search",
loadMessages: "Load messages",
loadingMessages: "Loading messages...",
noDlqTopics: "No DLQ topics found. Enable the DLQ type filter on the Topics tab to list them.",
noMessages: "No messages",
},
mqTrace: {
title: "Message trace",
msgId: "Message ID",
msgIdPlaceholder: "Original message ID",
msgIdRequired: "Message ID is required",
queryKeyRequired: "Message key is required",
selectTopicBeforeQuery: "Select a topic before querying traces",
traceTopic: "Trace topic",
traceTopicPlaceholder: "RMQ_SYS_TRACE_TOPIC",
query: "Query trace",
querying: "Querying...",
noTrace: "No trace records found",
searchHint: "Search for messages first, then click View trace",
viewTrace: "View trace",
detailTitle: "Message trace details",
traceTopicRouteMissing:
"The trace topic has no route on NameServer (CODE: 17).\n\nCommon causes:\n1. Broker trace is disabled: set traceTopicEnable=true in broker.conf and restart the broker\n2. Producer/Consumer trace is disabled: call setEnableTrace(true)\n3. A custom trace topic is used: select the correct trace topic above",
},
mqRaw: {
title: "Raw API",
+224
View File
@@ -532,6 +532,16 @@ export default withEnglishFallback({
elasticsearchKibanaProxyMode: "proxy de Kibana",
elasticsearchKibanaHost: "host de Kibana",
elasticsearchKibanaBasePath: "ruta base",
mqSystemRocketMq: "Apache RocketMQ",
rocketmqNamesrvAddr: "Dirección de NameServer",
rocketmqNamesrvAddrPlaceholder: "127.0.0.1:9876",
rocketmqNamesrvAddrRequired: "La dirección de NameServer de RocketMQ no puede estar vacía",
rocketmqNamesrvAddrInvalid: "La dirección de NameServer de RocketMQ no es válida",
rocketmqClusterName: "Nombre del clúster",
rocketmqClusterNamePlaceholder: "Opcional, por ejemplo DefaultCluster",
rocketmqAccessKey: "Access Key",
rocketmqSecretKey: "Secret Key",
rocketmqAclAuth: "ACL",
jdbcMissingRuntimeDependencyHint: "El controlador JDBC actual carece de dependencias de ejecución. Utilice las coordenadas Maven en 'Administración de controladores' para instalar, o importe el controlador y todos los JAR de dependencia de una vez.",
},
editor: {
@@ -4179,6 +4189,71 @@ export default withEnglishFallback({
currentPartitionsUnknown: "Se desconoce el número actual de particiones, no se puede ajustar de forma segura.",
partitionMustIncrease: "El nuevo número de particiones debe ser mayor que el actual.",
confirmDelete: '¿Está seguro de que desea eliminar el tema "{name}"? Esta operación no se puede deshacer.',
systemTopic: "Tema del sistema",
includeSystemTopics: "Incluir Topics del sistema",
noUserTopics: "No hay Topics de negocio. Marque el filtro de tipo para ver temas internos/reintentos/muertos.",
typeFilter: "Filtro de tipo",
messageType: "Tipo de mensaje",
messageTypeHint: "Se escribe en la propiedad message.type de la configuración de Broker Topic después de la creación, consistente con RocketMQ Dashboard.",
rocketmqType: {
normal: "Normal",
delay: "Retraso",
fifo: "FIFO",
transaction: "Transacción",
unspecified: "No especificado",
retry: "Reintento",
dlq: "Mensaje muerto",
system: "Sistema",
},
queues: "Colas",
queueCount: "{count} colas",
clusterName: "Clúster",
brokerName: "Broker",
allBrokers: "Todos los Brokers Master",
readQueues: "Número de colas de lectura",
writeQueues: "Número de colas de escritura",
perm: "Permiso",
permReadWrite: "Lectura/Escritura (6)",
permRead: "Solo lectura (4)",
permWrite: "Solo escritura (2)",
actionStatus: "Estado",
actionRoute: "Ruta",
actionConsumers: "Consumidores",
actionConfig: "Configuración",
actionSend: "Enviar mensaje",
actionMessageQuery: "Consulta de mensajes",
actionReset: "Restablecer posición",
actionSkip: "Saltar acumulación",
topicStatusTitle: "Estado del Topic: {name}",
topicRouteTitle: "Ruta del Topic: {name}",
topicConsumersTitle: "Consumidores: {name}",
topicConfigTitle: "Configuración del Topic: {name}",
topicConfigHint: "Consistente con RocketMQ Dashboard, solo permite modificar el número de colas de lectura/escritura y permisos. El tipo de mensaje se establece al crear el Topic.",
topicResetTitle: "Restablecer posición: {name}",
topicSkipTitle: "Saltar acumulación: {name}",
confirmSkip: "¿Está seguro de restablecer todos los grupos de consumidores del Topic «{name}» a la posición más reciente?",
skipHint: "Restablece la posición de todos los grupos de consumidores suscritos bajo este Topic a la más reciente.",
saveConfig: "Guardar configuración",
resetPosition: "Restablecer posición",
positionEarliest: "Más antiguo",
positionLatest: "Más reciente",
positionTimestamp: "Por tiempo",
timestamp: "Tiempo",
invalidTimestamp: "Ingrese una hora válida",
resetTimePreview: "Hora actual: {time}",
noConsumerGroups: "No hay grupos de consumidores",
noConsumers: "Este Topic no tiene grupos de consumidores",
manageConsumers: "Administrar",
routeQueues: "Datos de cola",
routeBrokers: "Datos de Broker",
brokerAddrs: "Direcciones de Broker",
noRouteData: "Sin datos de ruta",
noQueueStats: "Sin estadísticas de cola",
noConfig: "No se devolvió configuración del Topic",
queueId: "Cola",
beginOffset: "Offset mínimo",
endOffset: "Offset máximo",
messageCount: "Número de mensajes",
},
mqAdmin: {
viewTenant: "Ver tenant",
@@ -4196,6 +4271,12 @@ export default withEnglishFallback({
tabPolicies: "Políticas",
tabPermissions: "Permisos",
tabRawApi: "Raw API",
tabCluster: "Clúster",
tabConsumers: "Consumidores",
tabProducers: "Productores",
tabDlq: "Mensajes muertos",
tabTrace: "Trazas",
tabAcl: "ACL",
},
mqMessages: {
title: "Enviar mensaje",
@@ -4239,6 +4320,42 @@ export default withEnglishFallback({
metaOffset: "offset {offset}",
metaKey: "clave {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
selectTopicPlaceholder: "Seleccione un tema",
messageTag: "Etiqueta de mensaje (Tag)",
queryTitle: "Consulta de mensajes",
queryTabTopic: "Por Topic",
queryTabKey: "Por Key",
queryTabMsgId: "Por MsgId",
queryTopicLabel: "Tema",
noQueryResults: "No se encontraron resultados que cumplan los criterios",
tableMessageId: "ID de mensaje",
tableTag: "Etiqueta",
tableKey: "Key",
tableStoreTime: "Hora de almacenamiento",
tableOperation: "Operación",
viewDetails: "Ver",
messageDetails: "Detalles del mensaje",
search: "Buscar",
messageQuery: "Consulta de mensajes",
queryByMsgId: "Por MsgId",
queryByKey: "Por Key",
queryByTopic: "Por Topic y tiempo",
msgId: "ID de mensaje",
msgIdPlaceholder: "ej. 7F000001...",
msgIdRequired: "El ID de mensaje no puede estar vacío",
queryKey: "Key de mensaje",
queryKeyPlaceholder: "Key de negocio",
queryKeyRequired: "La Key de mensaje no puede estar vacía",
beginTime: "Hora de inicio",
endTime: "Hora de fin",
maxNum: "Número máximo",
query: "Consultar",
querying: "Consultando...",
messageOverview: "Información básica",
messageBody: "Cuerpo del mensaje",
resendMessage: "Reenviar",
payloadCopied: "Copiado",
dlqConsumerGroupPlaceholder: "Grupo de consumidores (opcional)",
},
mqMonitoring: {
title: "Monitoreo",
@@ -4345,6 +4462,13 @@ export default withEnglishFallback({
metaPartition: "partición {partition}",
metaOffset: "offset {offset}",
metaKey: "clave {key}",
rocketmqTopicOverview: "Resumen de Topics de RocketMQ",
rocketmqQueueDetails: "Detalles de Offset de cola",
queueCount: "Número de colas",
brokerCount: "Número de Brokers",
tableQueue: "Cola",
tableBroker: "Broker",
noRocketMqQueueMetrics: "Sin métricas de cola",
},
mqTenants: {
title: "Administración de tenants",
@@ -4456,6 +4580,40 @@ export default withEnglishFallback({
confirmClearBacklog: '¿Limpiar todos los mensajes en backlog de la suscripción "{name}"?',
subscriptionNameRequired: "El nombre de la suscripción es obligatorio",
peekMessageKey: "clave={key}",
searchPlaceholder: "Buscar nombre de suscripción",
subscribedTopics: "Topics suscritos",
refreshing: "Actualizando...",
noConsumerGroups: "No hay grupos de consumidores",
viewDetail: "Detalles",
editConfig: "Configurar",
save: "Guardar",
consumerDetailTitle: "Detalles del grupo de consumidores: {name}",
consumerConfigTitle: "Configuración del grupo de consumidores: {name}",
consumerOverview: "Resumen",
consumerTerminals: "Terminales en línea",
consumerConsumeDetail: "Detalles de consumo",
noOnlineConsumers: "No hay consumidores en línea actualmente",
noConsumeDetail: "Sin detalles de consumo",
operationTopic: "Topic",
consumeEnable: "Permitir consumo",
consumeBroadcastEnable: "Permitir consumo en difusión",
consumeFromMinEnable: "Comenzar desde el offset mínimo",
consumeMessageOrderly: "Consumo ordenado",
retryQueueNums: "Número de colas de reintento",
retryMaxTimes: "Número máximo de reintentos",
brokerId: "ID de Broker",
whichBrokerWhenConsumeSlowly: "ID de Broker para consumo lento",
mode: "Modo",
typeFilter: "Filtro de tipo",
rocketmqGroupType: {
normal: "Normal",
fifo: "FIFO",
system: "Sistema",
},
rocketmqGroupMode: {
clustering: "Cluster",
broadcasting: "Difusión",
},
},
mqClients: {
title: "Productores / Consumidores",
@@ -4495,6 +4653,7 @@ export default withEnglishFallback({
noConsumersOnPartition: "No hay consumidores activos en la suscripción de esta partición",
noConsumersOnSubscription: "No hay consumidores activos en esta suscripción",
permits: "Permisos",
rocketmqClusterHint: "Muestra todos los terminales de Producer y Consumer Group en línea en el Broker. Después de seleccionar un tema, puede ver los detalles del cliente para ese Topic.",
},
mqPolicies: {
title: "Administración de políticas",
@@ -4605,6 +4764,11 @@ export default withEnglishFallback({
roleController: "Controller",
roleFollower: "Follower",
noBrokerNodes: "No hay información de nodos del Broker",
brokerName: "Nombre de Broker",
brokerEndpoints: "Endpoints de Broker",
roleMaster: "Master",
roleSlave: "Slave",
clusterTitle: "Clúster",
},
mqRaw: {
title: "Raw API",
@@ -4839,4 +5003,64 @@ export default withEnglishFallback({
saturday: "Sábado",
},
},
mqRocketmq: {
consumerGroupTitle: "Grupo de consumidores",
searchConsumerGroup: "Buscar grupo de consumidores",
connectionNotReady: "Conexión no lista, actualice la información del clúster",
tabSend: "Enviar mensaje",
tabQuery: "Consulta de mensajes",
noDlqTopics: "No se encontraron Topics de mensajes muertos. Puede habilitar el filtro de tipo «Mensaje muerto» en la página de temas y actualizar.",
topicFilterAll: "Todos",
topicFilterBusiness: "Negocio",
topicFilterDlq: "Mensaje muerto",
actionProduce: "Publicar",
actionConsume: "Suscribir",
aclTitle: "Control de acceso",
actionMessageQuery: "Consulta de mensajes",
actionSendMessage: "Enviar mensaje",
actionMore: "Más",
actionDetailGroup: "Detalles",
actionResetOffset: "Restablecer offset",
dlqConsumerGroup: "Grupo de consumidores",
dlqConsumerGroupPlaceholder: "Grupo de consumidores (opcional)",
dlqQueryByKey: "Consultar por Key",
viewDlqMessages: "Ver mensajes muertos",
namesrvAddr: "NameServer",
},
mqDlq: {
title: "Cola de mensajes muertos",
refresh: "Actualizar",
refreshing: "Actualizando...",
loading: "Cargando...",
selectContext: "Seleccione primero el contexto del namespace",
selectTopic: "Seleccione un Topic DLQ",
topic: "Topic DLQ",
consumerGroup: "Grupo de consumidores",
groupPlaceholder: "Grupo de consumidores Peek (opcional)",
queryByKey: "Consultar por Key",
queryKey: "Key de mensaje",
queryKeyPlaceholder: "Key a buscar",
loadMessages: "Cargar mensajes",
loadingMessages: "Cargando mensajes...",
noDlqTopics: "No se encontraron Topics DLQ. Puede habilitar el filtro de tipo «Mensaje muerto» en la página de temas y actualizar.",
noMessages: "No hay mensajes",
},
mqTrace: {
title: "Trazas de mensajes",
msgId: "ID de mensaje",
msgIdPlaceholder: "MsgId del mensaje original",
msgIdRequired: "El ID de mensaje no puede estar vacío",
queryKeyRequired: "La Key del mensaje no puede estar vacía",
selectTopicBeforeQuery: "Seleccione primero un Topic para consultar trazas",
traceTopic: "Topic de trazas",
traceTopicPlaceholder: "RMQ_SYS_TRACE_TOPIC",
query: "Consultar trazas",
querying: "Consultando...",
noTrace: "No se encontraron registros de trazas",
searchHint: "Primero busque el mensaje, luego haga clic en «Ver traza de consumo»",
viewTrace: "Ver traza de consumo",
detailTitle: "Detalles de la traza de consumo",
traceTopicRouteMissing:
"El Topic de trazas no tiene información de ruta en NameServer (CODE: 17).\n\nCausas comunes:\n1. Broker no tiene habilitada la traza de mensajes: configure traceTopicEnable=true en broker.conf y reinicie el Broker\n2. Producer/Consumer no tiene habilitada la traza: setEnableTrace(true)\n3. Se utilizó un Topic de trazas personalizado, seleccione el Topic de trazas correcto arriba.",
},
});
+224
View File
@@ -530,6 +530,16 @@ export default withEnglishFallback({
elasticsearchKibanaProxyMode: "Proxy Kibana",
elasticsearchKibanaHost: "Host Kibana",
elasticsearchKibanaBasePath: "Percorso base",
mqSystemRocketMq: "Apache RocketMQ",
rocketmqNamesrvAddr: "Indirizzo NameServer",
rocketmqNamesrvAddrPlaceholder: "127.0.0.1:9876",
rocketmqNamesrvAddrRequired: "L'indirizzo NameServer di RocketMQ non può essere vuoto",
rocketmqNamesrvAddrInvalid: "L'indirizzo NameServer di RocketMQ non è valido",
rocketmqClusterName: "Nome cluster",
rocketmqClusterNamePlaceholder: "Opzionale, es. DefaultCluster",
rocketmqAccessKey: "Access Key",
rocketmqSecretKey: "Secret Key",
rocketmqAclAuth: "ACL",
jdbcMissingRuntimeDependencyHint: "Il driver JDBC corrente manca di dipendenze runtime. Installare utilizzando le coordinate Maven in 'Gestione driver', o importare il driver e tutti i JAR delle dipendenze in una volta.",
},
editor: {
@@ -4177,6 +4187,71 @@ export default withEnglishFallback({
currentPartitionsUnknown: "Numero di partizioni attuali sconosciuto, impossibile regolare le partizioni in modo sicuro.",
partitionMustIncrease: "Il nuovo numero di partizioni deve essere maggiore di quello attuale.",
confirmDelete: 'Confermi di eliminare il topic "{name}"? Questa operazione è irreversibile.',
systemTopic: "Topic di sistema",
includeSystemTopics: "Includi Topic di sistema",
noUserTopics: "Nessun Topic di business. È possibile selezionare il filtro per tipo per visualizzare i topic interni/retry/dead letter.",
typeFilter: "Filtro per tipo",
messageType: "Tipo di messaggio",
messageTypeHint: "Dopo la creazione, scrive l'attributo message.type nella configurazione del Broker Topic, coerente con RocketMQ Dashboard.",
rocketmqType: {
normal: "Normale",
delay: "Ritardo",
fifo: "FIFO",
transaction: "Transazionale",
unspecified: "Non specificato",
retry: "Retry",
dlq: "DLQ",
system: "Sistema",
},
queues: "Code",
queueCount: "{count} code",
clusterName: "Cluster",
brokerName: "Broker",
allBrokers: "Tutti i Master Broker",
readQueues: "Numero code di lettura",
writeQueues: "Numero code di scrittura",
perm: "Permessi",
permReadWrite: "Lettura/Scrittura (6)",
permRead: "Sola lettura (4)",
permWrite: "Sola scrittura (2)",
actionStatus: "Stato",
actionRoute: "Instradamento",
actionConsumers: "Consumatori",
actionConfig: "Configurazione",
actionSend: "Invia messaggio",
actionMessageQuery: "Query messaggi",
actionReset: "Reimposta posizione",
actionSkip: "Salta accumulo",
topicStatusTitle: "Stato Topic: {name}",
topicRouteTitle: "Instradamento Topic: {name}",
topicConsumersTitle: "Consumatori: {name}",
topicConfigTitle: "Configurazione Topic: {name}",
topicConfigHint: "Coerente con RocketMQ Dashboard, supporta solo la modifica del numero di code di lettura/scrittura e dei permessi. Il tipo di messaggio viene impostato durante la creazione del Topic.",
topicResetTitle: "Reimposta posizione: {name}",
topicSkipTitle: "Salta accumulo: {name}",
confirmSkip: "Confermi di reimpostare tutti i gruppi di consumatori del Topic '{name}' alla posizione più recente?",
skipHint: "Reimposta alla posizione più recente tutti i gruppi di consumatori sottoscritti a questo Topic.",
saveConfig: "Salva configurazione",
resetPosition: "Reimposta posizione",
positionEarliest: "Inizio",
positionLatest: "Fine",
positionTimestamp: "Per timestamp",
timestamp: "Timestamp",
invalidTimestamp: "Inserisci un timestamp valido",
resetTimePreview: "Ora corrente: {time}",
noConsumerGroups: "Nessun gruppo di consumatori",
noConsumers: "Questo Topic non ha gruppi di consumatori",
manageConsumers: "Gestisci",
routeQueues: "Dati code",
routeBrokers: "Dati Broker",
brokerAddrs: "Indirizzi Broker",
noRouteData: "Nessun dato di instradamento",
noQueueStats: "Nessuna statistica code",
noConfig: "Nessuna configurazione Topic restituita",
queueId: "Coda",
beginOffset: "Offset minimo",
endOffset: "Offset massimo",
messageCount: "Numero di messaggi",
},
mqAdmin: {
viewTenant: "Visualizza tenant",
@@ -4194,6 +4269,12 @@ export default withEnglishFallback({
tabPolicies: "Politiche",
tabPermissions: "Permessi",
tabRawApi: "Raw API",
tabCluster: "Cluster",
tabConsumers: "Consumatori",
tabProducers: "Produttori",
tabDlq: "DLQ",
tabTrace: "Traccia",
tabAcl: "ACL",
},
mqMessages: {
title: "Invia messaggio",
@@ -4237,6 +4318,42 @@ export default withEnglishFallback({
metaOffset: "offset {offset}",
metaKey: "chiave {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
selectTopicPlaceholder: "Seleziona un Topic",
messageTag: "Tag del messaggio",
queryTitle: "Query messaggi",
queryTabTopic: "Per Topic",
queryTabKey: "Per Key",
queryTabMsgId: "Per MsgId",
queryTopicLabel: "Topic",
noQueryResults: "Nessun risultato trovato",
tableMessageId: "ID messaggio",
tableTag: "Tag",
tableKey: "Key",
tableStoreTime: "Tempo di archiviazione",
tableOperation: "Operazione",
viewDetails: "Visualizza",
messageDetails: "Dettagli messaggio",
search: "Cerca",
messageQuery: "Query messaggi",
queryByMsgId: "Per MsgId",
queryByKey: "Per Key",
queryByTopic: "Per Topic e ora",
msgId: "ID messaggio",
msgIdPlaceholder: "es. 7F000001...",
msgIdRequired: "L'ID messaggio non può essere vuoto",
queryKey: "Key del messaggio",
queryKeyPlaceholder: "Key di business",
queryKeyRequired: "La Key del messaggio non può essere vuota",
beginTime: "Ora inizio",
endTime: "Ora fine",
maxNum: "Numero massimo",
query: "Cerca",
querying: "Ricerca in corso...",
messageOverview: "Informazioni di base",
messageBody: "Corpo del messaggio",
resendMessage: "Reinvia",
payloadCopied: "Copiato",
dlqConsumerGroupPlaceholder: "Gruppo di consumatori (opzionale)",
},
mqMonitoring: {
title: "Monitoraggio",
@@ -4343,6 +4460,13 @@ export default withEnglishFallback({
metaPartition: "partizione {partition}",
metaOffset: "offset {offset}",
metaKey: "chiave {key}",
rocketmqTopicOverview: "Panoramica Topic RocketMQ",
rocketmqQueueDetails: "Dettagli offset code",
queueCount: "Numero code",
brokerCount: "Numero Broker",
tableQueue: "Coda",
tableBroker: "Broker",
noRocketMqQueueMetrics: "Nessuna metrica code",
},
mqTenants: {
title: "Gestione tenant",
@@ -4454,6 +4578,40 @@ export default withEnglishFallback({
confirmClearBacklog: 'Svuotare tutti i messaggi in backlog per la sottoscrizione "{name}"?',
subscriptionNameRequired: "Il nome della sottoscrizione è obbligatorio",
peekMessageKey: "chiave={key}",
searchPlaceholder: "Cerca nome sottoscrizione",
subscribedTopics: "Topic sottoscritti",
refreshing: "Aggiornamento in corso...",
noConsumerGroups: "Nessun Consumer Group",
viewDetail: "Dettagli",
editConfig: "Configurazione",
save: "Salva",
consumerDetailTitle: "Dettagli gruppo di consumatori: {name}",
consumerConfigTitle: "Configurazione gruppo di consumatori: {name}",
consumerOverview: "Panoramica",
consumerTerminals: "Terminali online",
consumerConsumeDetail: "Dettagli consumo",
noOnlineConsumers: "Nessun consumatore online",
noConsumeDetail: "Nessun dettaglio consumo",
operationTopic: "Topic",
consumeEnable: "Abilita consumo",
consumeBroadcastEnable: "Abilita consumo broadcast",
consumeFromMinEnable: "Inizia dall'offset minimo",
consumeMessageOrderly: "Consumo ordinato",
retryQueueNums: "Numero code di retry",
retryMaxTimes: "Numero massimo di tentativi",
brokerId: "ID Broker",
whichBrokerWhenConsumeSlowly: "ID Broker per consumo lento",
mode: "Modalità",
typeFilter: "Filtro per tipo",
rocketmqGroupType: {
normal: "Normale",
fifo: "FIFO",
system: "Sistema",
},
rocketmqGroupMode: {
clustering: "Cluster",
broadcasting: "Broadcast",
},
},
mqClients: {
title: "Produttori / Consumatori",
@@ -4493,6 +4651,7 @@ export default withEnglishFallback({
noConsumersOnPartition: "Nessun consumatore attivo su questa sottoscrizione di partizione",
noConsumersOnSubscription: "Nessun consumatore attivo su questa sottoscrizione",
permits: "Permessi",
rocketmqClusterHint: "Mostra tutti i terminali Producer e Consumer Group online sui Broker. Dopo aver selezionato un Topic, è possibile visualizzare i dettagli dei client per quel Topic.",
},
mqPolicies: {
title: "Gestione politiche",
@@ -4603,6 +4762,11 @@ export default withEnglishFallback({
roleController: "Controller",
roleFollower: "Follower",
noBrokerNodes: "Nessuna informazione sui nodi broker",
brokerName: "Nome Broker",
brokerEndpoints: "Endpoint Broker",
roleMaster: "Master",
roleSlave: "Slave",
clusterTitle: "Cluster",
},
mqRaw: {
title: "Raw API",
@@ -4837,4 +5001,64 @@ export default withEnglishFallback({
saturday: "Sabato",
},
},
mqRocketmq: {
consumerGroupTitle: "Gruppo di consumatori",
searchConsumerGroup: "Cerca gruppo di consumatori",
connectionNotReady: "Connessione non pronta, aggiorna le informazioni del cluster",
tabSend: "Invia messaggio",
tabQuery: "Query messaggi",
noDlqTopics: "Nessun Topic DLQ trovato. Abilita il filtro 'DLQ' nella pagina dei topic e aggiorna.",
topicFilterAll: "Tutti",
topicFilterBusiness: "Business",
topicFilterDlq: "DLQ",
actionProduce: "Pubblica",
actionConsume: "Sottoscrivi",
aclTitle: "Controllo accessi",
actionMessageQuery: "Query messaggi",
actionSendMessage: "Invia messaggio",
actionMore: "Altro",
actionDetailGroup: "Dettagli",
actionResetOffset: "Reimposta offset",
dlqConsumerGroup: "Gruppo di consumatori",
dlqConsumerGroupPlaceholder: "Gruppo di consumatori (opzionale)",
dlqQueryByKey: "Cerca per Key",
viewDlqMessages: "Visualizza messaggi DLQ",
namesrvAddr: "NameServer",
},
mqDlq: {
title: "Coda DLQ",
refresh: "Aggiorna",
refreshing: "Aggiornamento in corso...",
loading: "Caricamento in corso...",
selectContext: "Seleziona prima il contesto del namespace",
selectTopic: "Seleziona un Topic DLQ",
topic: "Topic DLQ",
consumerGroup: "Gruppo di consumatori",
groupPlaceholder: "Gruppo di consumatori Peek (opzionale)",
queryByKey: "Cerca per Key",
queryKey: "Key del messaggio",
queryKeyPlaceholder: "Key da cercare",
loadMessages: "Carica messaggi",
loadingMessages: "Caricamento messaggi in corso...",
noDlqTopics: "Nessun Topic DLQ trovato. Abilita il filtro 'DLQ' nella pagina dei topic e aggiorna.",
noMessages: "Nessun messaggio",
},
mqTrace: {
title: "Traccia messaggi",
msgId: "ID messaggio",
msgIdPlaceholder: "MsgId del messaggio originale",
msgIdRequired: "L'ID messaggio non può essere vuoto",
queryKeyRequired: "La Message Key non può essere vuota",
selectTopicBeforeQuery: "Seleziona prima un Topic per cercare la traccia",
traceTopic: "Topic di traccia",
traceTopicPlaceholder: "RMQ_SYS_TRACE_TOPIC",
query: "Cerca traccia",
querying: "Ricerca in corso...",
noTrace: "Nessuna traccia trovata",
searchHint: "Cerca prima il messaggio, poi fai clic su 'Visualizza traccia consumo'",
viewTrace: "Visualizza traccia consumo",
detailTitle: "Dettagli traccia consumo",
traceTopicRouteMissing:
"Il Topic di traccia non ha informazioni di instradamento sul NameServer (CODE: 17).\n\nCause comuni:\n1. Il Broker non ha abilitato la traccia messaggi: imposta traceTopicEnable=true in broker.conf e riavvia il Broker\n2. Producer/Consumer non hanno abilitato la traccia: setEnableTrace(true)\n3. È stato utilizzato un Topic di traccia personalizzato, seleziona il Topic di traccia corretto sopra.",
},
});
+224
View File
@@ -530,6 +530,16 @@ export default withEnglishFallback({
elasticsearchKibanaProxyMode: "Kibana プロキシ",
elasticsearchKibanaHost: "Kibana ホスト",
elasticsearchKibanaBasePath: "ベースパス",
mqSystemRocketMq: "Apache RocketMQ",
rocketmqNamesrvAddr: "NameServer アドレス",
rocketmqNamesrvAddrPlaceholder: "127.0.0.1:9876",
rocketmqNamesrvAddrRequired: "RocketMQ NameServer アドレスは空にできません",
rocketmqNamesrvAddrInvalid: "RocketMQ NameServer アドレスが無効です",
rocketmqClusterName: "クラスタ名",
rocketmqClusterNamePlaceholder: "オプション、例: DefaultCluster",
rocketmqAccessKey: "Access Key",
rocketmqSecretKey: "Secret Key",
rocketmqAclAuth: "ACL",
jdbcMissingRuntimeDependencyHint: "現在のJDBCドライバーには実行依存関係が不足しています。「ドライバ管理」でMaven座標を使用してインストールするか、ドライバーとすべての依存JARを一度にインポートしてください。",
},
editor: {
@@ -4177,6 +4187,71 @@ export default withEnglishFallback({
currentPartitionsUnknown: "現在のパーティション数が不明なため、安全にパーティションを調整できません。",
partitionMustIncrease: "新しいパーティション数は現在のパーティション数より大きくなければなりません。",
confirmDelete: "トピック「{name}」を削除してもよろしいですか?この操作は元に戻せません。",
systemTopic: "システムトピック",
includeSystemTopics: "システム Topic を含む",
noUserTopics: "ビジネス Topic がありません。タイプフィルターをチェックすると、内部/リトライ/デッドレターのトピックを表示できます。",
typeFilter: "タイプフィルター",
messageType: "メッセージタイプ",
messageTypeHint: "作成後に Broker Topic 設定の message.type プロパティに書き込まれ、RocketMQ Dashboard と一致します。",
rocketmqType: {
normal: "通常",
delay: "遅延",
fifo: "FIFO",
transaction: "トランザクション",
unspecified: "未指定",
retry: "リトライ",
dlq: "デッドレター",
system: "システム",
},
queues: "キュー",
queueCount: "{count} キュー",
clusterName: "クラスタ",
brokerName: "Broker",
allBrokers: "全 Master Broker",
readQueues: "読み取りキュー数",
writeQueues: "書き込みキュー数",
perm: "権限",
permReadWrite: "読み書き (6)",
permRead: "読み取り専用 (4)",
permWrite: "書き込み専用 (2)",
actionStatus: "状態",
actionRoute: "ルート",
actionConsumers: "コンシューマ",
actionConfig: "設定",
actionSend: "メッセージ送信",
actionMessageQuery: "メッセージ検索",
actionReset: "オフセットリセット",
actionSkip: "滞留スキップ",
topicStatusTitle: "Topic 状態: {name}",
topicRouteTitle: "Topic ルート: {name}",
topicConsumersTitle: "コンシューマ: {name}",
topicConfigTitle: "Topic 設定: {name}",
topicConfigHint: "RocketMQ Dashboard と一致します。読み取り/書き込みキュー数と権限のみ変更可能です。メッセージタイプは Topic 作成時に設定します。",
topicResetTitle: "オフセットリセット: {name}",
topicSkipTitle: "滞留スキップ: {name}",
confirmSkip: "Topic「{name}」のすべてのコンシューマグループを最新オフセットにリセットしてもよろしいですか?",
skipHint: "この Topic のすべてのサブスクライブされたコンシューマグループのオフセットを最新にリセットします。",
saveConfig: "設定を保存",
resetPosition: "オフセットリセット",
positionEarliest: "最も早い",
positionLatest: "最も新しい",
positionTimestamp: "時刻指定",
timestamp: "時刻",
invalidTimestamp: "有効な時刻を入力してください",
resetTimePreview: "現在時刻: {time}",
noConsumerGroups: "コンシューマグループがありません",
noConsumers: "この Topic にはコンシューマグループがありません",
manageConsumers: "管理",
routeQueues: "キューデータ",
routeBrokers: "Broker データ",
brokerAddrs: "Broker アドレス",
noRouteData: "ルートデータがありません",
noQueueStats: "キュー統計がありません",
noConfig: "Topic 設定が返されませんでした",
queueId: "キュー",
beginOffset: "最小 Offset",
endOffset: "最大 Offset",
messageCount: "メッセージ数",
},
mqAdmin: {
viewTenant: "テナントを表示",
@@ -4194,6 +4269,12 @@ export default withEnglishFallback({
tabPolicies: "ポリシー",
tabPermissions: "権限",
tabRawApi: "Raw API",
tabCluster: "クラスタ",
tabConsumers: "コンシューマ",
tabProducers: "プロデューサ",
tabDlq: "デッドレター",
tabTrace: "トレース",
tabAcl: "ACL",
},
mqMessages: {
title: "メッセージ送信",
@@ -4237,6 +4318,42 @@ export default withEnglishFallback({
metaOffset: "オフセット {offset}",
metaKey: "キー {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
selectTopicPlaceholder: "トピックを選択してください",
messageTag: "メッセージタグ (Tag)",
queryTitle: "メッセージ検索",
queryTabTopic: "Topic 指定",
queryTabKey: "Key 指定",
queryTabMsgId: "MsgId 指定",
queryTopicLabel: "トピック",
noQueryResults: "条件に一致する結果が見つかりませんでした",
tableMessageId: "メッセージ ID",
tableTag: "タグ",
tableKey: "キー",
tableStoreTime: "保存時間",
tableOperation: "操作",
viewDetails: "表示",
messageDetails: "メッセージ詳細",
search: "検索",
messageQuery: "メッセージ検索",
queryByMsgId: "MsgId 指定",
queryByKey: "Key 指定",
queryByTopic: "Topic と時間指定",
msgId: "メッセージ ID",
msgIdPlaceholder: "例: 7F000001...",
msgIdRequired: "メッセージ ID は空にできません",
queryKey: "メッセージ Key",
queryKeyPlaceholder: "ビジネス Key",
queryKeyRequired: "メッセージ Key は空にできません",
beginTime: "開始時間",
endTime: "終了時間",
maxNum: "最大件数",
query: "検索",
querying: "検索中...",
messageOverview: "基本情報",
messageBody: "メッセージ本文",
resendMessage: "再送信",
payloadCopied: "コピーしました",
dlqConsumerGroupPlaceholder: "コンシューマグループ (オプション)",
},
mqMonitoring: {
title: "モニタリング",
@@ -4343,6 +4460,13 @@ export default withEnglishFallback({
metaPartition: "パーティション {partition}",
metaOffset: "オフセット {offset}",
metaKey: "キー {key}",
rocketmqTopicOverview: "RocketMQ Topic 概要",
rocketmqQueueDetails: "キュー Offset 詳細",
queueCount: "キュー数",
brokerCount: "Broker 数",
tableQueue: "キュー",
tableBroker: "Broker",
noRocketMqQueueMetrics: "キュー指標がありません",
},
mqTenants: {
title: "テナント管理",
@@ -4454,6 +4578,40 @@ export default withEnglishFallback({
confirmClearBacklog: "サブスクリプション「{name}」のバックログメッセージをすべてクリアしますか?",
subscriptionNameRequired: "サブスクリプション名は必須です",
peekMessageKey: "key={key}",
searchPlaceholder: "サブスクリプション名を検索",
subscribedTopics: "サブスクライブされた Topic",
refreshing: "更新中...",
noConsumerGroups: "Consumer Group がありません",
viewDetail: "詳細",
editConfig: "設定",
save: "保存",
consumerDetailTitle: "コンシューマグループ詳細: {name}",
consumerConfigTitle: "コンシューマグループ設定: {name}",
consumerOverview: "概要",
consumerTerminals: "オンライン端末",
consumerConsumeDetail: "消費詳細",
noOnlineConsumers: "現在オンラインのコンシューマはいません",
noConsumeDetail: "消費詳細がありません",
operationTopic: "Topic",
consumeEnable: "消費を許可",
consumeBroadcastEnable: "ブロードキャスト消費を許可",
consumeFromMinEnable: "最小 offset から開始",
consumeMessageOrderly: "順序消費",
retryQueueNums: "リトライキュー数",
retryMaxTimes: "最大リトライ回数",
brokerId: "Broker ID",
whichBrokerWhenConsumeSlowly: "低速消費時の Broker ID",
mode: "モード",
typeFilter: "タイプフィルター",
rocketmqGroupType: {
normal: "通常",
fifo: "FIFO",
system: "システム",
},
rocketmqGroupMode: {
clustering: "クラスタリング",
broadcasting: "ブロードキャスト",
},
},
mqClients: {
title: "プロデューサー / コンシューマー",
@@ -4493,6 +4651,7 @@ export default withEnglishFallback({
noConsumersOnPartition: "このパーティションのサブスクリプションにアクティブ コンシューマーがありません",
noConsumersOnSubscription: "このサブスクリプションにアクティブ コンシューマーがありません",
permits: "許可数",
rocketmqClusterHint: "Broker 上の全オンライン Producer と Consumer Group 端末を表示します。トピックを選択すると、その Topic のクライアント詳細を表示できます。",
},
mqPolicies: {
title: "ポリシー管理",
@@ -4603,6 +4762,11 @@ export default withEnglishFallback({
roleController: "Controller",
roleFollower: "Follower",
noBrokerNodes: "Broker ノード情報がありません",
brokerName: "Broker 名",
brokerEndpoints: "Broker エンドポイント",
roleMaster: "Master",
roleSlave: "Slave",
clusterTitle: "クラスタ",
},
mqRaw: {
title: "Raw API",
@@ -4837,4 +5001,64 @@ export default withEnglishFallback({
saturday: "土曜日",
},
},
mqRocketmq: {
consumerGroupTitle: "コンシューマグループ",
searchConsumerGroup: "コンシューマグループを検索",
connectionNotReady: "接続が準備できていません。クラスタ情報を更新してください。",
tabSend: "メッセージ送信",
tabQuery: "メッセージ検索",
noDlqTopics: "デッドレター Topic が見つかりません。トピックページで「デッドレター」タイプフィルターを有効にしてから更新してください。",
topicFilterAll: "すべて",
topicFilterBusiness: "ビジネス",
topicFilterDlq: "デッドレター",
actionProduce: "公開",
actionConsume: "購読",
aclTitle: "アクセス制御",
actionMessageQuery: "メッセージ検索",
actionSendMessage: "メッセージ送信",
actionMore: "もっと見る",
actionDetailGroup: "詳細",
actionResetOffset: "オフセットリセット",
dlqConsumerGroup: "コンシューマグループ",
dlqConsumerGroupPlaceholder: "コンシューマグループ (オプション)",
dlqQueryByKey: "Key で検索",
viewDlqMessages: "デッドレターメッセージを表示",
namesrvAddr: "NameServer",
},
mqDlq: {
title: "デッドレターキュー",
refresh: "更新",
refreshing: "更新中...",
loading: "読み込み中...",
selectContext: "名前空間コンテキストを選択してください",
selectTopic: "DLQ Topic を選択してください",
topic: "DLQ Topic",
consumerGroup: "コンシューマグループ",
groupPlaceholder: "Peek コンシューマグループ (オプション)",
queryByKey: "Key で検索",
queryKey: "メッセージ Key",
queryKeyPlaceholder: "検索する Key",
loadMessages: "メッセージを読み込む",
loadingMessages: "メッセージ読み込み中...",
noDlqTopics: "DLQ Topic が見つかりません。トピックページで「デッドレター」タイプフィルターを有効にしてから更新してください。",
noMessages: "メッセージがありません",
},
mqTrace: {
title: "メッセージトレース",
msgId: "メッセージ ID",
msgIdPlaceholder: "元のメッセージ MsgId",
msgIdRequired: "メッセージ ID は空にできません",
queryKeyRequired: "Message Key は空にできません",
selectTopicBeforeQuery: "トレースを検索する前に Topic を選択してください",
traceTopic: "トレース Topic",
traceTopicPlaceholder: "RMQ_SYS_TRACE_TOPIC",
query: "トレース検索",
querying: "検索中...",
noTrace: "トレース記録が見つかりませんでした",
searchHint: "まずメッセージを検索し、その後「消費トレースを表示」をクリックしてください",
viewTrace: "消費トレースを表示",
detailTitle: "消費トレース詳細",
traceTopicRouteMissing:
"トレース Topic が NameServer にルート情報がありません (CODE: 17)。\n\n一般的な原因:\n1. Broker でメッセージトレースが有効になっていない: broker.conf で traceTopicEnable=true を設定し Broker を再起動\n2. Producer/Consumer でトレースが有効になっていない: setEnableTrace(true)\n3. カスタムトレース Topic を使用している場合は、上で正しいトレース Topic を選択してください",
},
});
+224
View File
@@ -531,6 +531,16 @@ export default withEnglishFallback({
elasticsearchKibanaProxyMode: "Proxy do Kibana",
elasticsearchKibanaHost: "Host do Kibana",
elasticsearchKibanaBasePath: "Caminho Base",
mqSystemRocketMq: "Apache RocketMQ",
rocketmqNamesrvAddr: "Endereço do NameServer",
rocketmqNamesrvAddrPlaceholder: "127.0.0.1:9876",
rocketmqNamesrvAddrRequired: "O endereço do NameServer do RocketMQ não pode estar vazio",
rocketmqNamesrvAddrInvalid: "Endereço do NameServer do RocketMQ inválido",
rocketmqClusterName: "Nome do cluster",
rocketmqClusterNamePlaceholder: "Opcional, ex: DefaultCluster",
rocketmqAccessKey: "Access Key",
rocketmqSecretKey: "Secret Key",
rocketmqAclAuth: "ACL",
jdbcMissingRuntimeDependencyHint: "O driver JDBC atual está sem dependências de execução. Por favor, instale-o usando as coordenadas Maven no 'Gerenciamento de Drivers' ou importe o driver e todos os JARs de dependência de uma só vez.",
},
editor: {
@@ -4179,6 +4189,71 @@ export default withEnglishFallback({
currentPartitionsUnknown: "O número atual de partições é desconhecido, não é possível ajustar com segurança.",
partitionMustIncrease: "O novo número de partições deve ser maior que o atual.",
confirmDelete: 'Tem certeza de que deseja excluir o tópico "{name}"? Esta operação é irreversível.',
systemTopic: "Tópico do sistema",
includeSystemTopics: "Incluir tópicos do sistema",
noUserTopics: "Nenhum tópico de negócio. Marque o filtro de tipo para ver tópicos internos/retry/DLQ.",
typeFilter: "Filtro de tipo",
messageType: "Tipo de mensagem",
messageTypeHint: "Após a criação, escreve a propriedade message.type na configuração do Broker Topic, consistente com o RocketMQ Dashboard.",
rocketmqType: {
normal: "Normal",
delay: "Atrasada",
fifo: "Ordenada",
transaction: "Transacional",
unspecified: "Não especificado",
retry: "Retry",
dlq: "DLQ",
system: "Sistema",
},
queues: "Filas",
queueCount: "{count} filas",
clusterName: "Cluster",
brokerName: "Broker",
allBrokers: "Todos os Brokers Master",
readQueues: "Número de filas de leitura",
writeQueues: "Número de filas de escrita",
perm: "Permissão",
permReadWrite: "Leitura e Escrita (6)",
permRead: "Somente leitura (4)",
permWrite: "Somente escrita (2)",
actionStatus: "Status",
actionRoute: "Rota",
actionConsumers: "Consumidores",
actionConfig: "Configuração",
actionSend: "Enviar mensagem",
actionMessageQuery: "Consulta de mensagens",
actionReset: "Redefinir posição",
actionSkip: "Pular backlog",
topicStatusTitle: "Status do tópico: {name}",
topicRouteTitle: "Rota do tópico: {name}",
topicConsumersTitle: "Consumidores: {name}",
topicConfigTitle: "Configuração do tópico: {name}",
topicConfigHint: "Consistente com o RocketMQ Dashboard, suporta apenas modificar o número de filas de leitura/escrita e permissões. O tipo de mensagem é definido ao criar o tópico.",
topicResetTitle: "Redefinir posição: {name}",
topicSkipTitle: "Pular backlog: {name}",
confirmSkip: 'Tem certeza de que deseja redefinir todos os grupos de consumidores do tópico "{name}" para a posição mais recente?',
skipHint: "Redefine a posição de todos os grupos de consumidores inscritos neste tópico para a mais recente.",
saveConfig: "Salvar configuração",
resetPosition: "Redefinir posição",
positionEarliest: "Mais antigo",
positionLatest: "Mais recente",
positionTimestamp: "Por timestamp",
timestamp: "Timestamp",
invalidTimestamp: "Insira um timestamp válido",
resetTimePreview: "Hora atual: {time}",
noConsumerGroups: "Nenhum grupo de consumidores",
noConsumers: "Este tópico não possui grupos de consumidores",
manageConsumers: "Gerenciar",
routeQueues: "Dados da fila",
routeBrokers: "Dados do Broker",
brokerAddrs: "Endereço do Broker",
noRouteData: "Sem dados de rota",
noQueueStats: "Nenhuma estatística de fila",
noConfig: "Configuração do tópico não retornada",
queueId: "Fila",
beginOffset: "Offset mínimo",
endOffset: "Offset máximo",
messageCount: "Número de mensagens",
},
mqAdmin: {
viewTenant: "Ver locatário",
@@ -4196,6 +4271,12 @@ export default withEnglishFallback({
tabPolicies: "Políticas",
tabPermissions: "Permissões",
tabRawApi: "Raw API",
tabCluster: "Cluster",
tabConsumers: "Consumidores",
tabProducers: "Produtores",
tabDlq: "DLQ",
tabTrace: "Rastreamento",
tabAcl: "ACL",
},
mqMessages: {
title: "Enviar mensagem",
@@ -4239,6 +4320,42 @@ export default withEnglishFallback({
metaOffset: "offset {offset}",
metaKey: "chave {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
selectTopicPlaceholder: "Selecione um tópico",
messageTag: "Tag da mensagem",
queryTitle: "Consulta de mensagens",
queryTabTopic: "Por tópico",
queryTabKey: "Por Key",
queryTabMsgId: "Por MsgId",
queryTopicLabel: "Tópico",
noQueryResults: "Nenhum resultado encontrado",
tableMessageId: "ID da mensagem",
tableTag: "Tag",
tableKey: "Key",
tableStoreTime: "Hora de armazenamento",
tableOperation: "Operação",
viewDetails: "Ver",
messageDetails: "Detalhes da mensagem",
search: "Pesquisar",
messageQuery: "Consulta de mensagens",
queryByMsgId: "Por MsgId",
queryByKey: "Por Key",
queryByTopic: "Por tópico e hora",
msgId: "ID da mensagem",
msgIdPlaceholder: "Ex: 7F000001...",
msgIdRequired: "O ID da mensagem não pode estar vazio",
queryKey: "Key da mensagem",
queryKeyPlaceholder: "Key de negócio",
queryKeyRequired: "A Key da mensagem não pode estar vazia",
beginTime: "Hora inicial",
endTime: "Hora final",
maxNum: "Número máximo",
query: "Pesquisar",
querying: "Pesquisando...",
messageOverview: "Informações básicas",
messageBody: "Corpo da mensagem",
resendMessage: "Reenviar",
payloadCopied: "Copiado",
dlqConsumerGroupPlaceholder: "Grupo de consumidores (opcional)",
},
mqMonitoring: {
title: "Monitoramento",
@@ -4345,6 +4462,13 @@ export default withEnglishFallback({
metaPartition: "partição {partition}",
metaOffset: "offset {offset}",
metaKey: "chave {key}",
rocketmqTopicOverview: "Visão geral do tópico RocketMQ",
rocketmqQueueDetails: "Detalhes do offset da fila",
queueCount: "Número de filas",
brokerCount: "Número de Brokers",
tableQueue: "Fila",
tableBroker: "Broker",
noRocketMqQueueMetrics: "Nenhuma métrica de fila",
},
mqTenants: {
title: "Gerenciamento de locatários",
@@ -4456,6 +4580,40 @@ export default withEnglishFallback({
confirmClearBacklog: 'Limpar todas as mensagens em backlog da assinatura "{name}"?',
subscriptionNameRequired: "O nome da assinatura é obrigatório",
peekMessageKey: "chave={key}",
searchPlaceholder: "Pesquisar nome da assinatura",
subscribedTopics: "Tópicos inscritos",
refreshing: "Atualizando...",
noConsumerGroups: "Nenhum Consumer Group",
viewDetail: "Detalhes",
editConfig: "Configuração",
save: "Salvar",
consumerDetailTitle: "Detalhes do grupo de consumidores: {name}",
consumerConfigTitle: "Configuração do grupo de consumidores: {name}",
consumerOverview: "Visão geral",
consumerTerminals: "Terminais online",
consumerConsumeDetail: "Detalhes do consumo",
noOnlineConsumers: "Nenhum consumidor online no momento",
noConsumeDetail: "Nenhum detalhe de consumo",
operationTopic: "Topic",
consumeEnable: "Permitir consumo",
consumeBroadcastEnable: "Permitir consumo em broadcast",
consumeFromMinEnable: "Iniciar do offset mínimo",
consumeMessageOrderly: "Consumo ordenado",
retryQueueNums: "Número de filas de retry",
retryMaxTimes: "Máximo de tentativas de retry",
brokerId: "Broker ID",
whichBrokerWhenConsumeSlowly: "Broker ID para consumo lento",
mode: "Modo",
typeFilter: "Filtro de tipo",
rocketmqGroupType: {
normal: "Normal",
fifo: "Ordenado",
system: "Sistema",
},
rocketmqGroupMode: {
clustering: "Cluster",
broadcasting: "Broadcast",
},
},
mqClients: {
title: "Produtores / Consumidores",
@@ -4495,6 +4653,7 @@ export default withEnglishFallback({
noConsumersOnPartition: "Nenhum consumidor ativo nesta assinatura de partição",
noConsumersOnSubscription: "Nenhum consumidor ativo nesta assinatura",
permits: "Permissões",
rocketmqClusterHint: "Exibe todos os terminais de Producer e Consumer Group online no Broker. Selecione um tópico para ver os detalhes do cliente do tópico.",
},
mqPolicies: {
title: "Gerenciamento de políticas",
@@ -4605,6 +4764,11 @@ export default withEnglishFallback({
roleController: "Controller",
roleFollower: "Follower",
noBrokerNodes: "Nenhuma informação de nó do broker",
brokerName: "Nome do Broker",
brokerEndpoints: "Endpoints do Broker",
roleMaster: "Master",
roleSlave: "Slave",
clusterTitle: "Cluster",
},
mqRaw: {
title: "Raw API",
@@ -4839,4 +5003,64 @@ export default withEnglishFallback({
saturday: "Sábado",
},
},
mqRocketmq: {
consumerGroupTitle: "Grupo de consumidores",
searchConsumerGroup: "Pesquisar grupo de consumidores",
connectionNotReady: "Conexão não pronta, atualize as informações do cluster",
tabSend: "Enviar mensagem",
tabQuery: "Consulta de mensagens",
noDlqTopics: 'Nenhum tópico DLQ encontrado. Ative o filtro de tipo "DLQ" na página de tópicos e atualize.',
topicFilterAll: "Todos",
topicFilterBusiness: "Negócio",
topicFilterDlq: "DLQ",
actionProduce: "Publicar",
actionConsume: "Assinar",
aclTitle: "Controle de acesso",
actionMessageQuery: "Consulta de mensagens",
actionSendMessage: "Enviar mensagem",
actionMore: "Mais",
actionDetailGroup: "Detalhes",
actionResetOffset: "Redefinir offset",
dlqConsumerGroup: "Grupo de consumidores",
dlqConsumerGroupPlaceholder: "Grupo de consumidores (opcional)",
dlqQueryByKey: "Consultar por Key",
viewDlqMessages: "Ver mensagens DLQ",
namesrvAddr: "NameServer",
},
mqDlq: {
title: "Fila DLQ",
refresh: "Atualizar",
refreshing: "Atualizando...",
loading: "Carregando...",
selectContext: "Selecione o contexto do namespace primeiro",
selectTopic: "Selecione um tópico DLQ",
topic: "DLQ Topic",
consumerGroup: "Grupo de consumidores",
groupPlaceholder: "Grupo de consumidores para peek (opcional)",
queryByKey: "Consultar por Key",
queryKey: "Key da mensagem",
queryKeyPlaceholder: "Key a ser pesquisada",
loadMessages: "Carregar mensagens",
loadingMessages: "Carregando mensagens...",
noDlqTopics: 'Nenhum tópico DLQ encontrado. Ative o filtro de tipo "DLQ" na página de tópicos e atualize.',
noMessages: "Nenhuma mensagem",
},
mqTrace: {
title: "Rastreamento de mensagens",
msgId: "ID da mensagem",
msgIdPlaceholder: "MsgId original da mensagem",
msgIdRequired: "O ID da mensagem não pode estar vazio",
queryKeyRequired: "A Message Key não pode estar vazia",
selectTopicBeforeQuery: "Selecione um tópico antes de consultar o rastreamento",
traceTopic: "Tópico de rastreamento",
traceTopicPlaceholder: "RMQ_SYS_TRACE_TOPIC",
query: "Consultar rastreamento",
querying: "Consultando...",
noTrace: "Nenhum registro de rastreamento encontrado",
searchHint: 'Pesquise uma mensagem primeiro e clique em "Ver rastreamento de consumo"',
viewTrace: "Ver rastreamento de consumo",
detailTitle: "Detalhes do rastreamento de consumo",
traceTopicRouteMissing:
"O tópico de rastreamento não possui informações de rota no NameServer (CODE: 17).\n\nCausas comuns:\n1. Broker não habilitou rastreamento de mensagens: defina traceTopicEnable=true no broker.conf e reinicie o Broker\n2. Producer/Consumer não habilitou rastreamento: setEnableTrace(true)\n3. Usou um tópico de rastreamento personalizado, selecione o tópico correto acima.",
},
});
+228 -6
View File
@@ -339,6 +339,16 @@ export default withEnglishFallback({
mqSystem: "系统",
mqSystemPulsar: "Apache Pulsar",
mqSystemKafka: "Apache Kafka",
mqSystemRocketMq: "Apache RocketMQ",
rocketmqNamesrvAddr: "NameServer 地址",
rocketmqNamesrvAddrPlaceholder: "127.0.0.1:9876",
rocketmqNamesrvAddrRequired: "RocketMQ NameServer 地址不能为空",
rocketmqNamesrvAddrInvalid: "RocketMQ NameServer 地址无效",
rocketmqClusterName: "集群名称",
rocketmqClusterNamePlaceholder: "可选,例如 DefaultCluster",
rocketmqAccessKey: "Access Key",
rocketmqSecretKey: "Secret Key",
rocketmqAclAuth: "ACL",
mqBootstrapServers: "Bootstrap Servers",
mqBootstrapServersPlaceholder: "127.0.0.1:9092",
mqBootstrapServersRequired: "Kafka Bootstrap Servers 不能为空",
@@ -4257,7 +4267,7 @@ export default withEnglishFallback({
},
},
mqTopics: {
title: "主题管理",
title: "主题",
searchPlaceholder: "搜索主题",
includeNonPersistent: "包含非持久化主题",
refresh: "刷新",
@@ -4274,6 +4284,22 @@ export default withEnglishFallback({
nonPersistent: "非持久化",
partitionedTopic: "分区主题",
normalTopic: "普通主题",
systemTopic: "系统主题",
includeSystemTopics: "包含系统 Topic",
noUserTopics: "没有业务 Topic。可勾选类型筛选查看内部/重试/死信主题。",
typeFilter: "类型筛选",
messageType: "消息类型",
messageTypeHint: "创建后写入 Broker Topic 配置的 message.type 属性,与 RocketMQ Dashboard 一致。",
rocketmqType: {
normal: "普通",
delay: "延迟",
fifo: "顺序",
transaction: "事务",
unspecified: "未指定",
retry: "重试",
dlq: "死信",
system: "系统",
},
partitionCount: "{count} 个分区",
partitionsUnknown: "分区数未知",
adjustPartitions: "调整分区",
@@ -4298,6 +4324,79 @@ export default withEnglishFallback({
currentPartitionsUnknown: "当前分区数未知,无法安全调整分区。",
partitionMustIncrease: "新分区数必须大于当前分区数。",
confirmDelete: "确定要删除主题「{name}」吗?此操作不可逆。",
queues: "队列",
queueCount: "{count} 个队列",
clusterName: "集群",
brokerName: "Broker",
allBrokers: "全部 Master Broker",
readQueues: "读队列数",
writeQueues: "写队列数",
perm: "权限",
permReadWrite: "读写 (6)",
permRead: "只读 (4)",
permWrite: "只写 (2)",
actionStatus: "状态",
actionRoute: "路由",
actionConsumers: "消费者",
actionConfig: "配置",
actionSend: "发消息",
actionMessageQuery: "消息查询",
actionReset: "重置位点",
actionSkip: "跳过积压",
topicStatusTitle: "Topic 状态: {name}",
topicRouteTitle: "Topic 路由: {name}",
topicConsumersTitle: "消费者: {name}",
topicConfigTitle: "Topic 配置: {name}",
topicConfigHint: "与 RocketMQ Dashboard 一致,仅支持修改读/写队列数和权限。消息类型在创建 Topic 时设置。",
topicResetTitle: "重置位点: {name}",
topicSkipTitle: "跳过积压: {name}",
confirmSkip: "确定将 Topic「{name}」的所有消费组重置到最新位点吗?",
skipHint: "将该 Topic 下所有已订阅消费组的位点重置到最新。",
saveConfig: "保存配置",
resetPosition: "重置位点",
positionEarliest: "最早",
positionLatest: "最新",
positionTimestamp: "按时间",
timestamp: "时间",
invalidTimestamp: "请输入有效的时间",
resetTimePreview: "当前时间: {time}",
noConsumerGroups: "暂无消费组",
noConsumers: "该 Topic 暂无消费组",
manageConsumers: "管理",
routeQueues: "队列数据",
routeBrokers: "Broker 数据",
brokerAddrs: "Broker 地址",
noRouteData: "无路由数据",
noQueueStats: "暂无队列统计",
noConfig: "未返回 Topic 配置",
queueId: "队列",
beginOffset: "最小 Offset",
endOffset: "最大 Offset",
messageCount: "消息数",
},
mqRocketmq: {
consumerGroupTitle: "消费组",
searchConsumerGroup: "搜索消费组",
connectionNotReady: "连接未就绪,请刷新集群信息",
tabSend: "发送消息",
tabQuery: "消息查询",
noDlqTopics: "未找到死信 Topic。可在主题页启用「死信」类型筛选后刷新。",
topicFilterAll: "全部",
topicFilterBusiness: "业务",
topicFilterDlq: "死信",
actionProduce: "发布",
actionConsume: "订阅",
aclTitle: "访问控制",
actionMessageQuery: "消息查询",
actionSendMessage: "发消息",
actionMore: "更多",
actionDetailGroup: "详情",
actionResetOffset: "重置位点",
dlqConsumerGroup: "消费组",
dlqConsumerGroupPlaceholder: "消费组(可选)",
dlqQueryByKey: "按 Key 查询",
viewDlqMessages: "查看死信消息",
namesrvAddr: "NameServer",
},
mqAdmin: {
viewTenant: "查看租户",
@@ -4312,6 +4411,12 @@ export default withEnglishFallback({
tabClients: "客户端",
tabMessages: "消息",
tabBroker: "Broker",
tabCluster: "集群",
tabConsumers: "消费者",
tabProducers: "生产者",
tabDlq: "死信",
tabTrace: "轨迹",
tabAcl: "ACL",
tabPolicies: "策略",
tabPermissions: "权限",
tabRawApi: "Raw API",
@@ -4326,12 +4431,14 @@ export default withEnglishFallback({
sendSuccess: "消息发送成功 — 分区: {partition},偏移: {offset}",
targetTopic: "目标主题",
topicLoading: "加载中...",
topicSearchPlaceholder: "输入或搜索主题...",
selectTopicPlaceholder: "请选择主题",
topicSearchPlaceholder: "请选择主题",
refreshTopicList: "刷新主题列表",
topicOptionWithPartitions: "{label} ({partitions} 分区)",
noTopicsAvailable: "暂无可用主题",
topicSearchHint: "可输入关键词搜索,也可以直接粘贴主题名称。",
topicSearchHint: "输入关键词筛选下拉列表;选中主题后自动查询。再次点击时已选主题置灰,新输入用于筛选。",
messageKey: "消息键 (Key)",
messageTag: "消息标签 (Tag)",
optional: "可选",
messageContent: "消息内容",
formatJson: "格式化 JSON",
@@ -4358,6 +4465,40 @@ export default withEnglishFallback({
metaOffset: "偏移 {offset}",
metaKey: "键 {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
queryTitle: "消息查询",
queryTabTopic: "按 Topic",
queryTabKey: "按 Key",
queryTabMsgId: "按 MsgId",
queryTopicLabel: "主题",
noQueryResults: "没有查到符合条件的结果",
tableMessageId: "消息 ID",
tableTag: "标签",
tableKey: "键",
tableStoreTime: "存储时间",
tableOperation: "操作",
viewDetails: "查看",
messageDetails: "消息详情",
search: "查询",
messageQuery: "消息查询",
queryByMsgId: "按 MsgId",
queryByKey: "按 Key",
queryByTopic: "按 Topic 时间",
msgId: "消息 ID",
msgIdPlaceholder: "例如 7F000001...",
msgIdRequired: "消息 ID 不能为空",
queryKey: "消息 Key",
queryKeyPlaceholder: "业务 Key",
queryKeyRequired: "消息 Key 不能为空",
beginTime: "开始时间",
endTime: "结束时间",
maxNum: "最大条数",
query: "查询",
querying: "查询中...",
messageOverview: "基本信息",
messageBody: "消息体",
resendMessage: "重新发送",
payloadCopied: "已复制",
dlqConsumerGroupPlaceholder: "消费组(可选)",
},
mqMonitoring: {
title: "监控统计",
@@ -4464,6 +4605,13 @@ export default withEnglishFallback({
metaPartition: "分区 {partition}",
metaOffset: "偏移 {offset}",
metaKey: "键 {key}",
rocketmqTopicOverview: "RocketMQ Topic 概览",
rocketmqQueueDetails: "队列 Offset 明细",
queueCount: "队列数",
brokerCount: "Broker 数",
tableQueue: "队列",
tableBroker: "Broker",
noRocketMqQueueMetrics: "暂无队列指标",
},
mqTenants: {
title: "租户管理",
@@ -4522,11 +4670,46 @@ export default withEnglishFallback({
title: "订阅管理",
createSubscription: "创建订阅",
selectTopicFirst: "请先选择一个主题",
searchPlaceholder: "搜索订阅名称",
subscribedTopics: "订阅 Topic",
readOnly: "当前连接为只读模式,不能执行写操作",
loading: "加载中...",
refresh: "刷新",
refreshing: "刷新中...",
noSubscriptions: "该主题暂无订阅",
noConsumerGroups: "暂无 Consumer Group",
viewDetail: "详情",
editConfig: "配置",
save: "保存",
consumerDetailTitle: "消费组详情: {name}",
consumerConfigTitle: "消费组配置: {name}",
consumerOverview: "概览",
consumerTerminals: "在线终端",
consumerConsumeDetail: "消费详情",
noOnlineConsumers: "当前没有在线消费者",
noConsumeDetail: "暂无消费详情",
operationTopic: "Topic",
consumeEnable: "允许消费",
consumeBroadcastEnable: "允许广播消费",
consumeFromMinEnable: "从最小 offset 开始",
consumeMessageOrderly: "顺序消费",
retryQueueNums: "重试队列数",
retryMaxTimes: "最大重试次数",
brokerId: "Broker ID",
whichBrokerWhenConsumeSlowly: "慢消费 Broker ID",
subscriptionName: "订阅名称",
type: "类型",
mode: "模式",
typeFilter: "类型筛选",
rocketmqGroupType: {
normal: "普通",
fifo: "顺序",
system: "系统",
},
rocketmqGroupMode: {
clustering: "集群",
broadcasting: "广播",
},
backlog: "积压消息",
consumeRate: "消费速率",
consumers: "消费者",
@@ -4536,7 +4719,7 @@ export default withEnglishFallback({
resetCursor: "重置游标",
skipMessages: "跳过消息",
clearBacklog: "清空积压",
peek: "Peek",
peek: "查看消息",
expireMessages: "过期消息",
delete: "删除",
createDialogTitle: "创建订阅",
@@ -4562,7 +4745,6 @@ export default withEnglishFallback({
skip: "跳过",
peekDialogTitle: "Peek 消息: {name}",
count: "数量",
refresh: "刷新",
noPeekMessages: "没有可查看的消息",
close: "关闭",
expireDialogTitle: "过期消息: {name}",
@@ -4583,7 +4765,7 @@ export default withEnglishFallback({
refresh: "刷新",
refreshing: "刷新中...",
selectTopicFirst: "请先选择一个主题",
confirmUnload: "确认卸载当前主题?活跃生产者和消费者会重新连接。",
rocketmqClusterHint: "展示 Broker 上全部在线 Producer 与 Consumer Group 终端。选择主题后可查看该 Topic 的客户端详情。",
aggregateTopic: "聚合主题",
kafkaPartitionStatus: "Kafka 分区状态",
partitionCount: "{count} 个分区",
@@ -4716,6 +4898,8 @@ export default withEnglishFallback({
controller: "Controller",
controllerNode: "Node {id} · {host}",
brokerNodes: "Broker 节点",
brokerName: "Broker 名称",
brokerEndpoints: "Broker 端点",
nodeId: "Node ID",
host: "Host",
port: "Port",
@@ -4723,7 +4907,45 @@ export default withEnglishFallback({
role: "角色",
roleController: "Controller",
roleFollower: "Follower",
roleMaster: "Master",
roleSlave: "Slave",
noBrokerNodes: "暂无 Broker 节点信息",
clusterTitle: "集群",
},
mqDlq: {
title: "死信队列",
refresh: "刷新",
refreshing: "刷新中...",
loading: "加载中...",
selectContext: "请先选择命名空间上下文",
selectTopic: "请选择 DLQ Topic",
topic: "DLQ Topic",
consumerGroup: "消费组",
groupPlaceholder: "Peek 消费组(可选)",
queryByKey: "按 Key 查询",
queryKey: "消息 Key",
queryKeyPlaceholder: "要搜索的 Key",
loadMessages: "加载消息",
loadingMessages: "消息加载中...",
noDlqTopics: "未找到 DLQ Topic。可在主题页启用「死信」类型筛选后刷新。",
noMessages: "暂无消息",
},
mqTrace: {
title: "消息轨迹",
msgId: "消息 ID",
msgIdPlaceholder: "原始消息 MsgId",
msgIdRequired: "消息 ID 不能为空",
queryKeyRequired: "Message Key 不能为空",
selectTopicBeforeQuery: "请先选择 Topic 再查询轨迹",
traceTopic: "轨迹 Topic",
traceTopicPlaceholder: "RMQ_SYS_TRACE_TOPIC",
query: "查询轨迹",
querying: "查询中...",
noTrace: "未找到轨迹记录",
searchHint: "请先搜索消息,再点击「查看消费轨迹」",
viewTrace: "查看消费轨迹",
detailTitle: "消费轨迹详情",
traceTopicRouteMissing: "轨迹 Topic 在 NameServer 上没有路由信息(CODE: 17)。\n\n常见原因:\n1. Broker 未开启消息轨迹:在 broker.conf 中设置 traceTopicEnable=true 并重启 Broker\n2. Producer/Consumer 未开启轨迹:setEnableTrace(true)\n3. 使用了自定义轨迹 Topic,请在上方选择正确的轨迹 Topic",
},
mqRaw: {
title: "Raw API",
+223
View File
@@ -531,6 +531,16 @@ export default withEnglishFallback({
elasticsearchKibanaProxyMode: "Kibana 代理",
elasticsearchKibanaHost: "Kibana 主機",
elasticsearchKibanaBasePath: "基礎路徑",
mqSystemRocketMq: "Apache RocketMQ",
rocketmqNamesrvAddr: "NameServer 地址",
rocketmqNamesrvAddrPlaceholder: "127.0.0.1:9876",
rocketmqNamesrvAddrRequired: "RocketMQ NameServer 地址不能為空",
rocketmqNamesrvAddrInvalid: "RocketMQ NameServer 地址無效",
rocketmqClusterName: "集群名稱",
rocketmqClusterNamePlaceholder: "可選,例如 DefaultCluster",
rocketmqAccessKey: "Access Key",
rocketmqSecretKey: "Secret Key",
rocketmqAclAuth: "ACL",
jdbcMissingRuntimeDependencyHint: "目前 JDBC 驅動缺少執行依賴。請在「驅動管理」中使用 Maven 座標安裝,或一次匯入驅動及全部依賴 JAR。",
},
editor: {
@@ -4178,6 +4188,71 @@ export default withEnglishFallback({
currentPartitionsUnknown: "目前分區數未知,無法安全調整分區。",
partitionMustIncrease: "新分區數必須大於目前分區數。",
confirmDelete: "確定要刪除主題「{name}」嗎?此操作無法復原。",
systemTopic: "系統主題",
includeSystemTopics: "包含系統 Topic",
noUserTopics: "沒有業務 Topic。可勾選類型篩選檢視內部/重試/死信主題。",
typeFilter: "類型篩選",
messageType: "消息類型",
messageTypeHint: "創建後寫入 Broker Topic 配置的 message.type 屬性,與 RocketMQ Dashboard 一致。",
rocketmqType: {
normal: "普通",
delay: "延遲",
fifo: "順序",
transaction: "事務",
unspecified: "未指定",
retry: "重試",
dlq: "死信",
system: "系統",
},
queues: "隊列",
queueCount: "{count} 個隊列",
clusterName: "集群",
brokerName: "Broker",
allBrokers: "全部 Master Broker",
readQueues: "讀隊列數",
writeQueues: "寫隊列數",
perm: "權限",
permReadWrite: "讀寫 (6)",
permRead: "唯讀 (4)",
permWrite: "唯寫 (2)",
actionStatus: "狀態",
actionRoute: "路由",
actionConsumers: "消費者",
actionConfig: "配置",
actionSend: "發送消息",
actionMessageQuery: "消息查詢",
actionReset: "重置位點",
actionSkip: "跳過積壓",
topicStatusTitle: "Topic 狀態: {name}",
topicRouteTitle: "Topic 路由: {name}",
topicConsumersTitle: "消費者: {name}",
topicConfigTitle: "Topic 配置: {name}",
topicConfigHint: "與 RocketMQ Dashboard 一致,僅支援修改讀/寫隊列數和權限。消息類型在創建 Topic 時設置。",
topicResetTitle: "重置位點: {name}",
topicSkipTitle: "跳過積壓: {name}",
confirmSkip: "確定將 Topic「{name}」的所有消費組重置到最新位點嗎?",
skipHint: "將該 Topic 下所有已訂閱消費組的位點重置到最新。",
saveConfig: "儲存配置",
resetPosition: "重置位點",
positionEarliest: "最早",
positionLatest: "最新",
positionTimestamp: "按時間",
timestamp: "時間",
invalidTimestamp: "請輸入有效的時間",
resetTimePreview: "當前時間: {time}",
noConsumerGroups: "暫無消費組",
noConsumers: "該 Topic 暫無消費組",
manageConsumers: "管理",
routeQueues: "隊列數據",
routeBrokers: "Broker 數據",
brokerAddrs: "Broker 地址",
noRouteData: "無路由數據",
noQueueStats: "暫無隊列統計",
noConfig: "未返回 Topic 配置",
queueId: "隊列",
beginOffset: "最小 Offset",
endOffset: "最大 Offset",
messageCount: "消息數",
},
mqAdmin: {
viewTenant: "檢視租用戶",
@@ -4195,6 +4270,12 @@ export default withEnglishFallback({
tabPolicies: "策略",
tabPermissions: "權限",
tabRawApi: "Raw API",
tabCluster: "集群",
tabConsumers: "消費者",
tabProducers: "生產者",
tabDlq: "死信",
tabTrace: "軌跡",
tabAcl: "ACL",
},
mqMessages: {
title: "傳送訊息",
@@ -4238,6 +4319,42 @@ export default withEnglishFallback({
metaOffset: "偏移 {offset}",
metaKey: "鍵 {key}",
jsonBodyPlaceholder: "{'{'}\"key\": \"value\"{'}'}",
selectTopicPlaceholder: "請選擇主題",
messageTag: "消息標籤 (Tag)",
queryTitle: "消息查詢",
queryTabTopic: "按 Topic",
queryTabKey: "按 Key",
queryTabMsgId: "按 MsgId",
queryTopicLabel: "主題",
noQueryResults: "沒有查到符合條件的結果",
tableMessageId: "消息 ID",
tableTag: "標籤",
tableKey: "鍵",
tableStoreTime: "儲存時間",
tableOperation: "操作",
viewDetails: "檢視",
messageDetails: "消息詳情",
search: "查詢",
messageQuery: "消息查詢",
queryByMsgId: "按 MsgId",
queryByKey: "按 Key",
queryByTopic: "按 Topic 時間",
msgId: "消息 ID",
msgIdPlaceholder: "例如 7F000001...",
msgIdRequired: "消息 ID 不能為空",
queryKey: "消息 Key",
queryKeyPlaceholder: "業務 Key",
queryKeyRequired: "消息 Key 不能為空",
beginTime: "開始時間",
endTime: "結束時間",
maxNum: "最大條數",
query: "查詢",
querying: "查詢中...",
messageOverview: "基本資訊",
messageBody: "消息體",
resendMessage: "重新發送",
payloadCopied: "已複製",
dlqConsumerGroupPlaceholder: "消費組(可選)",
},
mqMonitoring: {
title: "監控統計",
@@ -4344,6 +4461,13 @@ export default withEnglishFallback({
metaPartition: "分區 {partition}",
metaOffset: "偏移 {offset}",
metaKey: "鍵 {key}",
rocketmqTopicOverview: "RocketMQ Topic 概覽",
rocketmqQueueDetails: "隊列 Offset 明細",
queueCount: "隊列數",
brokerCount: "Broker 數",
tableQueue: "隊列",
tableBroker: "Broker",
noRocketMqQueueMetrics: "暫無隊列指標",
},
mqTenants: {
title: "租用戶管理",
@@ -4455,6 +4579,40 @@ export default withEnglishFallback({
confirmClearBacklog: '確定要清空訂閱 "{name}" 的所有積壓訊息嗎?',
subscriptionNameRequired: "訂閱名稱不能為空",
peekMessageKey: "key={key}",
searchPlaceholder: "搜尋訂閱名稱",
subscribedTopics: "訂閱 Topic",
refreshing: "重新整理中...",
noConsumerGroups: "暫無 Consumer Group",
viewDetail: "詳情",
editConfig: "配置",
save: "儲存",
consumerDetailTitle: "消費組詳情: {name}",
consumerConfigTitle: "消費組配置: {name}",
consumerOverview: "概覽",
consumerTerminals: "線上終端",
consumerConsumeDetail: "消費詳情",
noOnlineConsumers: "當前沒有線上消費者",
noConsumeDetail: "暫無消費詳情",
operationTopic: "Topic",
consumeEnable: "允許消費",
consumeBroadcastEnable: "允許廣播消費",
consumeFromMinEnable: "從最小 offset 開始",
consumeMessageOrderly: "順序消費",
retryQueueNums: "重試隊列數",
retryMaxTimes: "最大重試次數",
brokerId: "Broker ID",
whichBrokerWhenConsumeSlowly: "慢消費 Broker ID",
mode: "模式",
typeFilter: "類型篩選",
rocketmqGroupType: {
normal: "普通",
fifo: "順序",
system: "系統",
},
rocketmqGroupMode: {
clustering: "集群",
broadcasting: "廣播",
},
},
mqClients: {
title: "生產者 / 消費者",
@@ -4494,6 +4652,7 @@ export default withEnglishFallback({
noConsumersOnPartition: "目前分區訂閱沒有活躍消費者",
noConsumersOnSubscription: "目前訂閱沒有活躍消費者",
permits: "許可數",
rocketmqClusterHint: "展示 Broker 上全部線上 Producer 與 Consumer Group 終端。選擇主題後可檢視該 Topic 的客戶端詳情。",
},
mqPolicies: {
title: "策略管理",
@@ -4604,6 +4763,11 @@ export default withEnglishFallback({
roleController: "Controller",
roleFollower: "Follower",
noBrokerNodes: "暫無 Broker 節點資訊",
brokerName: "Broker 名稱",
brokerEndpoints: "Broker 端點",
roleMaster: "Master",
roleSlave: "Slave",
clusterTitle: "集群",
},
mqRaw: {
title: "Raw API",
@@ -4838,4 +5002,63 @@ export default withEnglishFallback({
saturday: "星期六",
},
},
mqRocketmq: {
consumerGroupTitle: "消費組",
searchConsumerGroup: "搜尋消費組",
connectionNotReady: "連線未就緒,請重新整理集群資訊",
tabSend: "發送消息",
tabQuery: "消息查詢",
noDlqTopics: "未找到死信 Topic。可在主題頁啟用「死信」類型篩選後重新整理。",
topicFilterAll: "全部",
topicFilterBusiness: "業務",
topicFilterDlq: "死信",
actionProduce: "發布",
actionConsume: "訂閱",
aclTitle: "存取控制",
actionMessageQuery: "消息查詢",
actionSendMessage: "發送消息",
actionMore: "更多",
actionDetailGroup: "詳情",
actionResetOffset: "重置位點",
dlqConsumerGroup: "消費組",
dlqConsumerGroupPlaceholder: "消費組(可選)",
dlqQueryByKey: "按 Key 查詢",
viewDlqMessages: "檢視死信消息",
namesrvAddr: "NameServer",
},
mqDlq: {
title: "死信隊列",
refresh: "重新整理",
refreshing: "重新整理中...",
loading: "載入中...",
selectContext: "請先選擇命名空間上下文",
selectTopic: "請選擇 DLQ Topic",
topic: "DLQ Topic",
consumerGroup: "消費組",
groupPlaceholder: "Peek 消費組(可選)",
queryByKey: "按 Key 查詢",
queryKey: "消息 Key",
queryKeyPlaceholder: "要搜尋的 Key",
loadMessages: "載入消息",
loadingMessages: "消息載入中...",
noDlqTopics: "未找到 DLQ Topic。可在主題頁啟用「死信」類型篩選後重新整理。",
noMessages: "暫無消息",
},
mqTrace: {
title: "消息軌跡",
msgId: "消息 ID",
msgIdPlaceholder: "原始消息 MsgId",
msgIdRequired: "消息 ID 不能為空",
queryKeyRequired: "Message Key 不能為空",
selectTopicBeforeQuery: "請先選擇 Topic 再查詢軌跡",
traceTopic: "軌跡 Topic",
traceTopicPlaceholder: "RMQ_SYS_TRACE_TOPIC",
query: "查詢軌跡",
querying: "查詢中...",
noTrace: "未找到軌跡記錄",
searchHint: "請先搜尋消息,再點擊「檢視消費軌跡」",
viewTrace: "檢視消費軌跡",
detailTitle: "消費軌跡詳情",
traceTopicRouteMissing: "軌跡 Topic 在 NameServer 上沒有路由資訊(CODE: 17)。\n\n常見原因:\n1. Broker 未開啟消息軌跡:在 broker.conf 中設置 traceTopicEnable=true 並重新啟動 Broker\n2. Producer/Consumer 未開啟軌跡:setEnableTrace(true)\n3. 使用了自定義軌跡 Topic,請在上方選擇正確的軌跡 Topic",
},
});
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS, buildRocketMqTraceTopicOptions, isProtectedRocketMqTopic, matchesRocketMqTypeFilters, resolveRocketMqMessageType } from "@/lib/mq/rocketmqTopicTypes";
import type { TopicInfo } from "@/types/mq";
function topic(partial: Partial<TopicInfo>): TopicInfo {
return {
name: partial.name ?? partial.shortName ?? "demo",
shortName: partial.shortName ?? partial.name ?? "demo",
partitioned: partial.partitioned ?? false,
persistent: partial.persistent ?? true,
...partial,
};
}
describe("rocketmqTopicTypes", () => {
it("resolves dashboard-style message types", () => {
expect(resolveRocketMqMessageType(topic({ messageType: "NORMAL" }))).toBe("NORMAL");
expect(resolveRocketMqMessageType(topic({ messageType: "DELAY" }))).toBe("DELAY");
expect(resolveRocketMqMessageType(topic({ messageType: "ORDER" }))).toBe("FIFO");
expect(resolveRocketMqMessageType(topic({ messageType: "RETRY" }))).toBe("RETRY");
expect(resolveRocketMqMessageType(topic({ internal: true }))).toBe("SYSTEM");
expect(resolveRocketMqMessageType(topic({}))).toBe("UNSPECIFIED");
});
it("filters topics by selected message types", () => {
const filters = { ...DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS };
expect(matchesRocketMqTypeFilters(topic({ messageType: "NORMAL" }), filters)).toBe(true);
expect(matchesRocketMqTypeFilters(topic({ messageType: "SYSTEM" }), filters)).toBe(false);
filters.SYSTEM = true;
expect(matchesRocketMqTypeFilters(topic({ messageType: "SYSTEM" }), filters)).toBe(true);
});
it("marks retry, dlq and system topics as protected", () => {
expect(isProtectedRocketMqTopic(topic({ messageType: "NORMAL" }))).toBe(false);
expect(isProtectedRocketMqTopic(topic({ messageType: "RETRY" }))).toBe(true);
expect(isProtectedRocketMqTopic(topic({ messageType: "DLQ" }))).toBe(true);
expect(isProtectedRocketMqTopic(topic({ internal: true }))).toBe(true);
});
it("builds trace topic options with default and trace-related topics", () => {
expect(buildRocketMqTraceTopicOptions([])).toEqual(["RMQ_SYS_TRACE_TOPIC"]);
expect(buildRocketMqTraceTopicOptions([topic({ shortName: "RMQ_SYS_TRACE_TOPIC", internal: true }), topic({ shortName: "MyCustomTraceTopic", messageType: "NORMAL" }), topic({ shortName: "orders", messageType: "NORMAL" })])).toEqual(["RMQ_SYS_TRACE_TOPIC", "MyCustomTraceTopic"]);
});
});
+9
View File
@@ -428,7 +428,16 @@ export const mqListPermissions = forward("mqListPermissions");
export const mqIssueToken = forward("mqIssueToken");
export const mqListTokenRecords = forward("mqListTokenRecords");
export const mqGetBacklog = forward("mqGetBacklog");
export const mqGetConsumerGroupConfig = forward("mqGetConsumerGroupConfig");
export const mqAlterConsumerGroupConfig = forward("mqAlterConsumerGroupConfig");
export const mqGetClusterInfo = forward("mqGetClusterInfo");
export const mqGetTopicRoute = forward("mqGetTopicRoute");
export const mqAlterTopicConfig = forward("mqAlterTopicConfig");
export const mqSkipTopicAccumulation = forward("mqSkipTopicAccumulation");
export const mqViewMessage = forward("mqViewMessage");
export const mqQueryMessagesByKey = forward("mqQueryMessagesByKey");
export const mqQueryMessagesByTopic = forward("mqQueryMessagesByTopic");
export const mqQueryMessageTrace = forward("mqQueryMessageTrace");
export const mqRawRequest = forward("mqRawRequest");
export const mqSendMessage = forward("mqSendMessage");
+37
View File
@@ -29,6 +29,7 @@ import type {
MqTokenRecord,
MqIssuedToken,
BacklogStats,
RocketMqConsumerGroupConfig,
PeekedMessage,
PeekMessagesOptions,
MqRawRequest,
@@ -206,10 +207,46 @@ export async function mqGetBacklog(connectionId: string, topic: TopicRef, sub?:
return post("/api/mq/monitoring/backlog", { connectionId, topic, sub });
}
export async function mqGetConsumerGroupConfig(connectionId: string, groupId: string): Promise<RocketMqConsumerGroupConfig> {
return post("/api/mq/consumers/group-config/get", { connectionId, groupId });
}
export async function mqAlterConsumerGroupConfig(connectionId: string, groupId: string, config: Partial<RocketMqConsumerGroupConfig>): Promise<void> {
return post("/api/mq/consumers/group-config/alter", { connectionId, groupId, config });
}
export async function mqGetClusterInfo(connectionId: string): Promise<ClusterInfo> {
return post("/api/mq/monitoring/cluster-info", { connectionId });
}
export async function mqGetTopicRoute(connectionId: string, topic: TopicRef): Promise<unknown> {
return post("/api/mq/topics/route", { connectionId, topic });
}
export async function mqAlterTopicConfig(connectionId: string, topic: TopicRef, configs: unknown): Promise<void> {
return post("/api/mq/topics/alter-config", { connectionId, topic, configs });
}
export async function mqSkipTopicAccumulation(connectionId: string, topic: TopicRef): Promise<unknown> {
return post("/api/mq/topics/skip-accumulation", { connectionId, topic });
}
export async function mqViewMessage(connectionId: string, topic: TopicRef, msgId: string): Promise<unknown> {
return post("/api/mq/messages/view", { connectionId, topic, msgId });
}
export async function mqQueryMessagesByKey(connectionId: string, topic: TopicRef, key: string, begin: number, end: number, maxNum: number): Promise<unknown> {
return post("/api/mq/messages/query-by-key", { connectionId, topic, key, begin, end, maxNum });
}
export async function mqQueryMessagesByTopic(connectionId: string, topic: TopicRef, begin: number, end: number, maxNum: number): Promise<unknown> {
return post("/api/mq/messages/query-by-topic", { connectionId, topic, begin, end, maxNum });
}
export async function mqQueryMessageTrace(connectionId: string, msgId: string, traceTopic?: string): Promise<unknown> {
return post("/api/mq/messages/trace", { connectionId, msgId, traceTopic });
}
export async function mqRawRequest(connectionId: string, req: MqRawRequest): Promise<MqRawResponse> {
return post("/api/mq/raw", { connectionId, req });
}
+37
View File
@@ -29,6 +29,7 @@ import type {
MqTokenRecord,
MqIssuedToken,
BacklogStats,
RocketMqConsumerGroupConfig,
PeekedMessage,
PeekMessagesOptions,
MqRawRequest,
@@ -202,10 +203,46 @@ export async function mqGetBacklog(connectionId: string, topic: TopicRef, sub?:
return invoke("mq_get_backlog", { connectionId, topic, sub });
}
export async function mqGetConsumerGroupConfig(connectionId: string, groupId: string): Promise<RocketMqConsumerGroupConfig> {
return invoke("mq_get_consumer_group_config", { connectionId, groupId });
}
export async function mqAlterConsumerGroupConfig(connectionId: string, groupId: string, config: Partial<RocketMqConsumerGroupConfig>): Promise<void> {
return invoke("mq_alter_consumer_group_config", { connectionId, groupId, config });
}
export async function mqGetClusterInfo(connectionId: string): Promise<ClusterInfo> {
return invoke("mq_get_cluster_info", { connectionId });
}
export async function mqGetTopicRoute(connectionId: string, topic: TopicRef): Promise<unknown> {
return invoke("mq_get_topic_route", { connectionId, topic });
}
export async function mqAlterTopicConfig(connectionId: string, topic: TopicRef, configs: unknown): Promise<void> {
return invoke("mq_alter_topic_config", { connectionId, topic, configs });
}
export async function mqSkipTopicAccumulation(connectionId: string, topic: TopicRef): Promise<unknown> {
return invoke("mq_skip_topic_accumulation", { connectionId, topic });
}
export async function mqViewMessage(connectionId: string, topic: TopicRef, msgId: string): Promise<unknown> {
return invoke("mq_view_message", { connectionId, topic, msgId });
}
export async function mqQueryMessagesByKey(connectionId: string, topic: TopicRef, key: string, begin: number, end: number, maxNum: number): Promise<unknown> {
return invoke("mq_query_messages_by_key", { connectionId, topic, key, begin, end, maxNum });
}
export async function mqQueryMessagesByTopic(connectionId: string, topic: TopicRef, begin: number, end: number, maxNum: number): Promise<unknown> {
return invoke("mq_query_messages_by_topic", { connectionId, topic, begin, end, maxNum });
}
export async function mqQueryMessageTrace(connectionId: string, msgId: string, traceTopic?: string): Promise<unknown> {
return invoke("mq_query_message_trace", { connectionId, msgId, traceTopic });
}
// Raw request
export async function mqRawRequest(connectionId: string, req: MqRawRequest): Promise<MqRawResponse> {
return invoke("mq_raw_request", { connectionId, req });
@@ -14,6 +14,10 @@ describe("driverStoreFocusForInstallError", () => {
target: "driver",
driver: "kafka",
});
expect(driverStoreFocusForInstallError("rocketmq driver is not installed. Please install it from the Driver Manager.", "mq", "rocketmq")).toEqual({
target: "driver",
driver: "rocketmq",
});
});
it("focuses the JRE section for missing JRE errors", () => {
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem } from "@/lib/connection/mqAuth";
describe("mqAuth", () => {
it("allows basic auth for RocketMQ", () => {
expect(isMqAuthKindAllowedForSystem("rocketmq", "basic")).toBe(true);
expect(isMqAuthKindAllowedForSystem("rocketmq", "kerberos")).toBe(false);
});
it("detects RocketMQ basic auth from config", () => {
expect(
detectMqUiAuthKind({
systemKind: "rocketmq",
authKind: "basic",
saslMechanism: "",
jaasConfig: "",
}),
).toBe("basic");
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { normalizeRocketmqNamesrvAddr } from "@/lib/connection/rocketmqNamesrv";
describe("normalizeRocketmqNamesrvAddr", () => {
it("normalizes a single host:port address", () => {
expect(normalizeRocketmqNamesrvAddr("127.0.0.1:9876")).toBe("127.0.0.1:9876");
});
it("joins multiple addresses separated by commas", () => {
expect(normalizeRocketmqNamesrvAddr("127.0.0.1:9876, 192.168.1.2:9876")).toBe("127.0.0.1:9876;192.168.1.2:9876");
});
it("rejects empty input", () => {
expect(() => normalizeRocketmqNamesrvAddr(" ")).toThrow(/required/i);
});
it("rejects URL schemes", () => {
expect(() => normalizeRocketmqNamesrvAddr("http://127.0.0.1:9876")).toThrow(/without a URL scheme/i);
});
});
@@ -12,7 +12,11 @@ export function agentDriverInstallKey(dbType: DatabaseType | undefined, driverPr
if (dbType === "mongodb") return "mongodb";
if (dbType === "dameng") return "dameng";
if (dbType === "gbase") return driverProfile === "gbase8s" ? "gbase8s" : "gbase8a";
if (dbType === "mq") return driverProfile === "kafka" ? "kafka" : undefined;
if (dbType === "mq") {
if (driverProfile === "kafka") return "kafka";
if (driverProfile === "rocketmq") return "rocketmq";
return undefined;
}
return driverProfile && driverProfile !== dbType ? driverProfile : dbType;
}
@@ -4,9 +4,11 @@ export type MqAuthKind = MqAuth["kind"];
export type MqUiAuthKind = MqAuthKind | "kerberos";
const KAFKA_AUTH_KINDS = new Set<MqUiAuthKind>(["none", "basic", "kerberos"]);
const ROCKETMQ_AUTH_KINDS = new Set<MqUiAuthKind>(["none", "basic"]);
export function isMqAuthKindAllowedForSystem(systemKind: MqSystemKind, authKind: MqUiAuthKind): boolean {
if (systemKind === "kafka") return KAFKA_AUTH_KINDS.has(authKind);
if (systemKind === "rocketmq") return ROCKETMQ_AUTH_KINDS.has(authKind);
return authKind !== "kerberos";
}
@@ -17,6 +19,9 @@ export function detectMqUiAuthKind({ systemKind, authKind, saslMechanism, jaasCo
}
return authKind === "basic" ? "basic" : "none";
}
if (systemKind === "rocketmq") {
return authKind === "basic" ? "basic" : "none";
}
return authKind || "none";
}
@@ -0,0 +1,33 @@
const ROCKETMQ_NAMESRV_SEPARATOR = /[\s,;,;]+/u;
function requireRocketmqNamesrv(value: string): string {
const trimmed = value.trim();
if (!trimmed) throw new Error("RocketMQ NameServer address is required");
return trimmed;
}
function normalizeRocketmqNamesrvServer(server: string): string {
if (server.includes("://")) {
throw new Error("RocketMQ NameServer addresses must be host:port values without a URL scheme");
}
let parsed: URL;
try {
parsed = new URL(`rocketmq://${server}`);
} catch {
throw new Error("RocketMQ NameServer addresses are invalid");
}
if (!parsed.hostname || parsed.username || parsed.password || parsed.search || parsed.hash || (parsed.pathname && parsed.pathname !== "/")) {
throw new Error("RocketMQ NameServer addresses are invalid");
}
return server;
}
export function normalizeRocketmqNamesrvAddr(value: string): string {
const servers = requireRocketmqNamesrv(value)
.split(ROCKETMQ_NAMESRV_SEPARATOR)
.map((server) => server.trim())
.filter(Boolean)
.map(normalizeRocketmqNamesrvServer);
if (!servers.length) throw new Error("RocketMQ NameServer address is required");
return servers.join(";");
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import type { ConnectionConfig } from "@/types/database";
import { defaultMqCapabilitiesForSystemKind, normalizeMqTabForSystemKind, resolveAvailableMqTabs, resolveInitialMqTab, resolveMqSystemKindFromConnection } from "@/lib/mq/mqConsoleDefaults";
describe("mqConsoleDefaults", () => {
it("resolves RocketMQ from driver profile before cluster info loads", () => {
const config = {
id: "mq-1",
db_type: "mq",
driver_profile: "rocketmq",
external_config: { systemKind: "rocketmq", adminUrl: "", auth: { kind: "none" } },
} as ConnectionConfig;
expect(resolveMqSystemKindFromConnection(config)).toBe("rocketmq");
expect(defaultMqCapabilitiesForSystemKind("rocketmq").supportsTenants).toBe(false);
expect(defaultMqCapabilitiesForSystemKind("rocketmq").supportsNamespaces).toBe(false);
});
it("opens flat MQ consoles on the topics tab", () => {
expect(resolveInitialMqTab({ systemKind: "rocketmq", initialTenant: "_flat_mq" })).toBe("topics");
expect(resolveInitialMqTab({ systemKind: "pulsar" })).toBe("tenants");
expect(resolveInitialMqTab({ systemKind: "pulsar", initialTenant: "public" })).toBe("namespaces");
});
it("falls back dlq tab to messages for RocketMQ", () => {
expect(normalizeMqTabForSystemKind("dlq", "rocketmq")).toBe("messages");
expect(normalizeMqTabForSystemKind("trace", "rocketmq")).toBe("trace");
expect(normalizeMqTabForSystemKind("trace", "pulsar")).toBe("trace");
expect(resolveInitialMqTab({ systemKind: "rocketmq", initialTab: "dlq" })).toBe("messages");
expect(resolveInitialMqTab({ systemKind: "rocketmq", initialTab: "trace" })).toBe("trace");
});
it("exposes RocketMQ tabs including trace but not dlq", () => {
const caps = defaultMqCapabilitiesForSystemKind("rocketmq");
expect(resolveAvailableMqTabs({ systemKind: "rocketmq", capabilities: caps })).toEqual(["broker", "topics", "subscriptions", "producers", "messages", "trace", "permissions"]);
});
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { matchesRocketMqConsumerGroupTypeFilters, resolveRocketMqConsumerGroupMessageModel, resolveRocketMqConsumerGroupType } from "@/lib/mq/rocketmqConsumerGroupTypes";
describe("rocketmqConsumerGroupTypes", () => {
it("resolves group type from consumerGroupType or subType", () => {
expect(resolveRocketMqConsumerGroupType({ consumerGroupType: "FIFO", subType: "NORMAL" })).toBe("FIFO");
expect(resolveRocketMqConsumerGroupType({ subType: "SYSTEM" })).toBe("SYSTEM");
expect(resolveRocketMqConsumerGroupType({ subType: "CONSUME_PASSIVELY ? CLUSTERING" })).toBe("NORMAL");
});
it("filters by dashboard group types", () => {
const filters = { NORMAL: true, FIFO: true, SYSTEM: false };
expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "NORMAL", subType: "NORMAL" }, filters)).toBe(true);
expect(matchesRocketMqConsumerGroupTypeFilters({ consumerGroupType: "SYSTEM", subType: "SYSTEM" }, filters)).toBe(false);
});
it("resolves message model", () => {
expect(resolveRocketMqConsumerGroupMessageModel({ messageModel: "BROADCASTING" })).toBe("BROADCASTING");
expect(resolveRocketMqConsumerGroupMessageModel({})).toBe("CLUSTERING");
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import type { PeekedMessage } from "@/types/mq";
import { rocketMqDisplayFromPeeked } from "@/lib/mq/rocketmqMessageUtils";
describe("rocketMqDisplayFromPeeked", () => {
it("maps RocketMQ peek fields including msgId, tag and queue offset", () => {
const peeked: PeekedMessage = {
position: 1,
messageId: "0BC16699165C03B925DB8A404E2D****",
key: "order-1",
publishTime: "1710000000000",
properties: {
partition: "2",
offset: "15",
tag: "cs-pt-dlq-test",
},
headers: {},
payloadBase64: "",
payloadText: "dlq message",
};
expect(rocketMqDisplayFromPeeked(peeked, "%DLQ%cs-pt-test-group")).toEqual({
messageId: "0BC16699165C03B925DB8A404E2D****",
partition: 2,
offset: 15,
key: "order-1",
tag: "cs-pt-dlq-test",
timestamp: 1710000000000,
payloadText: "dlq message",
payloadBase64: "",
headers: {},
topic: "%DLQ%cs-pt-test-group",
});
});
it("falls back to TAGS header when tag property is missing", () => {
const peeked: PeekedMessage = {
position: 1,
messageId: "abc",
properties: {},
headers: { TAGS: "fallback-tag" },
payloadBase64: "",
};
expect(rocketMqDisplayFromPeeked(peeked).tag).toBe("fallback-tag");
});
});
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { formatRocketMqTraceError, isRocketMqTraceTopicRouteMissingError } from "@/lib/mq/rocketmqTraceUtils";
describe("rocketmqTraceUtils", () => {
it("detects RocketMQ trace topic route missing errors", () => {
expect(isRocketMqTraceTopicRouteMissingError(new Error("Agent RPC error (-1): CODE: 17 DESC: The topic[RMQ_SYS_TRACE_TOPIC] not matched route info"))).toBe(true);
expect(isRocketMqTraceTopicRouteMissingError(new Error("CODE: 208 DESC: no matched message"))).toBe(false);
});
it("returns a friendly hint for trace topic route missing errors", () => {
const hint = "Enable traceTopicEnable on broker";
expect(formatRocketMqTraceError(new Error("CODE: 17 DESC: No topic route info in name server for the topic: RMQ_SYS_TRACE_TOPIC"), hint)).toBe(hint);
});
});
@@ -0,0 +1,130 @@
import type { ConnectionConfig } from "@/types/database";
import type { MqAdminConfig, MqCapabilities, MqSystemKind } from "@/types/mq";
const FLAT_MQ_BASE_CAPABILITIES: MqCapabilities = {
supportsTenants: false,
supportsNamespaces: false,
supportsPartitionedTopics: true,
supportsSubscriptions: true,
supportsCreateSubscription: false,
supportsResetCursor: true,
supportsSkipMessages: false,
supportsClearBacklog: true,
supportsPeekMessages: true,
supportsExpireMessages: false,
supportsRateLimits: false,
supportsBacklogQuota: false,
supportsRetention: false,
supportsPermissions: true,
supportsGeoReplication: false,
supportsTokenManagement: false,
supportsRawAdminApi: false,
supportsSendMessage: true,
supportsMessageQuery: false,
supportsDlq: false,
supportsMessageTrace: false,
};
const PULSAR_DEFAULT_CAPABILITIES: MqCapabilities = {
supportsTenants: true,
supportsNamespaces: true,
supportsPartitionedTopics: true,
supportsSubscriptions: true,
supportsCreateSubscription: true,
supportsResetCursor: true,
supportsSkipMessages: true,
supportsClearBacklog: true,
supportsPeekMessages: true,
supportsExpireMessages: true,
supportsRateLimits: true,
supportsBacklogQuota: true,
supportsRetention: true,
supportsPermissions: true,
supportsGeoReplication: true,
supportsTokenManagement: true,
supportsRawAdminApi: true,
supportsSendMessage: false,
supportsMessageQuery: false,
supportsDlq: false,
supportsMessageTrace: false,
};
export function resolveMqSystemKindFromConnection(config: ConnectionConfig | undefined): MqSystemKind | undefined {
if (!config || config.db_type !== "mq") return undefined;
const external = config.external_config as Partial<MqAdminConfig> | undefined;
if (external?.systemKind === "kafka" || external?.systemKind === "rocketmq" || external?.systemKind === "pulsar") {
return external.systemKind;
}
if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq" || config.driver_profile === "pulsar") {
return config.driver_profile;
}
return "pulsar";
}
export function isFlatMqSystemKind(kind: MqSystemKind | undefined): boolean {
return kind === "kafka" || kind === "rocketmq";
}
export function defaultMqCapabilitiesForSystemKind(kind: MqSystemKind | undefined): MqCapabilities {
if (kind === "kafka") {
return {
...FLAT_MQ_BASE_CAPABILITIES,
supportsRetention: true,
supportsRateLimits: false,
};
}
if (kind === "rocketmq") {
return {
...FLAT_MQ_BASE_CAPABILITIES,
supportsMessageQuery: true,
supportsDlq: true,
supportsMessageTrace: true,
};
}
return { ...PULSAR_DEFAULT_CAPABILITIES };
}
export type MqTab = "tenants" | "namespaces" | "topics" | "subscriptions" | "monitoring" | "clients" | "producers" | "policies" | "permissions" | "messages" | "raw" | "broker" | "dlq" | "trace";
export function resolveAvailableMqTabs(options: { systemKind?: MqSystemKind; capabilities: MqCapabilities }): MqTab[] {
const { systemKind, capabilities } = options;
if (systemKind === "rocketmq") {
const tabs: MqTab[] = ["broker", "topics", "subscriptions", "producers"];
if (capabilities.supportsMessageQuery || capabilities.supportsSendMessage) tabs.push("messages");
if (capabilities.supportsMessageTrace) tabs.push("trace");
if (capabilities.supportsPermissions) tabs.push("permissions");
return tabs;
}
const tabs: MqTab[] = [];
if (capabilities.supportsTenants) tabs.push("tenants");
if (capabilities.supportsNamespaces) tabs.push("namespaces");
tabs.push("topics");
if (capabilities.supportsSubscriptions) tabs.push("subscriptions");
tabs.push("monitoring");
tabs.push("clients");
if (capabilities.supportsSendMessage) tabs.push("messages");
tabs.push("broker");
if (capabilities.supportsRateLimits || capabilities.supportsBacklogQuota || capabilities.supportsRetention) {
tabs.push("policies");
}
if (capabilities.supportsPermissions) tabs.push("permissions");
if (capabilities.supportsRawAdminApi) tabs.push("raw");
return tabs;
}
export function normalizeMqTabForSystemKind(tab: MqTab, systemKind?: MqSystemKind): MqTab {
if (systemKind === "rocketmq" && tab === "dlq") {
return "messages";
}
return tab;
}
export function resolveInitialMqTab(options: { initialTab?: MqTab; initialTenant?: string; systemKind?: MqSystemKind }): MqTab {
if (options.initialTab) {
return normalizeMqTabForSystemKind(options.initialTab, options.systemKind);
}
if (isFlatMqSystemKind(options.systemKind)) return "topics";
if (options.initialTenant) return "namespaces";
return "tenants";
}
@@ -0,0 +1,27 @@
import type { SubscriptionInfo } from "@/types/mq";
export type RocketMqConsumerGroupType = "NORMAL" | "FIFO" | "SYSTEM";
export const ROCKETMQ_CONSUMER_GROUP_TYPES: readonly RocketMqConsumerGroupType[] = ["NORMAL", "FIFO", "SYSTEM"];
export const DEFAULT_ROCKETMQ_CONSUMER_GROUP_TYPE_FILTERS: Record<RocketMqConsumerGroupType, boolean> = {
NORMAL: true,
FIFO: true,
SYSTEM: false,
};
export function resolveRocketMqConsumerGroupType(sub: Pick<SubscriptionInfo, "consumerGroupType" | "subType">): RocketMqConsumerGroupType {
const raw = (sub.consumerGroupType ?? sub.subType)?.trim().toUpperCase();
if (raw === "FIFO" || raw === "ORDER" || raw === "ORDERLY") return "FIFO";
if (raw === "SYSTEM") return "SYSTEM";
if (raw === "NORMAL") return "NORMAL";
return "NORMAL";
}
export function matchesRocketMqConsumerGroupTypeFilters(sub: Pick<SubscriptionInfo, "consumerGroupType" | "subType">, filters: Record<RocketMqConsumerGroupType, boolean>): boolean {
return filters[resolveRocketMqConsumerGroupType(sub)] === true;
}
export function resolveRocketMqConsumerGroupMessageModel(sub: Pick<SubscriptionInfo, "messageModel">): "CLUSTERING" | "BROADCASTING" {
return sub.messageModel?.trim().toUpperCase() === "BROADCASTING" ? "BROADCASTING" : "CLUSTERING";
}
@@ -0,0 +1,137 @@
import type { PeekedMessage } from "@/types/mq";
function peekedPropertyNumber(properties: Record<string, string>, key: string): number | undefined {
const raw = properties[key]?.trim();
if (!raw) return undefined;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : undefined;
}
export interface RocketMqDisplayMessage {
messageId?: string;
partition?: number;
offset?: number;
key?: string;
tag?: string;
timestamp?: number;
payloadText?: string;
payloadBase64?: string;
headers?: Record<string, string>;
topic?: string;
}
function objectRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function stringField(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function numberField(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function parseMessageRow(value: unknown): RocketMqDisplayMessage | undefined {
const body = objectRecord(value);
if (!Object.keys(body).length) return undefined;
const headers = objectRecord(body.headers);
const headerMap: Record<string, string> = {};
for (const [key, val] of Object.entries(headers)) {
headerMap[key] = typeof val === "string" ? val : String(val ?? "");
}
return {
topic: stringField(body.topic),
messageId: stringField(body.messageId) ?? stringField(body.msgId),
partition: numberField(body.partition) ?? numberField(body.queueId),
offset: numberField(body.offset) ?? numberField(body.queueOffset),
key: stringField(body.key) ?? stringField(body.keys),
tag: stringField(body.tag) ?? stringField(body.tags),
timestamp: numberField(body.timestamp) ?? numberField(body.storeTimestamp),
payloadText: stringField(body.payloadText),
payloadBase64: stringField(body.payloadBase64),
headers: headerMap,
};
}
/** Parse messages from mqViewMessage / mqQueryMessagesByKey / mqQueryMessagesByTopic / mqQueryMessageTrace responses. */
export function parseRocketMqMessagesFromResult(result: unknown): RocketMqDisplayMessage[] {
const root = objectRecord(result);
const single = root.message ?? root.msg;
if (single) {
const parsed = parseMessageRow(single);
return parsed ? [parsed] : [];
}
const list = root.messages;
if (!Array.isArray(list)) return [];
return list.map(parseMessageRow).filter((item): item is RocketMqDisplayMessage => !!item);
}
export function rocketMqMessagePayload(message: RocketMqDisplayMessage): string {
if (message.payloadText) return message.payloadText;
if (message.payloadBase64) {
try {
return decodeURIComponent(escape(atob(message.payloadBase64)));
} catch {
return message.payloadBase64;
}
}
return "";
}
export function formatRocketMqMessagePayload(text: string): string {
const trimmed = text.trim();
if (!trimmed) return text;
try {
return JSON.stringify(JSON.parse(trimmed), null, 2);
} catch {
return text;
}
}
export function rocketMqDisplayFromPeeked(msg: PeekedMessage, topic?: string): RocketMqDisplayMessage {
const tagFromProps = msg.properties?.tag?.trim();
const tagFromHeaders = msg.headers?.TAGS?.trim();
return {
messageId: msg.messageId,
partition: peekedPropertyNumber(msg.properties, "partition"),
offset: peekedPropertyNumber(msg.properties, "offset"),
key: msg.key,
tag: tagFromProps || tagFromHeaders || undefined,
timestamp: msg.publishTime ? Number(msg.publishTime) : undefined,
payloadText: msg.payloadText,
payloadBase64: msg.payloadBase64,
headers: msg.headers,
topic,
};
}
export function rocketMqMessageToPeeked(message: RocketMqDisplayMessage, position: number): PeekedMessage {
const properties: Record<string, string> = {};
if (message.partition != null) properties.partition = String(message.partition);
if (message.offset != null) properties.offset = String(message.offset);
if (message.tag) properties.tag = message.tag;
return {
position,
messageId: message.messageId ?? (message.offset != null ? String(message.offset) : undefined),
key: message.key,
publishTime: message.timestamp != null ? String(message.timestamp) : undefined,
eventTime: undefined,
properties,
headers: message.headers ?? {},
payloadBase64: message.payloadBase64 ?? "",
payloadText: message.payloadText,
};
}
export function formatRocketMqTimestamp(value?: string | number): string {
if (value == null || value === "") return "-";
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric)) return String(value);
return new Date(numeric).toLocaleString();
}
@@ -0,0 +1,59 @@
import type { RocketMqTopicMessageType, TopicInfo } from "@/types/mq";
export const ROCKETMQ_TOPIC_MESSAGE_TYPES: readonly RocketMqTopicMessageType[] = ["NORMAL", "DELAY", "FIFO", "TRANSACTION", "UNSPECIFIED", "RETRY", "DLQ", "SYSTEM"];
export const ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES: readonly RocketMqTopicMessageType[] = ["NORMAL", "DELAY", "FIFO", "TRANSACTION"];
export const DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS: Record<RocketMqTopicMessageType, boolean> = {
NORMAL: true,
DELAY: true,
FIFO: true,
TRANSACTION: true,
UNSPECIFIED: true,
RETRY: false,
DLQ: false,
SYSTEM: false,
};
export function resolveRocketMqMessageType(topic: Pick<TopicInfo, "messageType" | "internal">): RocketMqTopicMessageType {
const raw = topic.messageType?.trim().toUpperCase();
if (raw === "ORDER") return "FIFO";
if (raw && ROCKETMQ_TOPIC_MESSAGE_TYPES.includes(raw as RocketMqTopicMessageType)) {
return raw as RocketMqTopicMessageType;
}
if (topic.internal) return "SYSTEM";
return "UNSPECIFIED";
}
export function isRocketMqBusinessMessageType(type: RocketMqTopicMessageType): boolean {
return type !== "SYSTEM" && type !== "RETRY" && type !== "DLQ";
}
export function isProtectedRocketMqTopic(topic: Pick<TopicInfo, "messageType" | "internal">): boolean {
return !isRocketMqBusinessMessageType(resolveRocketMqMessageType(topic));
}
/** Default RocketMQ system trace topic; most clusters use this unless trace storage is customized. */
export const DEFAULT_ROCKETMQ_TRACE_TOPIC = "RMQ_SYS_TRACE_TOPIC";
/** Build selectable trace topic names from cluster metadata. */
export function buildRocketMqTraceTopicOptions(topics: Pick<TopicInfo, "shortName" | "messageType" | "internal">[]): string[] {
const options = new Set<string>([DEFAULT_ROCKETMQ_TRACE_TOPIC]);
for (const topic of topics) {
const name = topic.shortName.trim();
if (!name) continue;
const type = resolveRocketMqMessageType(topic);
if (type === "SYSTEM" || /trace/i.test(name)) {
options.add(name);
}
}
return [...options].sort((left, right) => {
if (left === DEFAULT_ROCKETMQ_TRACE_TOPIC) return -1;
if (right === DEFAULT_ROCKETMQ_TRACE_TOPIC) return 1;
return left.localeCompare(right);
});
}
export function matchesRocketMqTypeFilters(topic: Pick<TopicInfo, "messageType" | "internal">, filters: Record<RocketMqTopicMessageType, boolean>): boolean {
return filters[resolveRocketMqMessageType(topic)] === true;
}
@@ -0,0 +1,14 @@
import { formatError } from "@/lib/backend/errorUtils";
/** RocketMQ CODE 17: trace topic has no route on NameServer (trace not enabled or wrong topic). */
export function isRocketMqTraceTopicRouteMissingError(error: unknown): boolean {
const message = formatError(error);
return /CODE:\s*17/i.test(message) && /not matched route info|No topic route info/i.test(message);
}
export function formatRocketMqTraceError(error: unknown, traceTopicMissingHint: string): string {
if (isRocketMqTraceTopicRouteMissingError(error)) {
return traceTopicMissingHint;
}
return formatError(error);
}
@@ -134,7 +134,50 @@ describe("connectionStore MQ sidebar tree", () => {
await store.refreshTreeNode(node);
expect(node.children?.map((child) => ({ label: child.label, tenant: child.mqTenant, initialTab: child.mqInitialTab }))).toEqual([{ label: "Topics", tenant: "_kafka", initialTab: "topics" }]);
expect(node.children?.map((child) => ({ label: child.label, tenant: child.mqTenant, initialTab: child.mqInitialTab }))).toEqual([{ label: "Topics", tenant: "_flat_mq", initialTab: "topics" }]);
});
it("adds a RocketMQ topics child with a topics initial tab", async () => {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listDatabases: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
mqListTenants: vi.fn(),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = {
...kafkaConnection(),
name: "Apache RocketMQ",
driver_profile: "rocketmq",
driver_label: "Apache RocketMQ",
external_config: {
systemKind: "rocketmq",
adminUrl: "",
auth: { kind: "none" },
extra: { namesrvAddr: "127.0.0.1:9876" },
},
} as ConnectionConfig;
const node: TreeNode = {
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: false,
children: [],
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [node];
await store.refreshTreeNode(node);
expect(node.children?.map((child) => ({ label: child.label, tenant: child.mqTenant, initialTab: child.mqInitialTab }))).toEqual([{ label: "Topics", tenant: "_flat_mq", initialTab: "topics" }]);
});
it("detects Kafka from external config when driver profile is missing", async () => {
@@ -168,7 +211,7 @@ describe("connectionStore MQ sidebar tree", () => {
await store.refreshTreeNode(node);
expect(mqListTenants).not.toHaveBeenCalled();
expect(node.children?.map((child) => ({ label: child.label, tenant: child.mqTenant, initialTab: child.mqInitialTab }))).toEqual([{ label: "Topics", tenant: "_kafka", initialTab: "topics" }]);
expect(node.children?.map((child) => ({ label: child.label, tenant: child.mqTenant, initialTab: child.mqInitialTab }))).toEqual([{ label: "Topics", tenant: "_flat_mq", initialTab: "topics" }]);
});
it("reuses an in-flight connection attempt instead of recording stale superseded errors", async () => {
+8 -7
View File
@@ -116,10 +116,11 @@ function sidebarObjectGroupPageSize(): number {
return typeof size === "number" && size > 0 ? size : 500;
}
function isKafkaMqConnection(config: ConnectionConfig | undefined): boolean {
function isFlatMqConnection(config: ConnectionConfig | undefined): boolean {
if (!config || config.db_type !== "mq") return false;
if (config.driver_profile === "kafka") return true;
return (config.external_config as Partial<MqAdminConfig> | undefined)?.systemKind === "kafka";
if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq") return true;
const kind = (config.external_config as Partial<MqAdminConfig> | undefined)?.systemKind;
return kind === "kafka" || kind === "rocketmq";
}
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip";
@@ -2538,16 +2539,16 @@ export const useConnectionStore = defineStore("connection", () => {
if (useCachedChildren(node, options)) return;
const config = getConfig(connectionId);
if (isKafkaMqConnection(config)) {
// Kafka has no tenant/namespace concept. Create a synthetic child
if (isFlatMqConnection(config)) {
// Kafka/RocketMQ have no tenant/namespace concept. Create a synthetic child
// that opens the MQ admin console directly when clicked.
setChildren(node, [
{
id: schemaCacheKey(connectionId, "mq-tenant", "_kafka"),
id: schemaCacheKey(connectionId, "mq-tenant", "_flat_mq"),
label: "Topics",
type: "mq-tenant" as const,
connectionId,
mqTenant: "_kafka",
mqTenant: "_flat_mq",
mqInitialTab: "topics",
},
]);
+35
View File
@@ -21,6 +21,9 @@ export interface MqCapabilities {
supportsTokenManagement: boolean;
supportsRawAdminApi: boolean;
supportsSendMessage?: boolean;
supportsMessageQuery?: boolean;
supportsDlq?: boolean;
supportsMessageTrace?: boolean;
}
export interface MqClusterInfo {
@@ -125,14 +128,24 @@ export interface TopicRef {
topic: string;
persistent: boolean;
partitioned?: boolean;
/** RocketMQ create hint: NORMAL / DELAY / FIFO / TRANSACTION */
messageType?: string;
brokerName?: string;
readQueueNums?: number;
writeQueueNums?: number;
perm?: number;
}
export type RocketMqTopicMessageType = "NORMAL" | "DELAY" | "FIFO" | "TRANSACTION" | "UNSPECIFIED" | "RETRY" | "DLQ" | "SYSTEM";
export interface TopicInfo {
name: string;
shortName: string;
partitioned: boolean;
partitions?: number;
persistent: boolean;
internal?: boolean;
messageType?: RocketMqTopicMessageType | string;
}
export interface ListTopicsOpts {
@@ -161,6 +174,26 @@ export interface SubscriptionInfo {
msgRateOut: number;
msgThroughputOut: number;
consumers: ConsumerInfo[];
/** RocketMQ subscribed topics (cluster-wide listing). */
topics?: string[];
/** RocketMQ online consumer client count. */
onlineMembers?: number;
/** RocketMQ consumer group delivery type: NORMAL / FIFO / SYSTEM. */
consumerGroupType?: string;
/** RocketMQ consumer group message model: CLUSTERING / BROADCASTING. */
messageModel?: string;
}
export interface RocketMqConsumerGroupConfig {
groupName: string;
consumeEnable: boolean;
consumeFromMinEnable?: boolean;
consumeBroadcastEnable: boolean;
consumeMessageOrderly?: boolean;
retryQueueNums: number;
retryMaxTimes?: number;
brokerId: number;
whichBrokerWhenConsumeSlowly?: number;
}
export interface ConsumerInfo {
@@ -204,6 +237,8 @@ export interface BrokerNode {
host: string;
port: number;
rack?: string;
brokerName?: string;
role?: string;
}
export interface PeekedMessage {