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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user