feat(rocketmq): add agent and management console
This commit is contained in:
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
});
|
||||
@@ -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.",
|
||||
},
|
||||
});
|
||||
@@ -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 を選択してください",
|
||||
},
|
||||
});
|
||||
@@ -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.",
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user