feat(kafka): support Kafka MQ admin client

This commit is contained in:
miracle authored and GitHub committed 2026-06-30 17:01:24 +08:00
1 parent 3e7cf30ce5
commit 7cec4892bf
43 files changed
+4281 -186

No files matched your search

@@ -321,8 +321,12 @@ const dbPickerView = ref<DbPickerView>("icon");
const dbSearchQuery = ref("");
const configTab = ref<ConfigTab>("connection");
type MqAuthKind = MqAuth["kind"];
const MQ_KAFKA_SECURITY_PROTOCOL_AUTO = "__auto";
const mqAdminUrl = ref("http://127.0.0.1:8080");
const mqSystemKind = ref<MqSystemKind>("pulsar");
const mqKafkaBootstrapServers = ref("127.0.0.1:9092");
const mqKafkaSecurityProtocol = ref(MQ_KAFKA_SECURITY_PROTOCOL_AUTO);
const mqKafkaSaslMechanism = ref("PLAIN");
const mqAuthKind = ref<MqAuthKind>("none");
const mqToken = ref("");
const mqBasicUsername = ref("");
@@ -338,6 +342,22 @@ const mqTlsSkipVerify = ref(false);
const mqPinnedVersion = ref(pinnedVersionToSelection(undefined));
const mqTokenSigningMode = ref<MqTokenSigningMode>("none");
const mqTokenSigningKey = ref("");
const mqSystemOptions: Array<{ value: MqSystemKind; label: string }> = [
{ value: "pulsar", label: "Apache Pulsar" },
{ value: "kafka", label: "Apache Kafka" },
];
const mqKafkaSecurityProtocolOptions = [
{ value: MQ_KAFKA_SECURITY_PROTOCOL_AUTO, label: "Auto" },
{ value: "PLAINTEXT", label: "PLAINTEXT" },
{ value: "SSL", label: "SSL" },
{ value: "SASL_PLAINTEXT", label: "SASL_PLAINTEXT" },
{ value: "SASL_SSL", label: "SASL_SSL" },
];
const mqKafkaSaslMechanismOptions = [
{ value: "PLAIN", label: "PLAIN" },
{ value: "SCRAM-SHA-256", label: "SCRAM-SHA-256" },
{ value: "SCRAM-SHA-512", label: "SCRAM-SHA-512" },
];
const nacosServerAddr = ref(NACOS_DEFAULT_CONSOLE_URL);
const nacosNamespace = ref("");
const nacosContextPath = ref("");
@@ -552,6 +572,7 @@ const driverProfiles: Record<
etcd: { type: "etcd", port: 2379, user: "", label: "etcd", icon: "etcd" },
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" },
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" },
@@ -579,6 +600,8 @@ 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 === "dameng") return "dm";
if (config.db_type === "oceanbase-oracle") return "oceanbase";
return config.db_type;
@@ -588,13 +611,28 @@ function selectedProfile() {
return driverProfiles[selectedType.value] ?? driverProfiles.mysql;
}
function mqExtraRecord(config?: Partial<MqAdminConfig>): Record<string, unknown> {
const extra = config?.extra;
return extra && typeof extra === "object" && !Array.isArray(extra) ? (extra as Record<string, unknown>) : {};
}
function mqExtraString(extra: Record<string, unknown>, key: string): string {
const value = extra[key];
return typeof value === "string" ? value : "";
}
function resetMqFields(config?: Partial<MqAdminConfig>) {
mqSystemKind.value = "pulsar";
mqAdminUrl.value = config?.adminUrl?.trim() || "http://127.0.0.1:8080";
const systemKind = config?.systemKind === "kafka" ? "kafka" : "pulsar";
const extra = mqExtraRecord(config);
mqSystemKind.value = systemKind;
mqAdminUrl.value = config?.adminUrl?.trim() || (systemKind === "kafka" ? "" : "http://127.0.0.1:8080");
mqKafkaBootstrapServers.value = mqExtraString(extra, "bootstrapServers") || "127.0.0.1:9092";
mqKafkaSecurityProtocol.value = mqExtraString(extra, "securityProtocol") || MQ_KAFKA_SECURITY_PROTOCOL_AUTO;
mqKafkaSaslMechanism.value = mqExtraString(extra, "saslMechanism") || "PLAIN";
mqTlsSkipVerify.value = !!config?.tlsSkipVerify;
mqPinnedVersion.value = pinnedVersionToSelection(config?.pinnedVersion);
const auth = (config?.auth || { kind: "none" }) as MqAuth;
mqAuthKind.value = auth.kind || "none";
mqAuthKind.value = systemKind === "kafka" && auth.kind !== "basic" ? "none" : auth.kind || "none";
mqToken.value = auth.token || "";
mqBasicUsername.value = auth.username || "";
mqBasicPassword.value = auth.password || "";
@@ -610,6 +648,16 @@ function resetMqFields(config?: Partial<MqAdminConfig>) {
mqTokenSigningKey.value = tokenSigning?.key || "";
}
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" },
};
}
function hydrateMqFields(value: unknown) {
if (!value || typeof value !== "object") {
resetMqFields();
@@ -618,6 +666,15 @@ function hydrateMqFields(value: unknown) {
resetMqFields(value as Partial<MqAdminConfig>);
}
watch(mqSystemKind, (kind) => {
if (kind === "kafka") {
if (!mqKafkaBootstrapServers.value.trim()) mqKafkaBootstrapServers.value = "127.0.0.1:9092";
if (!["none", "basic"].includes(mqAuthKind.value)) mqAuthKind.value = "none";
return;
}
if (!mqAdminUrl.value.trim()) mqAdminUrl.value = "http://127.0.0.1:8080";
});
function resetNacosFields(config?: Partial<NacosAdminConfig>) {
nacosServerAddr.value = config?.serverAddr?.trim() || NACOS_DEFAULT_CONSOLE_URL;
nacosNamespace.value = config?.namespace || "";
@@ -644,6 +701,32 @@ function requireMqField(value: string, message: string): string {
return trimmed;
}
function normalizeMqKafkaBootstrapServer(server: string): string {
if (server.includes("://")) {
throw new Error("Kafka bootstrap servers must be host:port values without a URL scheme");
}
let parsed: URL;
try {
parsed = new URL(`kafka://${server}`);
} catch {
throw new Error("Kafka bootstrap servers are invalid");
}
if (!parsed.hostname || parsed.username || parsed.password || parsed.search || parsed.hash || (parsed.pathname && parsed.pathname !== "/")) {
throw new Error("Kafka bootstrap servers are invalid");
}
return server;
}
function normalizeMqKafkaBootstrapServers(value: string): string {
const servers = requireMqField(value, "Kafka bootstrap servers are required")
.split(",")
.map((server) => server.trim())
.filter(Boolean)
.map(normalizeMqKafkaBootstrapServer);
if (!servers.length) throw new Error("Kafka bootstrap servers are required");
return servers.join(",");
}
function buildMqAuth(): MqAuth {
switch (mqAuthKind.value) {
case "token":
@@ -683,8 +766,25 @@ function buildMqTokenSigning() {
}
function buildMqAdminConfig(): MqAdminConfig {
const systemKind = mqSystemKind.value;
if (systemKind === "kafka") {
const bootstrapServers = normalizeMqKafkaBootstrapServers(mqKafkaBootstrapServers.value);
const extra: Record<string, string> = { bootstrapServers };
const securityProtocol = mqKafkaSecurityProtocol.value === MQ_KAFKA_SECURITY_PROTOCOL_AUTO ? "" : mqKafkaSecurityProtocol.value.trim();
const saslMechanism = mqKafkaSaslMechanism.value.trim();
if (securityProtocol) extra.securityProtocol = securityProtocol;
if (mqAuthKind.value === "basic" && saslMechanism) extra.saslMechanism = saslMechanism;
return {
systemKind: mqSystemKind.value,
adminUrl: "",
auth: buildMqAuth(),
tlsSkipVerify: mqTlsSkipVerify.value || undefined,
extra,
};
}
return {
systemKind: "pulsar",
systemKind: mqSystemKind.value,
adminUrl: requireMqField(mqAdminUrl.value, "MQ Admin URL is required"),
auth: buildMqAuth(),
tlsSkipVerify: mqTlsSkipVerify.value || undefined,
@@ -804,6 +904,20 @@ function applyMqAdminUrl(config: LegacyConnectionConfig, adminUrl: string) {
config.ssl = parsed.protocol === "https:";
}
function applyMqKafkaBootstrapServers(config: LegacyConnectionConfig, bootstrapServers: string, securityProtocol?: string) {
const first = normalizeMqKafkaBootstrapServers(bootstrapServers).split(",")[0];
if (!first) throw new Error("Kafka bootstrap servers are required");
let parsed: URL;
try {
parsed = new URL(`kafka://${first}`);
} catch {
throw new Error("Kafka bootstrap servers are invalid");
}
config.host = parsed.hostname;
config.port = Number(parsed.port) || 9092;
config.ssl = securityProtocol === "SSL" || securityProtocol === "SASL_SSL";
}
function applyNacosServerAddr(config: LegacyConnectionConfig, serverAddr: string) {
let parsed: URL;
try {
@@ -934,7 +1048,7 @@ function applyProfile(val: string, preserveConnectionFields = false) {
applyPrestoSqlBuiltinDriverPathsIfAvailable();
}
if (profile.type === "mq") {
resetMqFields();
resetMqFields(defaultMqFieldsForProfile(val));
form.value.database = undefined;
form.value.connection_string = undefined;
}
@@ -1202,6 +1316,7 @@ const iconTypeMap: Record<string, string> = {
etcd: "etcd",
zookeeper: "zookeeper",
mq: "mq",
kafka: "kafka",
nacos: "nacos",
dm: "dm",
h2: "h2",
@@ -1289,6 +1404,7 @@ const dbOptions: DbOption[] = [
{ value: "etcd", label: "etcd" },
{ value: "zookeeper", label: "Apache ZooKeeper" },
{ value: "mq", label: "Apache Pulsar" },
{ value: "kafka", label: "Apache Kafka" },
{ value: "nacos", label: "Nacos" },
{ value: "influxdb", label: "InfluxDB" },
{ value: "iris", label: "IRIS" },
@@ -1497,7 +1613,9 @@ const connectionLabelSmallClass = `${connectionLabelClass} text-xs`;
const connectionLabelTopClass = `${connectionLabelClass} mt-2`;
const connectionLabelSmallPaddedClass = `${connectionLabelClass} pt-2 text-xs`;
const hasRequiredConnectionTarget = computed(() => {
if (form.value.db_type === "mq") return !!mqAdminUrl.value.trim();
if (form.value.db_type === "mq") {
return mqSystemKind.value === "kafka" ? !!mqKafkaBootstrapServers.value.trim() : !!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();
if (isH2FileMode.value) return !!(form.value.host.trim() || h2FilePathFromJdbcUrl(form.value.connection_string));
@@ -1745,7 +1863,14 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
if (config.db_type === "mq") {
const mqConfig = buildMqAdminConfig();
config.external_config = mqConfig;
applyMqAdminUrl(config, mqConfig.adminUrl);
config.driver_profile = mqConfig.systemKind === "kafka" ? "kafka" : "pulsar";
config.driver_label = mqConfig.systemKind === "kafka" ? "Apache Kafka" : "Apache Pulsar";
if (mqConfig.systemKind === "kafka") {
const extra = mqExtraRecord(mqConfig);
applyMqKafkaBootstrapServers(config, mqExtraString(extra, "bootstrapServers"), mqExtraString(extra, "securityProtocol"));
} else {
applyMqAdminUrl(config, mqConfig.adminUrl);
}
config.username = "";
config.password = "";
config.database = undefined;
@@ -3265,22 +3390,52 @@ 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">Admin URL</Label>
<Input v-model="mqAdminUrl" class="col-span-3" placeholder="http://127.0.0.1:8080" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">System</Label>
<div class="col-span-3 h-9 rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground">Apache Pulsar</div>
<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">Bootstrap Servers</Label>
<Input v-model="mqKafkaBootstrapServers" class="col-span-3" placeholder="127.0.0.1:9092" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">Security</Label>
<Select v-model="mqKafkaSecurityProtocol">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in mqKafkaSecurityProtocolOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</template>
<template v-else>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">Admin URL</Label>
<Input v-model="mqAdminUrl" class="col-span-3" placeholder="http://127.0.0.1:8080" />
</div>
</template>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">Auth</Label>
<div class="col-span-3 flex flex-wrap gap-2">
<Button size="sm" :variant="mqAuthKind === 'none' ? 'default' : 'outline'" @click="mqAuthKind = 'none'">None</Button>
<Button size="sm" :variant="mqAuthKind === 'token' ? 'default' : 'outline'" @click="mqAuthKind = 'token'">Token</Button>
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'token' ? 'default' : 'outline'" @click="mqAuthKind = 'token'">Token</Button>
<Button size="sm" :variant="mqAuthKind === 'basic' ? 'default' : 'outline'" @click="mqAuthKind = 'basic'">Basic</Button>
<Button size="sm" :variant="mqAuthKind === 'apiKey' ? 'default' : 'outline'" @click="mqAuthKind = 'apiKey'">API Key</Button>
<Button size="sm" :variant="mqAuthKind === 'oauth2' ? 'default' : 'outline'" @click="mqAuthKind = 'oauth2'">OAuth2</Button>
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'apiKey' ? 'default' : 'outline'" @click="mqAuthKind = 'apiKey'">API Key</Button>
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'oauth2' ? 'default' : 'outline'" @click="mqAuthKind = 'oauth2'">OAuth2</Button>
</div>
</div>
<template v-if="mqAuthKind === 'token'">
@@ -3298,6 +3453,19 @@ function openExternalUrl(url: string) {
<Label :class="connectionLabelClass">{{ t("connection.password") }}</Label>
<Input v-model="mqBasicPassword" type="password" class="col-span-3" />
</div>
<div v-if="mqSystemKind === 'kafka'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">SASL Mechanism</Label>
<Select v-model="mqKafkaSaslMechanism">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in mqKafkaSaslMechanismOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</template>
<template v-else-if="mqAuthKind === 'apiKey'">
<div class="grid grid-cols-4 items-center gap-4">
@@ -3338,7 +3506,7 @@ function openExternalUrl(url: string) {
<span class="text-xs text-muted-foreground">Skip certificate verification</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div v-if="mqSystemKind !== 'kafka'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">Pinned Version</Label>
<Select v-model="mqPinnedVersion">
<SelectTrigger class="col-span-3 h-9">
@@ -3354,7 +3522,7 @@ function openExternalUrl(url: string) {
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div v-if="mqSystemKind !== 'kafka'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">Broker Token 签发</Label>
<Select v-model="mqTokenSigningMode">
<SelectTrigger class="col-span-3 h-9">
@@ -3367,7 +3535,7 @@ function openExternalUrl(url: string) {
</SelectContent>
</Select>
</div>
<div v-if="mqTokenSigningMode !== 'none'" class="grid grid-cols-4 items-start gap-4">
<div v-if="mqSystemKind !== 'kafka' && mqTokenSigningMode !== 'none'" class="grid grid-cols-4 items-start gap-4">
<Label class="pt-2 text-right">签发密钥</Label>
<textarea
v-model="mqTokenSigningKey"
@@ -3375,7 +3543,7 @@ function openExternalUrl(url: string) {
:placeholder="mqTokenSigningMode === 'hs256' ? 'Broker SECRET' : '-----BEGIN PRIVATE KEY-----'"
/>
</div>
<div v-if="mqTokenSigningMode !== 'none'" class="grid grid-cols-4 items-start gap-4">
<div v-if="mqSystemKind !== 'kafka' && mqTokenSigningMode !== 'none'" class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 m-0 text-xs leading-5 text-muted-foreground">按 Broker 的 jwt.broker.token.mode 选择:SECRET 使用 HS256,PRIVATE 使用 RS256。密钥会走连接 secret 存储。</p>
</div>
@@ -84,6 +84,7 @@ const assetIcons: Record<string, string> = {
chromadb: "chromadb",
mq: "pulsar",
pulsar: "pulsar",
kafka: "kafka",
nacos: "nacos.png",
iris: "iris.png",
influxdb: "influxdb",
@@ -1299,7 +1299,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
<template v-else-if="activeTab.mode === 'mq'">
<div class="flex-1 min-h-0">
<MqAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-tenant="activeTab.mqTenant" :read-only="activeConnection?.read_only ?? false" />
<MqAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-tenant="activeTab.mqTenant" :initial-tab="activeTab.mqInitialTab" :read-only="activeConnection?.read_only ?? false" />
</div>
</template>
@@ -0,0 +1,384 @@
<script setup lang="ts">
import { formatError } from "@/lib/errorUtils";
import { ref, watch, onMounted, onUnmounted } from "vue";
import type { ClusterInfo } from "@/types/mq";
import { mqGetClusterInfo } from "@/lib/api";
interface Props {
connectionId: string;
readOnly?: boolean;
}
const props = defineProps<Props>();
const clusterInfo = ref<ClusterInfo>();
const loading = ref(false);
const error = ref<string>();
const autoRefresh = ref(true);
const refreshInterval = ref(10);
let refreshTimer: number | undefined;
function isDocumentHidden(): boolean {
return typeof document !== "undefined" && document.hidden;
}
async function loadClusterInfo(options: { skipWhenHidden?: boolean } = {}) {
if (options.skipWhenHidden && isDocumentHidden()) return;
loading.value = true;
error.value = undefined;
try {
clusterInfo.value = await mqGetClusterInfo(props.connectionId);
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
function refreshNow() {
void loadClusterInfo();
}
function startAutoRefresh() {
stopAutoRefresh();
if (autoRefresh.value && !isDocumentHidden()) {
refreshTimer = window.setInterval(() => {
void loadClusterInfo({ skipWhenHidden: true });
}, refreshInterval.value * 1000);
}
}
function stopAutoRefresh() {
if (refreshTimer !== undefined) {
clearInterval(refreshTimer);
refreshTimer = undefined;
}
}
function handleVisibilityChange() {
if (isDocumentHidden()) {
stopAutoRefresh();
return;
}
startAutoRefresh();
void loadClusterInfo();
}
watch(autoRefresh, () => {
startAutoRefresh();
});
watch(refreshInterval, () => {
if (autoRefresh.value) {
startAutoRefresh();
}
});
onMounted(() => {
document.addEventListener("visibilitychange", handleVisibilityChange);
void loadClusterInfo();
startAutoRefresh();
});
onUnmounted(() => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
stopAutoRefresh();
});
</script>
<template>
<div class="broker-panel">
<div class="panel-toolbar">
<h3>Broker 集群</h3>
<div class="toolbar-actions">
<label class="checkbox-label">
<input type="checkbox" v-model="autoRefresh" />
自动刷新
</label>
<select v-model.number="refreshInterval" :disabled="!autoRefresh" class="refresh-interval">
<option :value="5">5秒</option>
<option :value="10">10秒</option>
<option :value="30">30秒</option>
<option :value="60">60秒</option>
</select>
<button @click="refreshNow" :disabled="loading" class="btn-sm">
{{ loading ? "刷新中..." : "立即刷新" }}
</button>
</div>
</div>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-else-if="loading && !clusterInfo" class="panel-loading">加载中...</div>
<div v-else-if="clusterInfo" class="broker-content">
<!-- 集群概览 -->
<div class="stats-section">
<h4>集群概览</h4>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">🔗</div>
<div class="stat-content">
<div class="stat-label">集群 ID</div>
<div class="stat-value stat-value-sm">{{ clusterInfo.clusterId || "未知" }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🖥️</div>
<div class="stat-content">
<div class="stat-label">Broker 数量</div>
<div class="stat-value">{{ clusterInfo.brokerCount }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">👑</div>
<div class="stat-content">
<div class="stat-label">Controller</div>
<div class="stat-value stat-value-sm">
<template v-if="clusterInfo.controllerHost"> Node {{ clusterInfo.controllerId ?? "?" }} · {{ clusterInfo.controllerHost }} </template>
<template v-else>未知</template>
</div>
</div>
</div>
</div>
</div>
<!-- Broker 节点列表 -->
<div class="stats-section">
<h4>Broker 节点</h4>
<div v-if="clusterInfo.brokers.length" class="broker-table-wrap">
<table class="broker-table">
<thead>
<tr>
<th>Node ID</th>
<th>Host</th>
<th>Port</th>
<th>Rack</th>
<th>角色</th>
</tr>
</thead>
<tbody>
<tr v-for="broker in clusterInfo.brokers" :key="broker.id" :class="{ 'is-controller': broker.id === clusterInfo.controllerId }">
<td class="node-id">{{ broker.id }}</td>
<td>{{ broker.host }}</td>
<td>{{ broker.port }}</td>
<td>{{ broker.rack || "-" }}</td>
<td>
<span v-if="broker.id === clusterInfo.controllerId" class="role-badge controller">Controller</span>
<span v-else class="role-badge follower">Follower</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-state">暂无 Broker 节点信息</div>
</div>
</div>
</div>
</template>
<style scoped>
.broker-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;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
cursor: pointer;
}
.refresh-interval {
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 13px;
background: var(--color-background);
cursor: pointer;
}
.refresh-interval:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.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;
transition: all 0.2s;
}
.btn-sm:hover:not(:disabled) {
background: var(--color-hover);
}
.btn-sm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.panel-error,
.panel-loading,
.empty-state {
padding: 24px;
text-align: center;
color: var(--color-text-secondary);
}
.panel-error {
color: var(--color-error);
}
.broker-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.stats-section {
margin-bottom: 24px;
}
.stats-section h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text-secondary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--color-background-secondary);
border: 1px solid var(--color-border);
border-radius: 8px;
transition: all 0.2s;
}
.stat-card:hover {
border-color: var(--color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-icon {
font-size: 32px;
line-height: 1;
}
.stat-content {
flex: 1;
min-width: 0;
}
.stat-label {
font-size: 12px;
color: var(--color-text-tertiary);
margin-bottom: 4px;
}
.stat-value {
font-size: 20px;
font-weight: 600;
color: var(--color-text);
}
.stat-value-sm {
font-size: 14px;
font-weight: 500;
word-break: break-all;
}
.broker-table-wrap {
overflow-x: auto;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background-secondary);
}
.broker-table {
width: 100%;
border-collapse: collapse;
}
.broker-table th,
.broker-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--color-border-light);
text-align: left;
font-size: 13px;
}
.broker-table th {
color: var(--color-text-secondary);
font-weight: 600;
}
.broker-table tbody tr:last-child td {
border-bottom: none;
}
.broker-table tbody tr.is-controller {
background: var(--color-primary-alpha);
}
.node-id {
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.role-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.role-badge.controller {
background: var(--color-primary-alpha);
color: var(--color-primary);
}
.role-badge.follower {
background: var(--color-background-tertiary);
color: var(--color-text-secondary);
}
</style>
File diff suppressed because it is too large. Load diff
@@ -14,20 +14,23 @@ import ProducerConsumerPanel from "./ProducerConsumerPanel.vue";
import PoliciesPanel from "./PoliciesPanel.vue";
import PermissionsPanel from "./PermissionsPanel.vue";
import RawApiPanel from "./RawApiPanel.vue";
import SendMessagePanel from "./SendMessagePanel.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;
initialTab?: MqTab;
readOnly?: boolean;
}
const props = defineProps<Props>();
const connectionStore = useConnectionStore();
type MqTab = "tenants" | "namespaces" | "topics" | "subscriptions" | "monitoring" | "clients" | "policies" | "permissions" | "raw";
// State
const activeTab = ref<MqTab>(props.initialTenant ? "namespaces" : "tenants");
const activeTab = ref<MqTab>(props.initialTab || (props.initialTenant ? "namespaces" : "tenants"));
const selectedTenant = ref<string | undefined>(props.initialTenant);
const selectedNamespace = ref<string>();
const selectedTopic = ref<TopicInfo>();
@@ -36,8 +39,12 @@ const capabilities = ref<MqClusterInfo["capabilities"]>();
const clusterInfo = ref<MqClusterInfo>();
const loading = ref(false);
const error = ref<string>();
const KAFKA_CONTEXT = "_kafka";
// 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);
@@ -55,6 +62,7 @@ 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 clusterOptions = computed(() => mqClusterOptionsFromExtra(clusterInfo.value?.extra));
const availableTabs = computed<MqTab[]>(() => {
@@ -62,9 +70,11 @@ const availableTabs = computed<MqTab[]>(() => {
if (canManageTenants.value) tabs.push("tenants");
if (canManageNamespaces.value) tabs.push("namespaces");
tabs.push("topics");
if (canSendMessage.value) tabs.push("messages");
if (canManageSubscriptions.value) tabs.push("subscriptions");
tabs.push("monitoring");
tabs.push("clients");
tabs.push("broker");
if (canManagePolicies.value) tabs.push("policies");
if (canManagePermissions.value) tabs.push("permissions");
if (canUseRawApi.value) tabs.push("raw");
@@ -168,6 +178,12 @@ watch(
}
},
);
watch(
() => props.initialTab,
(tab) => {
if (tab) setActiveTab(tab);
},
);
// Lifecycle
onMounted(async () => {
@@ -204,9 +220,11 @@ onMounted(async () => {
<button v-if="canManageTenants" :class="{ active: activeTab === 'tenants' }" @click="setActiveTab('tenants')">租户</button>
<button v-if="canManageNamespaces" :class="{ active: activeTab === 'namespaces' }" @click="setActiveTab('namespaces')">命名空间</button>
<button :class="{ active: activeTab === 'topics' }" @click="setActiveTab('topics')">主题</button>
<button v-if="canSendMessage" :class="{ active: activeTab === 'messages' }" @click="setActiveTab('messages')">发送消息</button>
<button v-if="canManageSubscriptions" :class="{ active: activeTab === 'subscriptions' }" @click="setActiveTab('subscriptions')">订阅</button>
<button :class="{ active: activeTab === 'monitoring' }" @click="setActiveTab('monitoring')">监控</button>
<button :class="{ active: activeTab === 'clients' }" @click="setActiveTab('clients')">客户端</button>
<button :class="{ active: activeTab === 'broker' }" @click="setActiveTab('broker')">Broker</button>
<button v-if="canManagePolicies" :class="{ active: activeTab === 'policies' }" @click="setActiveTab('policies')">策略</button>
<button v-if="canManagePermissions" :class="{ active: activeTab === 'permissions' }" @click="setActiveTab('permissions')">权限</button>
<button v-if="canUseRawApi" :class="{ active: activeTab === 'raw' }" @click="setActiveTab('raw')">Raw API</button>
@@ -216,13 +234,14 @@ onMounted(async () => {
<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="selectedTenant" :namespace="selectedNamespace" :read-only="readOnly" :supports-partitioned-topics="canManagePartitionedTopics" @topic-selected="handleTopicSelected" />
<TopicsPanel v-else-if="activeTab === 'topics'" :connection-id="connectionId" :tenant="effectiveTenant" :namespace="effectiveNamespace" :read-only="readOnly" :supports-partitioned-topics="canManagePartitionedTopics" @topic-selected="handleTopicSelected" />
<SendMessagePanel v-else-if="activeTab === 'messages' && canSendMessage" :connection-id="connectionId" :tenant="effectiveTenant" :namespace="effectiveNamespace" :topic="selectedTopic" :read-only="readOnly" />
<SubscriptionsPanel
v-else-if="activeTab === 'subscriptions' && canManageSubscriptions"
:connection-id="connectionId"
:topic="selectedTopic"
:tenant="selectedTenant"
:namespace="selectedNamespace"
:tenant="effectiveTenant"
:namespace="effectiveNamespace"
:read-only="readOnly"
:supports-create-subscription="canCreateSubscription"
:supports-reset-cursor="canResetCursor"
@@ -232,20 +251,21 @@ onMounted(async () => {
:supports-expire-messages="canExpireMessages"
@subscription-selected="handleSubscriptionSelected"
/>
<MonitoringPanel v-else-if="activeTab === 'monitoring'" :connection-id="connectionId" :topic="selectedTopic" :tenant="selectedTenant" :namespace="selectedNamespace" />
<ProducerConsumerPanel v-else-if="activeTab === 'clients'" :connection-id="connectionId" :topic="selectedTopic" :tenant="selectedTenant" :namespace="selectedNamespace" :read-only="readOnly" :selected-subscription="selectedSubscriptionName" />
<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" />
<BrokerPanel v-else-if="activeTab === 'broker'" :connection-id="connectionId" :read-only="readOnly" />
<PoliciesPanel
v-else-if="activeTab === 'policies' && canManagePolicies"
:connection-id="connectionId"
:topic="selectedTopic"
:tenant="selectedTenant"
:namespace="selectedNamespace"
:tenant="effectiveTenant"
:namespace="effectiveNamespace"
:read-only="readOnly"
:supports-rate-limits="canManageRateLimits"
:supports-backlog-quota="canManageBacklogQuota"
:supports-retention="canManageRetention"
/>
<PermissionsPanel v-else-if="activeTab === 'permissions' && canManagePermissions" :connection-id="connectionId" :topic="selectedTopic" :tenant="selectedTenant" :namespace="selectedNamespace" :read-only="readOnly" />
<PermissionsPanel v-else-if="activeTab === 'permissions' && canManagePermissions" :connection-id="connectionId" :topic="selectedTopic" :tenant="effectiveTenant" :namespace="effectiveNamespace" :read-only="readOnly" />
<RawApiPanel v-else-if="activeTab === 'raw' && canUseRawApi" :connection-id="connectionId" :tenant="selectedTenant" :namespace="selectedNamespace" :topic="selectedTopic" :read-only="readOnly" />
</div>
</div>
@@ -90,7 +90,14 @@ async function loadPermissions() {
try {
permissions.value = await mqListPermissions(props.connectionId, current);
} catch (e: unknown) {
error.value = formatError(e);
const msg = formatError(e);
// Gracefully handle Kafka brokers without an authorizer configured.
if (msg.includes("SecurityDisabled") || msg.includes("No Authorizer") || msg.includes("authorizer")) {
error.value = undefined;
notice.value = "当前 Kafka 集群未启用权限管理(Authorizer 未配置)。如需 ACL 功能,请在 Broker 配置中添加 authorizer.class.name。";
} else {
error.value = msg;
}
} finally {
loading.value = false;
}
@@ -128,7 +135,12 @@ async function grantPermission() {
roleName.value = "";
await loadPermissions();
} catch (e: unknown) {
error.value = formatError(e);
const msg = formatError(e);
if (msg.includes("SecurityDisabled") || msg.includes("No Authorizer") || msg.includes("authorizer")) {
error.value = "当前 Kafka 集群未启用权限管理,无法执行授权操作。请在 Broker 配置中启用 Authorizer。";
} else {
error.value = msg;
}
} finally {
loading.value = false;
}
@@ -2,7 +2,7 @@
import { formatError } from "@/lib/errorUtils";
import { computed, ref, watch } from "vue";
import type { ConsumerInfo, ProducerInfo, SubscriptionInfo, TopicInfo, TopicRef, TopicStats } from "@/types/mq";
import { mqGetTopicStats, mqListSubscriptions, mqUnloadTopic } from "@/lib/api";
import { mqGetTopicStats, mqListConsumers, mqListProducers, mqListSubscriptions, mqUnloadTopic } from "@/lib/api";
interface Props {
connectionId: string;
@@ -27,6 +27,16 @@ interface PartitionClientRow {
subscriptions: SubscriptionInfo[];
}
interface KafkaPartitionRow {
partition: number;
beginOffset: number;
endOffset: number;
messageCount: number;
leader: number;
replicas: number[];
isr: number[];
}
const producers = ref<ProducerInfo[]>([]);
const subscriptions = ref<SubscriptionInfo[]>([]);
const stats = ref<TopicStats>();
@@ -48,6 +58,8 @@ const topicRef = computed<TopicRef | null>(() => {
});
const partitionRows = computed(() => extractPartitionClientRows(stats.value?.raw));
const kafkaPartitionRows = computed(() => extractKafkaPartitionRows(stats.value?.raw));
const isKafkaStats = computed(() => kafkaPartitionRows.value.length > 0);
const selectedPartition = computed(() => {
if (!partitionRows.value.length) return undefined;
return partitionRows.value.find((row) => row.name === selectedPartitionName.value) ?? partitionRows.value[0];
@@ -78,11 +90,15 @@ async function loadRuntimeClients() {
try {
const statsData = await mqGetTopicStats(props.connectionId, current);
stats.value = statsData;
producers.value = extractProducersFromStats(statsData.raw);
const parsedProducers = extractProducersFromStats(statsData.raw);
producers.value = parsedProducers.length ? parsedProducers : await mqListProducers(props.connectionId, current);
const parsedSubscriptions = extractSubscriptionsFromStats(statsData.raw);
const partitionSubscriptions = mergeSubscriptionOptions([], extractPartitionClientRows(statsData.raw));
subscriptions.value = parsedSubscriptions.length || partitionSubscriptions.length ? mergeSubscriptionOptions(parsedSubscriptions, extractPartitionClientRows(statsData.raw)) : await mqListSubscriptions(props.connectionId, current);
syncSelectedSubscription();
if (selectedSubscription.value && !subscriptions.value.some((sub) => sub.name === selectedSubscription.value && sub.consumers.length > 0)) {
await loadSelectedSubscriptionConsumers();
}
syncSelectedPartition();
} catch (e: unknown) {
error.value = formatError(e) || String(e);
@@ -119,6 +135,14 @@ function formatBytes(value: number): string {
return `${(value / 1024 ** index).toFixed(2)} ${units[index]}`;
}
function formatOptionalRate(value: number): string {
return isKafkaStats.value ? "-" : formatRate(value);
}
function formatOptionalBytes(value: number): string {
return isKafkaStats.value ? "-" : formatBytes(value);
}
function syncSelectedSubscription() {
const options = subscriptionOptions.value;
if (props.selectedSubscription && options.some((sub) => sub.name === props.selectedSubscription)) {
@@ -151,6 +175,13 @@ function syncSelectedSubscriptionForPartition() {
selectedSubscription.value = (active ?? partition.subscriptions[0])?.name ?? selectedSubscription.value;
}
async function loadSelectedSubscriptionConsumers() {
const current = topicRef.value;
if (!current || !selectedSubscription.value) return;
const consumers = await mqListConsumers(props.connectionId, current, selectedSubscription.value);
subscriptions.value = subscriptions.value.map((sub) => (sub.name === selectedSubscription.value ? { ...sub, consumers } : sub));
}
function extractProducersFromStats(raw: unknown): ProducerInfo[] {
const root = objectRecord(raw);
const producers = arrayObjects(root.publishers).map(parseProducer);
@@ -183,6 +214,20 @@ function extractPartitionClientRows(raw: unknown): PartitionClientRow[] {
});
}
function extractKafkaPartitionRows(raw: unknown): KafkaPartitionRow[] {
return arrayObjects(objectRecord(raw).partitionStats)
.map((body) => ({
partition: numberField(body.partition) ?? 0,
beginOffset: numberField(body.beginOffset) ?? 0,
endOffset: numberField(body.endOffset) ?? 0,
messageCount: numberField(body.messageCount) ?? 0,
leader: numberField(body.leader) ?? -1,
replicas: numberArrayField(body.replicas),
isr: numberArrayField(body.isr),
}))
.sort((a, b) => a.partition - b.partition);
}
function mergeSubscriptionOptions(base: SubscriptionInfo[], partitions: PartitionClientRow[]): SubscriptionInfo[] {
const byName = new Map<string, SubscriptionInfo>();
for (const sub of base) {
@@ -263,6 +308,10 @@ function numberField(value: unknown): number | undefined {
return undefined;
}
function numberArrayField(value: unknown): number[] {
return Array.isArray(value) ? value.map(numberField).filter((item): item is number => item !== undefined) : [];
}
function stringField(value: unknown): string {
return typeof value === "string" ? value : "";
}
@@ -277,6 +326,9 @@ watch(
watch(selectedSubscription, () => {
syncSelectedPartition();
void loadSelectedSubscriptionConsumers().catch((e: unknown) => {
error.value = formatError(e) || String(e);
});
});
watch(selectedPartitionName, () => {
@@ -311,7 +363,38 @@ watch(
<div v-else-if="error" class="panel-error">{{ error }}</div>
<div v-else class="runtime-content">
<section v-if="partitionRows.length" class="runtime-section">
<section v-if="isKafkaStats" class="runtime-section">
<div class="section-heading">
<h4>Kafka 分区状态</h4>
<span>{{ kafkaPartitionRows.length }} 个分区</span>
</div>
<table class="runtime-table partition-table">
<thead>
<tr>
<th>分区</th>
<th>起始 offset</th>
<th>最新 offset</th>
<th>消息数</th>
<th>Leader</th>
<th>Replicas</th>
<th>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>分区客户端</h4>
<span>{{ partitionRows.length }} 个分区</span>
@@ -366,8 +449,8 @@ watch(
<td>{{ producer.producerId }}</td>
<td>{{ producer.address || "-" }}</td>
<td>{{ producer.clientVersion || "-" }}</td>
<td>{{ formatRate(producer.msgRateIn) }}</td>
<td>{{ formatBytes(producer.msgThroughputIn) }}</td>
<td>{{ formatOptionalRate(producer.msgRateIn) }}</td>
<td>{{ formatOptionalBytes(producer.msgThroughputIn) }}</td>
</tr>
</tbody>
</table>
@@ -379,7 +462,7 @@ watch(
<div class="subscription-selector">
<span v-if="selectedPartition" class="scope-chip">{{ selectedScopeLabel }}</span>
<span>{{ displayedConsumers.length }}</span>
<select v-if="partitionRows.length" v-model="selectedPartitionName" :disabled="loading">
<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">
@@ -407,8 +490,8 @@ watch(
<td>{{ consumer.consumerName || "-" }}</td>
<td>{{ consumer.address || "-" }}</td>
<td>{{ consumer.clientVersion || "-" }}</td>
<td>{{ formatRate(consumer.msgRateOut) }}</td>
<td>{{ formatBytes(consumer.msgThroughputOut) }}</td>
<td>{{ formatOptionalRate(consumer.msgRateOut) }}</td>
<td>{{ formatOptionalBytes(consumer.msgThroughputOut) }}</td>
<td>{{ consumer.availablePermits }}</td>
</tr>
</tbody>
@@ -0,0 +1,532 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from "vue";
import type { TopicInfo, SendMessageRequest, SendMessageResponse } from "@/types/mq";
import { mqSendMessage, mqListTopics } from "@/lib/api";
import { formatError } from "@/lib/errorUtils";
interface Props {
connectionId: string;
tenant?: string;
namespace?: string;
topic?: TopicInfo;
readOnly?: boolean;
}
const props = defineProps<Props>();
const topicName = ref("");
const messageKey = ref("");
const messageValue = ref("");
const headersText = ref("");
const loading = ref(false);
const error = ref<string>();
const success = ref<SendMessageResponse>();
const availableTopics = ref<TopicInfo[]>([]);
const topicsLoading = ref(false);
const headersExpanded = ref(false);
let successTimer: ReturnType<typeof setTimeout> | undefined;
const readOnlyMessage = "当前连接为只读模式,不能发送消息";
const topicOptions = computed(() => {
return availableTopics.value.map((t) => ({
value: t.shortName,
label: t.shortName,
partitions: t.partitions,
}));
});
function clearSuccessLater() {
if (successTimer) clearTimeout(successTimer);
successTimer = setTimeout(() => {
success.value = undefined;
}, 4000);
}
onUnmounted(() => {
if (successTimer) clearTimeout(successTimer);
});
function guardWritable() {
if (props.readOnly) {
error.value = readOnlyMessage;
return false;
}
return true;
}
async function loadTopics() {
if (!props.tenant || !props.namespace) return;
topicsLoading.value = true;
try {
availableTopics.value = await mqListTopics(
props.connectionId,
{
tenant: props.tenant,
namespace: props.namespace,
},
{ includeNonPersistent: false },
);
if (props.topic && !topicName.value) {
topicName.value = props.topic.shortName;
}
} catch (e: unknown) {
console.warn("Failed to load topics:", e);
} finally {
topicsLoading.value = false;
}
}
function parseHeaders(): Record<string, string> {
const result: Record<string, string> = {};
if (!headersText.value.trim()) return result;
for (const line of headersText.value.split("\n")) {
const trimmed = line.trim();
if (!trimmed || !trimmed.includes(":")) continue;
const colonIndex = trimmed.indexOf(":");
const key = trimmed.slice(0, colonIndex).trim();
const value = trimmed.slice(colonIndex + 1).trim();
if (key) result[key] = value;
}
return result;
}
async function sendMessage() {
if (!guardWritable()) return;
error.value = undefined;
success.value = undefined;
const topic = topicName.value.trim();
if (!topic) {
error.value = "请选择目标主题";
return;
}
if (!messageValue.value) {
error.value = "消息内容不能为空";
return;
}
loading.value = true;
try {
const headers = parseHeaders();
const payloadBase64 = btoa(unescape(encodeURIComponent(messageValue.value)));
const req: SendMessageRequest = {
topic,
key: messageKey.value.trim() || undefined,
payloadBase64,
payloadText: messageValue.value,
headers,
};
success.value = await mqSendMessage(props.connectionId, req);
messageValue.value = "";
messageKey.value = "";
clearSuccessLater();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
function formatJson() {
try {
const parsed = JSON.parse(messageValue.value);
messageValue.value = JSON.stringify(parsed, null, 2);
} catch {
// Not valid JSON, leave as-is
}
}
function clearForm() {
topicName.value = "";
messageKey.value = "";
messageValue.value = "";
headersText.value = "";
error.value = undefined;
success.value = undefined;
}
watch(
() => [props.tenant, props.namespace],
() => {
loadTopics();
},
{ immediate: true },
);
watch(
() => props.topic,
(newTopic) => {
if (newTopic) {
topicName.value = newTopic.shortName;
}
},
);
</script>
<template>
<div class="send-message-panel">
<div class="panel-toolbar">
<h3>发送消息</h3>
<button @click="clearForm" :disabled="loading" class="btn-sm">清空</button>
</div>
<div v-if="!tenant || !namespace" class="panel-placeholder">请先选择命名空间或主题</div>
<div v-else class="send-form">
<div v-if="readOnly" class="readonly-hint">{{ readOnlyMessage }}</div>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-if="success" class="panel-success">
<span class="success-icon">✓</span>
<span>消息发送成功 — 分区: {{ success.partition }},偏移: {{ success.offset }}</span>
</div>
<!-- 主题选择 -->
<div class="form-group">
<label>目标主题 <span class="required">*</span></label>
<div class="topic-select-row">
<select v-model="topicName" :disabled="readOnly || topicsLoading" class="topic-select">
<option value="" disabled>{{ topicsLoading ? "加载中..." : "选择主题..." }}</option>
<option v-for="t in topicOptions" :key="t.value" :value="t.value">
{{ t.label }}<template v-if="t.partitions != null"> ({{ t.partitions }} 分区)</template>
</option>
</select>
<button @click="loadTopics" :disabled="topicsLoading" class="btn-icon" title="刷新主题列表">
<span v-if="topicsLoading" class="spin">⟳</span>
<span v-else>⟳</span>
</button>
</div>
<div v-if="!availableTopics.length && !topicsLoading" class="form-hint">暂无可用主题</div>
</div>
<!-- 消息键 -->
<div class="form-group">
<label>消息键 (Key)</label>
<input v-model="messageKey" type="text" placeholder="可选" :disabled="readOnly" />
</div>
<!-- 消息内容 -->
<div class="form-group">
<div class="label-row">
<label>消息内容 <span class="required">*</span></label>
<button @click="formatJson" :disabled="readOnly || !messageValue" class="btn-sm">格式化 JSON</button>
</div>
<textarea v-model="messageValue" :disabled="readOnly" placeholder='{"key": "value"}' rows="8" class="code-textarea" />
</div>
<!-- 消息头(可折叠) -->
<div class="form-group">
<button type="button" class="collapse-toggle" @click="headersExpanded = !headersExpanded">
<span class="collapse-arrow" :class="{ expanded: headersExpanded }">▶</span>
<span>消息头 (Headers)</span>
<span v-if="headersText.trim() && !headersExpanded" class="collapse-badge">·</span>
</button>
<div v-if="headersExpanded" class="collapse-body">
<textarea v-model="headersText" :disabled="readOnly" placeholder="key: value(每行一个)" rows="3" class="headers-textarea" />
</div>
</div>
<!-- 发送按钮 -->
<div class="form-actions">
<button @click="sendMessage" :disabled="loading || readOnly || !topicName || !messageValue" class="btn-primary">
{{ loading ? "发送中..." : "发送消息" }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.send-message-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 {
padding: 40px 24px;
text-align: center;
color: var(--color-text-secondary);
font-size: 14px;
}
.send-form {
flex: 1;
overflow: auto;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.readonly-hint {
padding: 10px 14px;
border-radius: 6px;
background: var(--color-warning-alpha);
color: var(--color-warning);
font-size: 13px;
}
.panel-error {
padding: 10px 14px;
border-radius: 6px;
background: var(--color-error-bg);
color: var(--color-error);
font-size: 13px;
}
.panel-success {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
border: 1px solid color-mix(in srgb, var(--color-success) 34%, transparent);
border-left: 4px solid var(--color-success);
border-radius: 8px;
background: color-mix(in srgb, var(--color-success) 13%, var(--color-background));
color: var(--color-success);
font-size: 15px;
font-weight: 700;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.08);
}
.success-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: 999px;
background: var(--color-success);
color: #fff;
font-size: 16px;
line-height: 1;
}
.panel-success span:last-child {
padding-top: 3px;
line-height: 1.45;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.label-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.form-group label {
font-weight: 500;
font-size: 13px;
color: var(--color-text-secondary);
}
.required {
color: var(--color-error);
}
.topic-select-row {
display: flex;
align-items: center;
gap: 8px;
}
.topic-select {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
font-size: 13px;
cursor: pointer;
appearance: auto;
}
.topic-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
.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);
}
.spin {
display: inline-block;
animation: spin-anim 0.8s linear infinite;
}
@keyframes spin-anim {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
input[type="text"] {
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;
}
input[type="text"]:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
.code-textarea,
.headers-textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
resize: vertical;
box-sizing: border-box;
line-height: 1.5;
}
.code-textarea:focus,
.headers-textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
.headers-textarea {
font-family: inherit;
}
.form-hint {
font-size: 12px;
color: var(--color-text-tertiary);
line-height: 1.4;
}
.collapse-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 0;
border: none;
background: none;
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
user-select: none;
}
.collapse-arrow {
font-size: 10px;
transition: transform 0.15s;
display: inline-block;
}
.collapse-arrow.expanded {
transform: rotate(90deg);
}
.collapse-badge {
color: var(--color-primary);
font-weight: 700;
}
.collapse-body {
margin-top: 6px;
}
.form-actions {
display: flex;
gap: 8px;
padding-top: 4px;
}
.btn-primary,
.btn-sm {
padding: 7px 16px;
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.15s;
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
font-weight: 500;
min-width: 100px;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
button:disabled,
input:disabled,
textarea:disabled,
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -499,7 +499,7 @@ async function toggle() {
queryStore.createTab(node.connectionId, node.database, tabTitle, "redis");
} else if (node.type === "mq-tenant" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
queryStore.openMqAdmin(node.connectionId, { tenant: node.mqTenant || node.label });
queryStore.openMqAdmin(node.connectionId, { tenant: node.mqTenant || node.label, initialTab: node.mqInitialTab });
} else if (node.type === "nacos-namespace" && node.connectionId) {
await connectionStore.ensureConnected(node.connectionId);
queryStore.openNacosAdmin(node.connectionId, { namespace: node.nacosNamespace || "", namespaceName: node.nacosNamespaceName || node.label });