feat(kafka): support Kafka MQ admin client
This commit is contained in:
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 });
|
||||
|
||||
Reference in new issue
Block a user