mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(kafka): support Kafka MQ admin client
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ plugins {
|
||||
|
||||
def java8Projects = ['common', 'test-support'] as Set
|
||||
def infrastructureProjects = ['common', 'test-support'] as Set
|
||||
def legacyStandaloneProjects = ['mongodb'] as Set
|
||||
def legacyStandaloneProjects = ['mongodb', 'kafka'] as Set
|
||||
def agentProjects = subprojects.findAll { !infrastructureProjects.contains(it.name) }
|
||||
def jdbcAgentProjects = agentProjects.findAll { !legacyStandaloneProjects.contains(it.name) }
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.12.1'
|
||||
implementation 'org.apache.kafka:kafka-clients:3.9.0'
|
||||
runtimeOnly 'org.slf4j:slf4j-simple:1.7.36'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
mergeServiceFiles()
|
||||
manifest {
|
||||
attributes('Agent-Label': 'Apache Kafka', 'Main-Class': 'com.dbx.agent.kafka.KafkaAgent')
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ SOURCE_GLOBS = ("*/src/main/**/*.java", "drivers/*/src/main/**/*.java")
|
||||
KOTLIN_FILE_SUFFIXES = (".kt", ".kts")
|
||||
KOTLIN_SCAN_EXCLUDED_PARTS = {".git", ".gradle", "build"}
|
||||
DEFAULT_AGENT_JRE_KEY = "21"
|
||||
NON_JDBC_AGENT_MODULES = {"mongodb", "etcd", "zookeeper"}
|
||||
NON_JDBC_AGENT_MODULES = {"mongodb", "etcd", "zookeeper", "kafka"}
|
||||
NATIVE_ONLY_AGENT_MODULES = {
|
||||
"oracle": "drivers/oracle-go",
|
||||
"xugu": "drivers/xugu",
|
||||
|
||||
@@ -6,7 +6,7 @@ def driverModules = [
|
||||
'teradata', 'vertica', 'firebird', 'exasol', 'oceanbase-oracle', 'gbase8a', 'gbase8s',
|
||||
'bigquery', 'kylin', 'sundb', 'h2', 'snowflake', 'trino', 'hive',
|
||||
'db2', 'informix', 'neo4j', 'cassandra', 'mongodb', 'highgo', 'tdengine', 'yashandb',
|
||||
'iris', 'iotdb', 'etcd', 'zookeeper'
|
||||
'iris', 'iotdb', 'etcd', 'zookeeper', 'kafka'
|
||||
]
|
||||
|
||||
include(*(infrastructureModules + driverModules))
|
||||
|
||||
@@ -34,5 +34,6 @@
|
||||
"iris": "0.1.16",
|
||||
"iotdb": "0.1.13",
|
||||
"etcd": "0.1.13",
|
||||
"zookeeper": "0.1.3"
|
||||
"zookeeper": "0.1.3",
|
||||
"kafka": "0.1.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<!-- Kafka network topology icon (symbol only, no text) -->
|
||||
<!-- Central node (larger) -->
|
||||
<circle cx="24" cy="24" r="5" fill="#231F20"/>
|
||||
<!-- Top node -->
|
||||
<circle cx="24" cy="6" r="3.5" fill="#231F20"/>
|
||||
<!-- Bottom node -->
|
||||
<circle cx="24" cy="42" r="3.5" fill="#231F20"/>
|
||||
<!-- Upper-left node -->
|
||||
<circle cx="8" cy="14" r="3.5" fill="#231F20"/>
|
||||
<!-- Lower-left node -->
|
||||
<circle cx="8" cy="34" r="3.5" fill="#231F20"/>
|
||||
<!-- Right node -->
|
||||
<circle cx="40" cy="24" r="3.5" fill="#231F20"/>
|
||||
<!-- Connecting lines -->
|
||||
<line x1="24" y1="19" x2="24" y2="9.5" stroke="#231F20" stroke-width="1.5"/>
|
||||
<line x1="24" y1="29" x2="24" y2="38.5" stroke="#231F20" stroke-width="1.5"/>
|
||||
<line x1="20.5" y1="21.5" x2="11" y2="16" stroke="#231F20" stroke-width="1.5"/>
|
||||
<line x1="20.5" y1="26.5" x2="11" y2="32" stroke="#231F20" stroke-width="1.5"/>
|
||||
<line x1="29" y1="24" x2="36.5" y2="24" stroke="#231F20" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -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 });
|
||||
|
||||
@@ -11,12 +11,14 @@ function agentDriverInstallKey(dbType: DatabaseType | undefined, driverProfile?:
|
||||
if (dbType === "oracle") return "oracle";
|
||||
if (dbType === "mongodb") return "mongodb";
|
||||
if (dbType === "dameng") return "dameng";
|
||||
if (dbType === "mq") return driverProfile === "kafka" ? "kafka" : undefined;
|
||||
return driverProfile && driverProfile !== dbType ? driverProfile : dbType;
|
||||
}
|
||||
|
||||
export function showAgentDriverInstallHint(dbType: DatabaseType | undefined, drivers: readonly AgentDriverInstallState[], driverProfile?: string): boolean {
|
||||
if (!supportsDriverManagement(dbType)) return false;
|
||||
const driverKey = agentDriverInstallKey(dbType, driverProfile);
|
||||
if (!driverKey) return false;
|
||||
return drivers.find((driver) => driver.db_type === driverKey)?.installed !== true;
|
||||
}
|
||||
|
||||
|
||||
@@ -361,7 +361,9 @@ export const mqListPermissions = forward("mqListPermissions");
|
||||
export const mqIssueToken = forward("mqIssueToken");
|
||||
export const mqListTokenRecords = forward("mqListTokenRecords");
|
||||
export const mqGetBacklog = forward("mqGetBacklog");
|
||||
export const mqGetClusterInfo = forward("mqGetClusterInfo");
|
||||
export const mqRawRequest = forward("mqRawRequest");
|
||||
export const mqSendMessage = forward("mqSendMessage");
|
||||
|
||||
// MongoDB
|
||||
export const mongoListDatabases = forward("mongoListDatabases");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { apiUrl } from "@/lib/webPath";
|
||||
import type {
|
||||
MqClusterInfo,
|
||||
ClusterInfo,
|
||||
TenantInfo,
|
||||
TenantConfig,
|
||||
NamespaceRef,
|
||||
@@ -31,6 +32,8 @@ import type {
|
||||
PeekedMessage,
|
||||
MqRawRequest,
|
||||
MqRawResponse,
|
||||
SendMessageRequest,
|
||||
SendMessageResponse,
|
||||
} from "@/types/mq";
|
||||
|
||||
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
@@ -202,6 +205,14 @@ export async function mqGetBacklog(connectionId: string, topic: TopicRef, sub?:
|
||||
return post("/api/mq/monitoring/backlog", { connectionId, topic, sub });
|
||||
}
|
||||
|
||||
export async function mqGetClusterInfo(connectionId: string): Promise<ClusterInfo> {
|
||||
return post("/api/mq/monitoring/cluster-info", { connectionId });
|
||||
}
|
||||
|
||||
export async function mqRawRequest(connectionId: string, req: MqRawRequest): Promise<MqRawResponse> {
|
||||
return post("/api/mq/raw", { connectionId, req });
|
||||
}
|
||||
|
||||
export async function mqSendMessage(connectionId: string, req: SendMessageRequest): Promise<SendMessageResponse> {
|
||||
return post("/api/mq/send-message", { connectionId, req });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
MqClusterInfo,
|
||||
ClusterInfo,
|
||||
TenantInfo,
|
||||
TenantConfig,
|
||||
NamespaceRef,
|
||||
@@ -31,6 +32,8 @@ import type {
|
||||
PeekedMessage,
|
||||
MqRawRequest,
|
||||
MqRawResponse,
|
||||
SendMessageRequest,
|
||||
SendMessageResponse,
|
||||
} from "@/types/mq";
|
||||
|
||||
// Connectivity
|
||||
@@ -198,7 +201,15 @@ export async function mqGetBacklog(connectionId: string, topic: TopicRef, sub?:
|
||||
return invoke("mq_get_backlog", { connectionId, topic, sub });
|
||||
}
|
||||
|
||||
export async function mqGetClusterInfo(connectionId: string): Promise<ClusterInfo> {
|
||||
return invoke("mq_get_cluster_info", { connectionId });
|
||||
}
|
||||
|
||||
// Raw request
|
||||
export async function mqRawRequest(connectionId: string, req: MqRawRequest): Promise<MqRawResponse> {
|
||||
return invoke("mq_raw_request", { connectionId, req });
|
||||
}
|
||||
|
||||
export async function mqSendMessage(connectionId: string, req: SendMessageRequest): Promise<SendMessageResponse> {
|
||||
return invoke("mq_send_message", { connectionId, req });
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface SavedOpenTab {
|
||||
pinned?: boolean;
|
||||
mode?: QueryTab["mode"];
|
||||
mqTenant?: string;
|
||||
mqInitialTab?: QueryTab["mqInitialTab"];
|
||||
nacosNamespace?: string;
|
||||
nacosNamespaceName?: string;
|
||||
structureTableName?: string;
|
||||
@@ -84,6 +85,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
|
||||
pinned: tab.pinned,
|
||||
mode: tab.mode,
|
||||
...(tab.mqTenant !== undefined ? { mqTenant: tab.mqTenant } : {}),
|
||||
...(tab.mqInitialTab !== undefined ? { mqInitialTab: tab.mqInitialTab } : {}),
|
||||
...(tab.nacosNamespace !== undefined ? { nacosNamespace: tab.nacosNamespace } : {}),
|
||||
...(tab.nacosNamespaceName !== undefined ? { nacosNamespaceName: tab.nacosNamespaceName } : {}),
|
||||
...(tab.structureTableName !== undefined ? { structureTableName: tab.structureTableName } : {}),
|
||||
|
||||
@@ -1589,18 +1589,36 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await ensureConnected(connectionId);
|
||||
if (useCachedChildren(node, options)) return;
|
||||
|
||||
const tenants = await withMetadataLoadTimeout(connectionId, api.mqListTenants(connectionId), "message queue tenants");
|
||||
const tenantNames = sortSidebarNames(tenants.map((tenant) => tenant.name).filter((name) => !!name.trim()));
|
||||
setChildren(
|
||||
node,
|
||||
tenantNames.map((tenant) => ({
|
||||
id: schemaCacheKey(connectionId, "mq-tenant", tenant),
|
||||
label: tenant,
|
||||
type: "mq-tenant" as const,
|
||||
connectionId,
|
||||
mqTenant: tenant,
|
||||
})),
|
||||
);
|
||||
const config = getConfig(connectionId);
|
||||
const isKafka = config?.driver_profile === "kafka";
|
||||
|
||||
if (isKafka) {
|
||||
// Kafka has no tenant/namespace concept. Create a synthetic child
|
||||
// that opens the MQ admin console directly when clicked.
|
||||
setChildren(node, [
|
||||
{
|
||||
id: schemaCacheKey(connectionId, "mq-tenant", "_kafka"),
|
||||
label: "Topics",
|
||||
type: "mq-tenant" as const,
|
||||
connectionId,
|
||||
mqTenant: "_kafka",
|
||||
mqInitialTab: "topics",
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const tenants = await withMetadataLoadTimeout(connectionId, api.mqListTenants(connectionId), "message queue tenants");
|
||||
const tenantNames = sortSidebarNames(tenants.map((tenant) => tenant.name).filter((name) => !!name.trim()));
|
||||
setChildren(
|
||||
node,
|
||||
tenantNames.map((tenant) => ({
|
||||
id: schemaCacheKey(connectionId, "mq-tenant", tenant),
|
||||
label: tenant,
|
||||
type: "mq-tenant" as const,
|
||||
connectionId,
|
||||
mqTenant: tenant,
|
||||
})),
|
||||
);
|
||||
}
|
||||
node.isExpanded = true;
|
||||
} catch (e) {
|
||||
recordMetadataLoadError(connectionId, e);
|
||||
|
||||
@@ -684,10 +684,11 @@ export const useQueryStore = defineStore("query", () => {
|
||||
return id;
|
||||
}
|
||||
|
||||
function openMqAdmin(connectionId: string, target?: { tenant?: string }) {
|
||||
function openMqAdmin(connectionId: string, target?: { tenant?: string; initialTab?: QueryTab["mqInitialTab"] }) {
|
||||
const existing = tabs.value.find((tab) => tab.mode === "mq" && tab.connectionId === connectionId);
|
||||
if (existing) {
|
||||
if (target?.tenant) existing.mqTenant = target.tenant;
|
||||
if (target?.initialTab) existing.mqInitialTab = target.initialTab;
|
||||
activeTabId.value = existing.id;
|
||||
return existing.id;
|
||||
}
|
||||
@@ -705,6 +706,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
isExplaining: false,
|
||||
mode: "mq",
|
||||
mqTenant: target?.tenant,
|
||||
mqInitialTab: target?.initialTab,
|
||||
};
|
||||
tabs.value.push(tab);
|
||||
activeTabId.value = id;
|
||||
@@ -910,6 +912,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
explainExecutionId: undefined,
|
||||
mode: original.mode,
|
||||
mqTenant: original.mqTenant,
|
||||
mqInitialTab: original.mqInitialTab,
|
||||
nacosNamespace: original.nacosNamespace,
|
||||
nacosNamespaceName: original.nacosNamespaceName,
|
||||
structureTableName: original.structureTableName,
|
||||
|
||||
@@ -536,6 +536,7 @@ export interface TreeNode {
|
||||
linkedCatalog?: string;
|
||||
linkedSchema?: string;
|
||||
mqTenant?: string;
|
||||
mqInitialTab?: "topics";
|
||||
nacosNamespace?: string;
|
||||
nacosNamespaceName?: string;
|
||||
schema?: string;
|
||||
@@ -629,6 +630,7 @@ export interface QueryTab {
|
||||
explainExecutionId?: string;
|
||||
mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users";
|
||||
mqTenant?: string;
|
||||
mqInitialTab?: "topics";
|
||||
nacosNamespace?: string;
|
||||
nacosNamespaceName?: string;
|
||||
structureTableName?: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface MqCapabilities {
|
||||
supportsGeoReplication: boolean;
|
||||
supportsTokenManagement: boolean;
|
||||
supportsRawAdminApi: boolean;
|
||||
supportsSendMessage?: boolean;
|
||||
}
|
||||
|
||||
export interface MqClusterInfo {
|
||||
@@ -189,6 +190,22 @@ export interface BacklogStats {
|
||||
backlogSize: number;
|
||||
}
|
||||
|
||||
export interface ClusterInfo {
|
||||
clusterId?: string;
|
||||
brokerCount: number;
|
||||
controllerId?: number;
|
||||
controllerHost?: string;
|
||||
brokers: BrokerNode[];
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface BrokerNode {
|
||||
id: number;
|
||||
host: string;
|
||||
port: number;
|
||||
rack?: string;
|
||||
}
|
||||
|
||||
export interface PeekedMessage {
|
||||
position: number;
|
||||
messageId?: string;
|
||||
@@ -249,3 +266,20 @@ export interface MqRawResponse {
|
||||
body: unknown;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// Send message (produce)
|
||||
export interface SendMessageRequest {
|
||||
topic: string;
|
||||
key?: string;
|
||||
payloadBase64: string;
|
||||
payloadText?: string;
|
||||
headers: Record<string, string>;
|
||||
partition?: number;
|
||||
}
|
||||
|
||||
export interface SendMessageResponse {
|
||||
topic: string;
|
||||
partition: number;
|
||||
offset: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
@@ -1646,6 +1646,13 @@
|
||||
"metadataConnectionScoped": false,
|
||||
"skipTcpProbe": true,
|
||||
"defaultPort": 8080,
|
||||
"driverProfiles": [
|
||||
{
|
||||
"profile": "kafka",
|
||||
"label": "Apache Kafka",
|
||||
"agentKey": "kafka"
|
||||
}
|
||||
],
|
||||
"supportLevel": "connect",
|
||||
"capabilities": {
|
||||
"queryExecution": false,
|
||||
@@ -1663,7 +1670,7 @@
|
||||
"fieldLineage": false,
|
||||
"sqlExplain": false,
|
||||
"userAdmin": false,
|
||||
"driverManagement": false
|
||||
"driverManagement": true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -36,6 +36,8 @@ const MONGODB_PROFILES: &[AgentDriverProfile] = &[AgentDriverProfile {
|
||||
store_visible: false,
|
||||
}];
|
||||
|
||||
const EXTRA_DRIVER_STORE_ENTRIES: &[(&str, &str)] = &[("kafka", "Apache Kafka")];
|
||||
|
||||
const AGENT_CATALOG: &[AgentCatalogEntry] = &[
|
||||
AgentCatalogEntry {
|
||||
db_type: DatabaseType::Dameng,
|
||||
@@ -278,6 +280,9 @@ pub fn entries() -> &'static [AgentCatalogEntry] {
|
||||
}
|
||||
|
||||
pub fn agent_key(db_type: &DatabaseType, driver_profile: Option<&str>) -> Option<&'static str> {
|
||||
if *db_type == DatabaseType::MessageQueue {
|
||||
return (driver_profile == Some("kafka")).then_some("kafka");
|
||||
}
|
||||
let entry = entry_for_db_type(db_type)?;
|
||||
if let Some(driver_profile) = driver_profile {
|
||||
if let Some(profile) = entry.profiles.iter().find(|profile| profile.profile == driver_profile) {
|
||||
@@ -304,10 +309,14 @@ pub fn driver_store_entries() -> impl Iterator<Item = (&'static str, &'static st
|
||||
.map(|profile| (profile.key, profile.label));
|
||||
base.into_iter().chain(profiles)
|
||||
})
|
||||
.chain(EXTRA_DRIVER_STORE_ENTRIES.iter().copied())
|
||||
.filter(move |(key, _)| seen.insert(*key))
|
||||
}
|
||||
|
||||
pub fn label_for_key(agent_key: &str) -> Option<&'static str> {
|
||||
if let Some((_, label)) = EXTRA_DRIVER_STORE_ENTRIES.iter().find(|(key, _)| *key == agent_key) {
|
||||
return Some(label);
|
||||
}
|
||||
for entry in entries() {
|
||||
if entry.key == agent_key {
|
||||
return Some(entry.label);
|
||||
|
||||
@@ -255,17 +255,32 @@ pub fn jre_needs_install(am: &AgentManager, registry: &AgentRegistry, jre_key: &
|
||||
|
||||
pub fn local_agent_jar_candidates(db_type: &str) -> Vec<PathBuf> {
|
||||
let jar_name = format!("dbx-agent-{db_type}.jar");
|
||||
let monorepo_driver =
|
||||
PathBuf::from("agents").join("drivers").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let monorepo_legacy = PathBuf::from("agents").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let relative_driver =
|
||||
PathBuf::from("..").join("dbx-agents").join("drivers").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let nested_driver =
|
||||
PathBuf::from("dbx-agents").join("drivers").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let relative_legacy =
|
||||
PathBuf::from("..").join("dbx-agents").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let nested_legacy = PathBuf::from("dbx-agents").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
vec![monorepo_driver, monorepo_legacy, relative_driver, nested_driver, relative_legacy, nested_legacy]
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
for agents_dir in local_agents_dir_candidates() {
|
||||
candidates.push(agent_driver_jar_path(&agents_dir, db_type, &jar_name));
|
||||
candidates.push(agent_legacy_jar_path(&agents_dir, db_type, &jar_name));
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn local_agents_dir_candidates() -> Vec<PathBuf> {
|
||||
let mut candidates = vec![PathBuf::from("agents"), PathBuf::from("..").join("agents")];
|
||||
if let Some(workspace_root) = PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().and_then(|path| path.parent()) {
|
||||
candidates.push(workspace_root.join("agents"));
|
||||
}
|
||||
candidates.push(PathBuf::from("..").join("dbx-agents"));
|
||||
candidates.push(PathBuf::from("dbx-agents"));
|
||||
candidates
|
||||
}
|
||||
|
||||
fn agent_driver_jar_path(agents_dir: &Path, db_type: &str, jar_name: &str) -> PathBuf {
|
||||
agents_dir.join("drivers").join(db_type).join("build").join("libs").join(jar_name)
|
||||
}
|
||||
|
||||
fn agent_legacy_jar_path(agents_dir: &Path, db_type: &str, jar_name: &str) -> PathBuf {
|
||||
agents_dir.join(db_type).join("build").join("libs").join(jar_name)
|
||||
}
|
||||
|
||||
pub fn find_local_agent_jar(db_type: &str) -> Option<PathBuf> {
|
||||
|
||||
@@ -1018,7 +1018,8 @@ impl AppState {
|
||||
// connectivity via the mq_registry and insert a marker so this
|
||||
// connection_id is recognized as valid.
|
||||
let mqc = self.mq_admin_config_for_connection(connection_id, &config).await?;
|
||||
let adapter = self.mq_registry.build_transient_config(mqc).await?;
|
||||
let kafka_launch = crate::mq::service::resolve_kafka_launch_spec(&mqc, self);
|
||||
let adapter = self.mq_registry.build_transient_config(mqc, kafka_launch).await?;
|
||||
adapter.test_connection().await?;
|
||||
PoolKind::MessageQueue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
//! Apache Kafka admin adapter. Communicates with a Java agent process
|
||||
//! (`KafkaAgent.java`) via JSON-RPC over stdin/stdout. The Java agent uses
|
||||
//! `kafka-clients` AdminClient for admin operations and KafkaProducer for
|
||||
//! message production.
|
||||
//!
|
||||
//! This adapter follows the same pattern as the ZooKeeper/Etcd agents:
|
||||
//! 1. Spawn a Java agent process via `AgentDriverClient`
|
||||
//! 2. Perform JSON-RPC handshake + connect
|
||||
//! 3. Delegate all `MessageQueueAdmin` trait methods to JSON-RPC calls
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::db::agent_driver::{AgentDriverClient, AgentLaunchSpec};
|
||||
use crate::mq::auth::MqAuth;
|
||||
use crate::mq::config::MqAdminConfig;
|
||||
use crate::mq::port::MessageQueueAdmin;
|
||||
use crate::mq::types::*;
|
||||
|
||||
/// Kafka capabilities — no tenants/namespaces, supports topics, consumer groups,
|
||||
/// ACLs, retention, and message production.
|
||||
const KAFKA_CAPABILITIES: MqCapabilities = MqCapabilities {
|
||||
supports_tenants: false,
|
||||
supports_namespaces: false,
|
||||
supports_partitioned_topics: true,
|
||||
supports_subscriptions: true,
|
||||
supports_create_subscription: false,
|
||||
supports_reset_cursor: true,
|
||||
supports_skip_messages: false,
|
||||
supports_clear_backlog: true,
|
||||
supports_peek_messages: true,
|
||||
supports_expire_messages: false,
|
||||
supports_rate_limits: false,
|
||||
supports_backlog_quota: false,
|
||||
supports_retention: true,
|
||||
supports_permissions: true,
|
||||
supports_geo_replication: false,
|
||||
supports_token_management: false,
|
||||
supports_raw_admin_api: false,
|
||||
supports_send_message: true,
|
||||
};
|
||||
|
||||
pub struct KafkaAdmin {
|
||||
client: Arc<Mutex<AgentDriverClient>>,
|
||||
config: MqAdminConfig,
|
||||
}
|
||||
|
||||
impl KafkaAdmin {
|
||||
/// Spawn the Kafka Java agent, perform handshake, and connect.
|
||||
pub async fn new(cfg: MqAdminConfig, launch: AgentLaunchSpec) -> Result<Self, String> {
|
||||
let mut client = AgentDriverClient::spawn(launch).await?;
|
||||
|
||||
// Handshake
|
||||
let _: serde_json::Value = client.call("handshake", serde_json::json!({})).await?;
|
||||
|
||||
// Build the connection params from MqAdminConfig
|
||||
let conn_params = build_connection_params(&cfg);
|
||||
let connect_params = serde_json::json!({ "connection": conn_params });
|
||||
let _: serde_json::Value = client.call("connect", connect_params).await?;
|
||||
|
||||
log::info!("Kafka admin connected via agent (bootstrap servers: {})", bootstrap_servers(&cfg));
|
||||
|
||||
Ok(Self { client: Arc::new(Mutex::new(client)), config: cfg })
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC call to the Kafka agent and deserialize the result.
|
||||
async fn call<T: DeserializeOwned + Send + 'static>(
|
||||
&self,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<T, String> {
|
||||
let mut client = self.client.lock().await;
|
||||
client.call(method, params).await
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC call that returns `{ok: true}` on success.
|
||||
async fn call_ok(&self, method: &str, params: serde_json::Value) -> Result<(), String> {
|
||||
let _: serde_json::Value = self.call(method, params).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MessageQueueAdmin for KafkaAdmin {
|
||||
fn capabilities(&self) -> MqCapabilities {
|
||||
KAFKA_CAPABILITIES
|
||||
}
|
||||
|
||||
fn system_kind(&self) -> MqSystemKind {
|
||||
MqSystemKind::Kafka
|
||||
}
|
||||
|
||||
async fn test_connection(&self) -> Result<MqClusterInfo, String> {
|
||||
let conn_params = build_connection_params(&self.config);
|
||||
let result: serde_json::Value =
|
||||
self.call("test_connection", serde_json::json!({ "connection": conn_params })).await?;
|
||||
|
||||
let cluster_id = result.get("clusterId").and_then(|v| v.as_str()).map(String::from);
|
||||
let brokers = result.get("brokers").cloned().unwrap_or(serde_json::json!([]));
|
||||
|
||||
// When the broker has no authorizer configured, disable permissions in the UI
|
||||
// so the frontend hides the tab instead of showing raw errors.
|
||||
let acl_enabled = result.get("aclEnabled").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let mut caps = KAFKA_CAPABILITIES;
|
||||
if !acl_enabled {
|
||||
caps.supports_permissions = false;
|
||||
}
|
||||
|
||||
Ok(MqClusterInfo {
|
||||
system_kind: MqSystemKind::Kafka,
|
||||
server_version: None,
|
||||
resolved_profile: "kafka-agent".to_string(),
|
||||
version_detection: "agent".to_string(),
|
||||
capabilities: caps,
|
||||
extra: serde_json::json!({
|
||||
"clusterId": cluster_id,
|
||||
"brokers": brokers,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Tenants (not supported by Kafka) ----
|
||||
|
||||
async fn list_tenants(&self) -> Result<Vec<TenantInfo>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_tenant(&self, _name: &str) -> Result<TenantInfo, String> {
|
||||
Err("Kafka does not support tenants".to_string())
|
||||
}
|
||||
|
||||
async fn create_tenant(&self, _name: &str, _cfg: TenantConfig) -> Result<(), String> {
|
||||
Err("Kafka does not support tenants".to_string())
|
||||
}
|
||||
|
||||
async fn update_tenant(&self, _name: &str, _cfg: TenantConfig) -> Result<(), String> {
|
||||
Err("Kafka does not support tenants".to_string())
|
||||
}
|
||||
|
||||
async fn delete_tenant(&self, _name: &str, _force: bool) -> Result<(), String> {
|
||||
Err("Kafka does not support tenants".to_string())
|
||||
}
|
||||
|
||||
// ---- Namespaces (not supported by Kafka) ----
|
||||
|
||||
async fn list_namespaces(&self, _tenant: &str) -> Result<Vec<NamespaceInfo>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn create_namespace(&self, _ns: &NamespaceRef, _cfg: NamespaceConfig) -> Result<(), String> {
|
||||
Err("Kafka does not support namespaces".to_string())
|
||||
}
|
||||
|
||||
async fn delete_namespace(&self, _ns: &NamespaceRef, _force: bool) -> Result<(), String> {
|
||||
Err("Kafka does not support namespaces".to_string())
|
||||
}
|
||||
|
||||
async fn get_namespace_policies(&self, _ns: &NamespaceRef) -> Result<serde_json::Value, String> {
|
||||
Err("Kafka does not support namespaces".to_string())
|
||||
}
|
||||
|
||||
// ---- Topics ----
|
||||
|
||||
async fn list_topics(&self, _ns: &NamespaceRef, _opts: ListTopicsOpts) -> Result<Vec<TopicInfo>, String> {
|
||||
let result: serde_json::Value = self.call("mq_list_topics", serde_json::json!({})).await?;
|
||||
let topics = result.get("topics").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
|
||||
Ok(topics
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let name = t.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
let partitions = t.get("partitions").and_then(|v| v.as_u64()).map(|v| v as u32);
|
||||
TopicInfo {
|
||||
name: name.clone(),
|
||||
short_name: name,
|
||||
partitioned: partitions.map(|p| p > 1).unwrap_or(false),
|
||||
partitions,
|
||||
persistent: true,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn create_topic(&self, topic: &TopicRef, partitions: Option<u32>) -> Result<(), String> {
|
||||
let params = serde_json::json!({
|
||||
"name": topic.topic,
|
||||
"partitions": partitions.unwrap_or(1),
|
||||
"replicationFactor": 1,
|
||||
});
|
||||
self.call_ok("mq_create_topic", params).await
|
||||
}
|
||||
|
||||
async fn delete_topic(&self, topic: &TopicRef, _force: bool) -> Result<(), String> {
|
||||
self.call_ok("mq_delete_topic", serde_json::json!({ "name": topic.topic })).await
|
||||
}
|
||||
|
||||
async fn update_partitions(&self, topic: &TopicRef, partitions: u32) -> Result<(), String> {
|
||||
self.call_ok(
|
||||
"mq_update_partitions",
|
||||
serde_json::json!({
|
||||
"name": topic.topic,
|
||||
"totalPartitions": partitions,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_topic_stats(&self, topic: &TopicRef) -> Result<TopicStats, String> {
|
||||
let result: serde_json::Value =
|
||||
self.call("mq_get_topic_stats", serde_json::json!({ "name": topic.topic })).await?;
|
||||
|
||||
let total_messages = result.get("totalMessages").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let _partitions = result.get("partitions").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
|
||||
Ok(TopicStats {
|
||||
msg_rate_in: 0.0,
|
||||
msg_rate_out: 0.0,
|
||||
msg_throughput_in: 0.0,
|
||||
msg_throughput_out: 0.0,
|
||||
storage_size: 0,
|
||||
backlog_size: 0,
|
||||
msg_in_counter: total_messages,
|
||||
msg_out_counter: 0,
|
||||
subscription_count: 0,
|
||||
producer_count: 0,
|
||||
raw: result,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_topic_internal_stats(&self, topic: &TopicRef) -> Result<serde_json::Value, String> {
|
||||
self.call("mq_get_topic_config", serde_json::json!({ "name": topic.topic })).await
|
||||
}
|
||||
|
||||
// ---- Subscriptions (mapped to consumer groups) ----
|
||||
|
||||
async fn list_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String> {
|
||||
// List consumer groups and filter those subscribed to this topic
|
||||
let result: serde_json::Value = self.call("mq_list_consumer_groups", serde_json::json!({})).await?;
|
||||
let groups = result.get("groups").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
|
||||
// For each group, check both active assignments and committed offsets.
|
||||
let mut subs = Vec::new();
|
||||
for group in groups {
|
||||
let group_id = group.get("groupId").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let desc = match self
|
||||
.call::<serde_json::Value>("mq_describe_consumer_group", serde_json::json!({ "groupId": group_id }))
|
||||
.await
|
||||
{
|
||||
Ok(desc) => desc,
|
||||
Err(_) => continue, // Skip groups we can't describe
|
||||
};
|
||||
let lag = self
|
||||
.call::<serde_json::Value>(
|
||||
"mq_get_consumer_lag",
|
||||
serde_json::json!({
|
||||
"groupId": group_id,
|
||||
"topic": topic.topic,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
if let Some(sub) = kafka_subscription_for_topic(group_id, &topic.topic, &desc, lag.as_ref()) {
|
||||
subs.push(sub);
|
||||
}
|
||||
}
|
||||
Ok(subs)
|
||||
}
|
||||
|
||||
async fn create_subscription(&self, _topic: &TopicRef, _sub: &str, _pos: ResetPosition) -> Result<(), String> {
|
||||
Err("Kafka consumer groups are created automatically when consumers join".to_string())
|
||||
}
|
||||
|
||||
async fn delete_subscription(&self, _topic: &TopicRef, sub: &str, _force: bool) -> Result<(), String> {
|
||||
self.call_ok("mq_delete_consumer_group", serde_json::json!({ "groupId": sub })).await
|
||||
}
|
||||
|
||||
async fn skip_messages(&self, _topic: &TopicRef, _sub: &str, _count: SkipCount) -> Result<(), String> {
|
||||
Err("Kafka does not support skipping messages directly".to_string())
|
||||
}
|
||||
|
||||
async fn reset_cursor(&self, topic: &TopicRef, sub: &str, pos: ResetPosition) -> Result<(), String> {
|
||||
let params = reset_cursor_params(topic, sub, pos)?;
|
||||
self.call_ok("mq_reset_consumer_group_offsets", params).await
|
||||
}
|
||||
|
||||
async fn clear_backlog(&self, topic: &TopicRef, sub: &str) -> Result<(), String> {
|
||||
// Clearing backlog = resetting offsets to latest
|
||||
self.call_ok(
|
||||
"mq_reset_consumer_group_offsets",
|
||||
serde_json::json!({
|
||||
"groupId": sub,
|
||||
"topic": topic.topic,
|
||||
"position": "latest",
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn peek_messages(&self, topic: &TopicRef, _sub: &str, count: u32) -> Result<Vec<PeekedMessage>, String> {
|
||||
let conn_params = build_connection_params(&self.config);
|
||||
let result: serde_json::Value = self
|
||||
.call(
|
||||
"mq_peek_messages",
|
||||
serde_json::json!({
|
||||
"topic": topic.topic,
|
||||
"partition": 0,
|
||||
"offset": 0,
|
||||
"count": count,
|
||||
"connection": conn_params,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let messages = result.get("messages").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
Ok(messages
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, m)| PeekedMessage {
|
||||
position: (idx + 1) as u32,
|
||||
message_id: m.get("offset").and_then(|v| v.as_i64()).map(|v| v.to_string()),
|
||||
key: m.get("key").and_then(|v| v.as_str()).map(String::from),
|
||||
publish_time: m.get("timestamp").and_then(|v| v.as_i64()).map(|v| v.to_string()),
|
||||
event_time: None,
|
||||
properties: HashMap::new(),
|
||||
headers: m
|
||||
.get("headers")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string())).collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
payload_base64: m.get("payloadBase64").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
payload_text: m.get("payloadText").and_then(|v| v.as_str()).map(String::from),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn expire_messages(&self, _topic: &TopicRef, _sub: &str, _expire_seconds: i64) -> Result<(), String> {
|
||||
Err("Kafka does not support expiring messages on a subscription".to_string())
|
||||
}
|
||||
|
||||
// ---- Producers / consumers ----
|
||||
|
||||
async fn list_producers(&self, topic: &TopicRef) -> Result<Vec<ProducerInfo>, String> {
|
||||
let result: serde_json::Value =
|
||||
self.call("mq_list_producers", serde_json::json!({ "topic": topic.topic })).await?;
|
||||
let producers = result.get("producers").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
Ok(producers
|
||||
.into_iter()
|
||||
.map(|p| ProducerInfo {
|
||||
producer_id: p.get("producerId").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
producer_name: p.get("producerName").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
msg_rate_in: p.get("msgRateIn").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
msg_throughput_in: p.get("msgThroughputIn").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
address: p.get("address").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
client_version: p.get("clientVersion").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_consumers(&self, _topic: &TopicRef, sub: &str) -> Result<Vec<ConsumerInfo>, String> {
|
||||
let result: serde_json::Value =
|
||||
self.call("mq_describe_consumer_group", serde_json::json!({ "groupId": sub })).await?;
|
||||
|
||||
let members = result.get("members").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
Ok(members
|
||||
.into_iter()
|
||||
.map(|m| ConsumerInfo {
|
||||
consumer_name: m.get("memberId").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
msg_rate_out: 0.0,
|
||||
msg_throughput_out: 0.0,
|
||||
available_permits: 0,
|
||||
address: m.get("host").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
client_version: String::new(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn unload_topic(&self, _topic: &TopicRef) -> Result<(), String> {
|
||||
Err("Kafka does not support unloading topics".to_string())
|
||||
}
|
||||
|
||||
// ---- Rate limits / quotas / retention ----
|
||||
|
||||
async fn set_publish_rate(&self, _scope: &PolicyScope, _rate: PublishRate) -> Result<(), String> {
|
||||
Err("Kafka does not support publish rate limits via AdminClient".to_string())
|
||||
}
|
||||
|
||||
async fn set_dispatch_rate(&self, _scope: &PolicyScope, _rate: DispatchRate) -> Result<(), String> {
|
||||
Err("Kafka does not support dispatch rate limits via AdminClient".to_string())
|
||||
}
|
||||
|
||||
async fn set_subscribe_rate(&self, _scope: &PolicyScope, _rate: SubscribeRate) -> Result<(), String> {
|
||||
Err("Kafka does not support subscribe rate limits via AdminClient".to_string())
|
||||
}
|
||||
|
||||
async fn set_backlog_quota(&self, _scope: &PolicyScope, _quota: BacklogQuota) -> Result<(), String> {
|
||||
Err("Kafka does not support backlog quotas via AdminClient".to_string())
|
||||
}
|
||||
|
||||
async fn set_retention(&self, scope: &PolicyScope, retention: RetentionPolicy) -> Result<(), String> {
|
||||
let topic_name = match scope {
|
||||
PolicyScope::Topic { topic, .. } => topic.clone(),
|
||||
PolicyScope::Namespace { .. } => return Err("Kafka retention can only be set on topics".to_string()),
|
||||
};
|
||||
|
||||
let retention_ms = if retention.retention_time_in_minutes < 0 {
|
||||
"-1".to_string()
|
||||
} else {
|
||||
(retention.retention_time_in_minutes as i64 * 60 * 1000).to_string()
|
||||
};
|
||||
|
||||
let mut configs = vec![serde_json::json!({ "key": "retention.ms", "value": retention_ms })];
|
||||
if retention.retention_size_in_mb >= 0 {
|
||||
let retention_bytes = (retention.retention_size_in_mb as i64 * 1024 * 1024).to_string();
|
||||
configs.push(serde_json::json!({ "key": "retention.bytes", "value": retention_bytes }));
|
||||
}
|
||||
|
||||
self.call_ok(
|
||||
"mq_alter_topic_config",
|
||||
serde_json::json!({
|
||||
"name": topic_name,
|
||||
"configs": configs,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_effective_policies(&self, scope: &PolicyScope) -> Result<serde_json::Value, String> {
|
||||
let topic_name = match scope {
|
||||
PolicyScope::Topic { topic, .. } => topic.clone(),
|
||||
PolicyScope::Namespace { .. } => return Err("Kafka does not support namespace policies".to_string()),
|
||||
};
|
||||
self.call("mq_get_topic_config", serde_json::json!({ "name": topic_name })).await
|
||||
}
|
||||
|
||||
// ---- Permissions (mapped to Kafka ACLs) ----
|
||||
|
||||
async fn grant_permission(&self, scope: &PolicyScope, role: &str, actions: Vec<AuthAction>) -> Result<(), String> {
|
||||
let (resource_type, resource_name) = match scope {
|
||||
PolicyScope::Topic { topic, .. } => ("TOPIC", topic.clone()),
|
||||
PolicyScope::Namespace { .. } => ("TOPIC", "*".to_string()),
|
||||
};
|
||||
|
||||
let acls: Vec<serde_json::Value> = actions
|
||||
.into_iter()
|
||||
.map(|action| {
|
||||
let operation = match action {
|
||||
AuthAction::Produce => "WRITE",
|
||||
AuthAction::Consume => "READ",
|
||||
_ => "ALL",
|
||||
};
|
||||
serde_json::json!({
|
||||
"resourceType": resource_type,
|
||||
"resourceName": resource_name,
|
||||
"patternType": "LITERAL",
|
||||
"principal": format!("User:{}", role),
|
||||
"host": "*",
|
||||
"operation": operation,
|
||||
"permissionType": "ALLOW",
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.call_ok("mq_create_acls", serde_json::json!({ "acls": acls })).await
|
||||
}
|
||||
|
||||
async fn revoke_permission(&self, scope: &PolicyScope, role: &str) -> Result<(), String> {
|
||||
let (resource_type, resource_name) = match scope {
|
||||
PolicyScope::Topic { topic, .. } => ("TOPIC", topic.clone()),
|
||||
PolicyScope::Namespace { .. } => ("TOPIC", "*".to_string()),
|
||||
};
|
||||
|
||||
self.call_ok(
|
||||
"mq_delete_acls",
|
||||
serde_json::json!({
|
||||
"filters": [{
|
||||
"resourceType": resource_type,
|
||||
"resourceName": resource_name,
|
||||
"principal": format!("User:{}", role),
|
||||
}]
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_permissions(&self, scope: &PolicyScope) -> Result<PermissionMap, String> {
|
||||
let (resource_type, resource_name) = match scope {
|
||||
PolicyScope::Topic { topic, .. } => ("TOPIC", topic.clone()),
|
||||
PolicyScope::Namespace { .. } => ("TOPIC", "*".to_string()),
|
||||
};
|
||||
|
||||
let result: serde_json::Value = self
|
||||
.call(
|
||||
"mq_list_acls",
|
||||
serde_json::json!({
|
||||
"resourceType": resource_type,
|
||||
"resourceName": resource_name,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let acls = result.get("acls").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
let mut permissions: PermissionMap = HashMap::new();
|
||||
|
||||
for acl in acls {
|
||||
let principal = acl.get("principal").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let role = principal.strip_prefix("User:").unwrap_or(principal).to_string();
|
||||
let operation = acl.get("operation").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let action = match operation {
|
||||
"WRITE" => AuthAction::Produce,
|
||||
"READ" => AuthAction::Consume,
|
||||
_ => continue,
|
||||
};
|
||||
permissions.entry(role).or_default().push(action);
|
||||
}
|
||||
Ok(permissions)
|
||||
}
|
||||
|
||||
// ---- Monitoring ----
|
||||
|
||||
async fn get_backlog(&self, topic: &TopicRef, sub: Option<&str>) -> Result<BacklogStats, String> {
|
||||
let group_id = sub.ok_or("Consumer group name (subscription) is required for Kafka backlog")?;
|
||||
let result: serde_json::Value = self
|
||||
.call(
|
||||
"mq_get_consumer_lag",
|
||||
serde_json::json!({
|
||||
"groupId": group_id,
|
||||
"topic": topic.topic,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let total_lag = result.get("totalLag").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
Ok(BacklogStats { msg_backlog: total_lag, backlog_size: 0 })
|
||||
}
|
||||
|
||||
async fn get_cluster_info(&self) -> Result<ClusterInfo, String> {
|
||||
let result: serde_json::Value = self.call("mq_describe_cluster", serde_json::json!({})).await?;
|
||||
|
||||
let cluster_id = result.get("clusterId").and_then(|v| v.as_str()).map(String::from);
|
||||
let broker_count = result.get("nodeCount").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
|
||||
let controller = result.get("controller").filter(|v| !v.is_null());
|
||||
let controller_id = controller.and_then(|v| v.get("id")).and_then(|v| v.as_i64()).map(|v| v as i32);
|
||||
let controller_host = controller.and_then(|v| v.get("host")).and_then(|v| v.as_str()).map(|host| {
|
||||
let port = controller.and_then(|v| v.get("port")).and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
format!("{}:{}", host, port)
|
||||
});
|
||||
|
||||
let brokers = result
|
||||
.get("brokers")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|node| {
|
||||
Some(BrokerNode {
|
||||
id: node.get("id")?.as_i64()? as i32,
|
||||
host: node.get("host")?.as_str()?.to_string(),
|
||||
port: node.get("port")?.as_i64()? as i32,
|
||||
rack: node.get("rack").and_then(|v| v.as_str()).map(String::from),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(ClusterInfo { cluster_id, broker_count, controller_id, controller_host, brokers, raw: result })
|
||||
}
|
||||
|
||||
// ---- Raw request (not supported for Kafka) ----
|
||||
|
||||
async fn raw_request(&self, _req: MqRawRequest) -> Result<MqRawResponse, String> {
|
||||
Err("Kafka does not have a REST admin API; raw requests are not supported".to_string())
|
||||
}
|
||||
|
||||
// ---- Message production ----
|
||||
|
||||
async fn send_message(&self, req: SendMessageRequest) -> Result<SendMessageResponse, String> {
|
||||
let params = serde_json::json!({
|
||||
"topic": req.topic,
|
||||
"key": req.key,
|
||||
"payloadBase64": req.payload_base64,
|
||||
"headers": req.headers,
|
||||
"partition": req.partition,
|
||||
});
|
||||
let result: serde_json::Value = self.call("mq_send_message", params).await?;
|
||||
|
||||
Ok(SendMessageResponse {
|
||||
topic: result.get("topic").and_then(|v| v.as_str()).unwrap_or(&req.topic).to_string(),
|
||||
partition: result.get("partition").and_then(|v| v.as_i64()).unwrap_or(0) as i32,
|
||||
offset: result.get("offset").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
timestamp: result.get("timestamp").and_then(|v| v.as_i64()).map(|v| v.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract Kafka bootstrap servers from MqAdminConfig.extra.
|
||||
fn bootstrap_servers(cfg: &MqAdminConfig) -> String {
|
||||
cfg.extra.get("bootstrapServers").and_then(|v| v.as_str()).unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
fn extra_str<'a>(extra: &'a serde_json::Value, key: &str) -> Option<&'a str> {
|
||||
extra.get(key).and_then(|v| v.as_str()).filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Build the connection params JSON from MqAdminConfig for the Java agent.
|
||||
fn build_connection_params(cfg: &MqAdminConfig) -> serde_json::Value {
|
||||
let extra = &cfg.extra;
|
||||
let basic_auth = match &cfg.auth {
|
||||
MqAuth::Basic { username, password } => Some((username.as_str(), password.as_str())),
|
||||
_ => None,
|
||||
};
|
||||
let sasl_username =
|
||||
extra_str(extra, "saslUsername").or_else(|| basic_auth.map(|(username, _)| username)).unwrap_or("");
|
||||
let sasl_password =
|
||||
extra_str(extra, "saslPassword").or_else(|| basic_auth.map(|(_, password)| password)).unwrap_or("");
|
||||
let sasl_mechanism = extra_str(extra, "saslMechanism").unwrap_or(if basic_auth.is_some() { "PLAIN" } else { "" });
|
||||
let security_protocol =
|
||||
extra_str(extra, "securityProtocol").unwrap_or(if !sasl_mechanism.is_empty() { "SASL_PLAINTEXT" } else { "" });
|
||||
let properties =
|
||||
extra.get("properties").filter(|value| value.is_object()).cloned().unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
serde_json::json!({
|
||||
"bootstrap_servers": bootstrap_servers(cfg),
|
||||
"security_protocol": security_protocol,
|
||||
"sasl_mechanism": sasl_mechanism,
|
||||
"sasl_username": sasl_username,
|
||||
"sasl_password": sasl_password,
|
||||
"tls_skip_verify": cfg.tls_skip_verify,
|
||||
"tls": {
|
||||
"skip_verify": cfg.tls_skip_verify,
|
||||
},
|
||||
"properties": properties,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset_cursor_params(topic: &TopicRef, sub: &str, pos: ResetPosition) -> Result<serde_json::Value, String> {
|
||||
match pos {
|
||||
ResetPosition::Earliest => Ok(serde_json::json!({
|
||||
"groupId": sub,
|
||||
"topic": topic.topic,
|
||||
"position": "earliest",
|
||||
})),
|
||||
ResetPosition::Latest => Ok(serde_json::json!({
|
||||
"groupId": sub,
|
||||
"topic": topic.topic,
|
||||
"position": "latest",
|
||||
})),
|
||||
ResetPosition::Timestamp { timestamp_ms } => Ok(serde_json::json!({
|
||||
"groupId": sub,
|
||||
"topic": topic.topic,
|
||||
"position": "timestamp",
|
||||
"timestampMs": timestamp_ms,
|
||||
})),
|
||||
ResetPosition::MessageId { .. } => Err("Kafka does not support cursor reset by Pulsar message id".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn kafka_subscription_for_topic(
|
||||
group_id: &str,
|
||||
topic: &str,
|
||||
desc: &serde_json::Value,
|
||||
lag: Option<&serde_json::Value>,
|
||||
) -> Option<SubscriptionInfo> {
|
||||
let has_active_assignment = desc
|
||||
.get("members")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|members| {
|
||||
members.iter().any(|member| {
|
||||
member
|
||||
.get("assignments")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|assignments| {
|
||||
assignments.iter().any(|a| a.get("topic").and_then(|v| v.as_str()) == Some(topic))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let has_committed_offsets = lag
|
||||
.and_then(|v| v.get("partitions"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|partitions| !partitions.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_active_assignment && !has_committed_offsets {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(SubscriptionInfo {
|
||||
name: group_id.to_string(),
|
||||
sub_type: "consumer-group".to_string(),
|
||||
msg_backlog: lag.and_then(|v| v.get("totalLag")).and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
msg_rate_out: 0.0,
|
||||
msg_throughput_out: 0.0,
|
||||
consumers: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mq::auth::MqAuth;
|
||||
use crate::mq::types::MqSystemKind;
|
||||
|
||||
fn kafka_config(extra: serde_json::Value, auth: MqAuth, tls_skip_verify: bool) -> MqAdminConfig {
|
||||
MqAdminConfig {
|
||||
system_kind: MqSystemKind::Kafka,
|
||||
admin_url: String::new(),
|
||||
auth,
|
||||
tls_skip_verify,
|
||||
pinned_version: None,
|
||||
token_signing: None,
|
||||
connect_override: None,
|
||||
extra,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_params_map_basic_auth_to_kafka_sasl_without_plaintext_password_extra() {
|
||||
let cfg = kafka_config(
|
||||
serde_json::json!({
|
||||
"bootstrapServers": "localhost:9092"
|
||||
}),
|
||||
MqAuth::Basic { username: "alice".to_string(), password: "secret".to_string() },
|
||||
false,
|
||||
);
|
||||
|
||||
let params = build_connection_params(&cfg);
|
||||
|
||||
assert_eq!(params.get("bootstrap_servers").and_then(|v| v.as_str()), Some("localhost:9092"));
|
||||
assert_eq!(params.get("security_protocol").and_then(|v| v.as_str()), Some("SASL_PLAINTEXT"));
|
||||
assert_eq!(params.get("sasl_mechanism").and_then(|v| v.as_str()), Some("PLAIN"));
|
||||
assert_eq!(params.get("sasl_username").and_then(|v| v.as_str()), Some("alice"));
|
||||
assert_eq!(params.get("sasl_password").and_then(|v| v.as_str()), Some("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_params_preserve_kafka_security_extra_and_nested_tls() {
|
||||
let cfg = kafka_config(
|
||||
serde_json::json!({
|
||||
"bootstrapServers": "broker:9093",
|
||||
"securityProtocol": "SASL_SSL",
|
||||
"saslMechanism": "SCRAM-SHA-512",
|
||||
"properties": {
|
||||
"client.id": "dbx"
|
||||
}
|
||||
}),
|
||||
MqAuth::Basic { username: "bob".to_string(), password: "pw".to_string() },
|
||||
true,
|
||||
);
|
||||
|
||||
let params = build_connection_params(&cfg);
|
||||
|
||||
assert_eq!(params.get("security_protocol").and_then(|v| v.as_str()), Some("SASL_SSL"));
|
||||
assert_eq!(params.get("sasl_mechanism").and_then(|v| v.as_str()), Some("SCRAM-SHA-512"));
|
||||
assert_eq!(params.pointer("/tls/skip_verify").and_then(|v| v.as_bool()), Some(true));
|
||||
assert_eq!(params.pointer("/properties/client.id").and_then(|v| v.as_str()), Some("dbx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_cursor_params_preserve_timestamp_position() {
|
||||
let topic = TopicRef {
|
||||
tenant: "_kafka".to_string(),
|
||||
namespace: "_kafka".to_string(),
|
||||
topic: "events".to_string(),
|
||||
persistent: true,
|
||||
partitioned: None,
|
||||
};
|
||||
|
||||
let params = reset_cursor_params(&topic, "group-a", ResetPosition::Timestamp { timestamp_ms: 1710000000000 })
|
||||
.expect("timestamp reset should be supported");
|
||||
|
||||
assert_eq!(params.get("groupId").and_then(|v| v.as_str()), Some("group-a"));
|
||||
assert_eq!(params.get("topic").and_then(|v| v.as_str()), Some("events"));
|
||||
assert_eq!(params.get("position").and_then(|v| v.as_str()), Some("timestamp"));
|
||||
assert_eq!(params.get("timestampMs").and_then(|v| v.as_i64()), Some(1710000000000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_cursor_params_reject_message_id_position() {
|
||||
let topic = TopicRef {
|
||||
tenant: "_kafka".to_string(),
|
||||
namespace: "_kafka".to_string(),
|
||||
topic: "events".to_string(),
|
||||
persistent: true,
|
||||
partitioned: None,
|
||||
};
|
||||
|
||||
let err = reset_cursor_params(&topic, "group-a", ResetPosition::MessageId { ledger_id: 1, entry_id: 2 })
|
||||
.expect_err("Kafka should not accept Pulsar message ids");
|
||||
|
||||
assert!(err.contains("message id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kafka_subscription_for_topic_includes_offline_group_with_committed_offsets() {
|
||||
let desc = serde_json::json!({
|
||||
"groupId": "orders-service",
|
||||
"members": []
|
||||
});
|
||||
let lag = serde_json::json!({
|
||||
"totalLag": 7,
|
||||
"partitions": [
|
||||
{ "partition": 0, "currentOffset": 3, "endOffset": 10, "lag": 7 }
|
||||
]
|
||||
});
|
||||
|
||||
let sub = kafka_subscription_for_topic("orders-service", "orders", &desc, Some(&lag))
|
||||
.expect("committed offsets should make an inactive group visible");
|
||||
|
||||
assert_eq!(sub.name, "orders-service");
|
||||
assert_eq!(sub.sub_type, "consumer-group");
|
||||
assert_eq!(sub.msg_backlog, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kafka_subscription_for_topic_includes_active_assignment_without_committed_offsets() {
|
||||
let desc = serde_json::json!({
|
||||
"groupId": "live-service",
|
||||
"members": [{
|
||||
"assignments": [
|
||||
{ "topic": "events", "partition": 0 }
|
||||
]
|
||||
}]
|
||||
});
|
||||
let lag = serde_json::json!({
|
||||
"totalLag": 0,
|
||||
"partitions": []
|
||||
});
|
||||
|
||||
let sub = kafka_subscription_for_topic("live-service", "events", &desc, Some(&lag))
|
||||
.expect("active assignments should make the group visible");
|
||||
|
||||
assert_eq!(sub.name, "live-service");
|
||||
assert_eq!(sub.msg_backlog, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kafka_subscription_for_topic_ignores_unrelated_group() {
|
||||
let desc = serde_json::json!({
|
||||
"groupId": "billing-service",
|
||||
"members": [{
|
||||
"assignments": [
|
||||
{ "topic": "billing", "partition": 0 }
|
||||
]
|
||||
}]
|
||||
});
|
||||
let lag = serde_json::json!({
|
||||
"totalLag": 0,
|
||||
"partitions": []
|
||||
});
|
||||
|
||||
assert!(kafka_subscription_for_topic("billing-service", "orders", &desc, Some(&lag)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Concrete message queue admin adapters.
|
||||
|
||||
pub mod kafka;
|
||||
pub mod pulsar;
|
||||
pub mod pulsar_version;
|
||||
|
||||
@@ -139,6 +139,7 @@ impl PulsarApiProfile {
|
||||
// Token signing is NOT a broker REST capability — see chapter 11.
|
||||
supports_token_management: false,
|
||||
supports_raw_admin_api: true,
|
||||
supports_send_message: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,9 @@ impl MqAdminConfig {
|
||||
let mut parsed: MqAdminConfig = serde_json::from_value(raw.clone())
|
||||
.map_err(|e| format!("Failed to parse message queue admin config: {e}"))?;
|
||||
parsed.admin_url = parsed.admin_url.trim().to_string();
|
||||
if parsed.admin_url.is_empty() {
|
||||
// Kafka uses bootstrap servers from `extra` instead of an admin URL,
|
||||
// so allow an empty admin_url for Kafka connections.
|
||||
if parsed.admin_url.is_empty() && parsed.system_kind != MqSystemKind::Kafka {
|
||||
return Err("Message queue admin URL is empty".to_string());
|
||||
}
|
||||
Ok(parsed)
|
||||
@@ -175,6 +177,22 @@ mod tests {
|
||||
assert!(MqAdminConfig::from_connection(&cfg).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_kafka_config_with_empty_admin_url() {
|
||||
let cfg = connection_with_external(serde_json::json!({
|
||||
"systemKind": "kafka",
|
||||
"adminUrl": "",
|
||||
"auth": { "kind": "none" },
|
||||
"extra": {
|
||||
"bootstrapServers": "broker1:9092,broker2:9092"
|
||||
}
|
||||
}));
|
||||
let mqc = MqAdminConfig::from_connection(&cfg).expect("should parse valid Kafka config");
|
||||
assert_eq!(mqc.system_kind, MqSystemKind::Kafka);
|
||||
assert_eq!(mqc.admin_url, "");
|
||||
assert_eq!(mqc.extra.get("bootstrapServers").and_then(|v| v.as_str()), Some("broker1:9092,broker2:9092"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_url_with_endpoint_preserves_scheme_path_and_query() {
|
||||
let rewritten =
|
||||
|
||||
@@ -24,7 +24,9 @@ use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use crate::db::agent_driver::AgentLaunchSpec;
|
||||
use crate::models::connection::ConnectionConfig;
|
||||
use crate::mq::adapters::kafka::KafkaAdmin;
|
||||
use crate::mq::adapters::pulsar::PulsarAdmin;
|
||||
use crate::mq::config::MqAdminConfig;
|
||||
use crate::mq::port::MessageQueueAdmin;
|
||||
@@ -55,13 +57,14 @@ impl MqAdminRegistry {
|
||||
/// connection's `external_config` if not already present.
|
||||
pub async fn get_or_build(&self, cfg: &ConnectionConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
let mqc = MqAdminConfig::from_connection(cfg)?;
|
||||
self.get_or_build_config(&cfg.id, mqc).await
|
||||
self.get_or_build_config(&cfg.id, mqc, None).await
|
||||
}
|
||||
|
||||
pub async fn get_or_build_config(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
mqc: MqAdminConfig,
|
||||
kafka_launch: Option<AgentLaunchSpec>,
|
||||
) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
// Fast path: return the cached adapter.
|
||||
if let Some(adapter) = self.instances.read().await.get(connection_id) {
|
||||
@@ -81,7 +84,7 @@ impl MqAdminRegistry {
|
||||
return Ok(adapter.clone());
|
||||
}
|
||||
|
||||
let adapter = build_adapter(mqc).await?;
|
||||
let adapter = build_adapter(mqc, kafka_launch).await?;
|
||||
self.instances.write().await.insert(connection_id.to_string(), adapter.clone());
|
||||
Ok(adapter)
|
||||
}
|
||||
@@ -96,21 +99,33 @@ impl MqAdminRegistry {
|
||||
/// where we don't want to retain state.
|
||||
pub async fn build_transient(&self, cfg: &ConnectionConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
let mqc = MqAdminConfig::from_connection(cfg)?;
|
||||
self.build_transient_config(mqc).await
|
||||
self.build_transient_config(mqc, None).await
|
||||
}
|
||||
|
||||
pub async fn build_transient_config(&self, mqc: MqAdminConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
build_adapter(mqc).await
|
||||
pub async fn build_transient_config(
|
||||
&self,
|
||||
mqc: MqAdminConfig,
|
||||
kafka_launch: Option<AgentLaunchSpec>,
|
||||
) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
build_adapter(mqc, kafka_launch).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_adapter(mqc: MqAdminConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
async fn build_adapter(
|
||||
mqc: MqAdminConfig,
|
||||
kafka_launch: Option<AgentLaunchSpec>,
|
||||
) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
match mqc.system_kind {
|
||||
MqSystemKindInternal::Pulsar => {
|
||||
let adapter = PulsarAdmin::new(mqc).await?;
|
||||
Ok(Arc::new(adapter))
|
||||
}
|
||||
MqSystemKindInternal::Kafka => Err("Kafka admin is not yet implemented".to_string()),
|
||||
MqSystemKindInternal::Kafka => {
|
||||
let launch = kafka_launch
|
||||
.ok_or("Kafka adapter requires an agent launch spec. The Kafka agent driver is not installed or not configured.")?;
|
||||
let adapter = KafkaAdmin::new(mqc, launch).await?;
|
||||
Ok(Arc::new(adapter))
|
||||
}
|
||||
MqSystemKindInternal::RocketMq => Err("RocketMQ admin is not yet implemented".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,20 @@ pub trait MessageQueueAdmin: Send + Sync {
|
||||
// ---- Monitoring ----
|
||||
async fn get_backlog(&self, topic: &TopicRef, sub: Option<&str>) -> Result<BacklogStats, String>;
|
||||
|
||||
/// Cluster-level info for the Broker monitoring panel.
|
||||
async fn get_cluster_info(&self) -> Result<ClusterInfo, String> {
|
||||
Err("Cluster info is not supported by this MQ system".to_string())
|
||||
}
|
||||
|
||||
/// Escape hatch: proxy an arbitrary admin REST call. Covers any endpoint the
|
||||
/// typed methods do not.
|
||||
async fn raw_request(&self, req: MqRawRequest) -> Result<MqRawResponse, String>;
|
||||
|
||||
// ---- Message production ----
|
||||
|
||||
/// Produce a message to a topic. Adapters that do not support message
|
||||
/// production (e.g. admin-only systems) return an error by default.
|
||||
async fn send_message(&self, _req: SendMessageRequest) -> Result<SendMessageResponse, String> {
|
||||
Err("Message production is not supported by this MQ system".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! Mirrors the pattern used by `agent_kv::*_core`.
|
||||
|
||||
use crate::connection::AppState;
|
||||
use crate::db::agent_driver::AgentLaunchSpec;
|
||||
use crate::mq::config::MqAdminConfig;
|
||||
use crate::mq::token::sign_pulsar_token;
|
||||
use crate::mq::types::*;
|
||||
@@ -19,7 +20,8 @@ const MAX_PEEK_MESSAGES: u32 = 100;
|
||||
pub async fn mq_test_connection_core(state: &AppState, conn_id: &str) -> Result<MqClusterInfo, String> {
|
||||
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
|
||||
let mqc = state.mq_admin_config_for_connection(conn_id, &cfg).await?;
|
||||
let adapter = state.mq_registry.build_transient_config(mqc).await?;
|
||||
let kafka_launch = resolve_kafka_launch_spec(&mqc, state);
|
||||
let adapter = state.mq_registry.build_transient_config(mqc, kafka_launch).await?;
|
||||
adapter.test_connection().await
|
||||
}
|
||||
|
||||
@@ -446,6 +448,11 @@ pub async fn mq_get_backlog_core(
|
||||
adapter.get_backlog(&topic, sub.as_deref()).await
|
||||
}
|
||||
|
||||
pub async fn mq_get_cluster_info_core(state: &AppState, conn_id: &str) -> Result<ClusterInfo, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.get_cluster_info().await
|
||||
}
|
||||
|
||||
// ---- Raw request (escape hatch) ----
|
||||
|
||||
pub async fn mq_raw_request_core(state: &AppState, conn_id: &str, req: MqRawRequest) -> Result<MqRawResponse, String> {
|
||||
@@ -456,6 +463,19 @@ pub async fn mq_raw_request_core(state: &AppState, conn_id: &str, req: MqRawRequ
|
||||
adapter.raw_request(req).await
|
||||
}
|
||||
|
||||
// ---- Message production ----
|
||||
|
||||
/// Produce a message to a topic through the MQ adapter.
|
||||
pub async fn mq_send_message_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
req: SendMessageRequest,
|
||||
) -> Result<SendMessageResponse, String> {
|
||||
ensure_connection_writable(state, conn_id, "Send message").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.send_message(req).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -466,7 +486,29 @@ async fn get_adapter(
|
||||
) -> Result<std::sync::Arc<dyn crate::mq::port::MessageQueueAdmin>, String> {
|
||||
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
|
||||
let mqc = state.mq_admin_config_for_connection(conn_id, &cfg).await?;
|
||||
state.mq_registry.get_or_build_config(conn_id, mqc).await
|
||||
let kafka_launch = resolve_kafka_launch_spec(&mqc, state);
|
||||
state.mq_registry.get_or_build_config(conn_id, mqc, kafka_launch).await
|
||||
}
|
||||
|
||||
/// Resolve the Kafka agent launch spec if the config targets Kafka.
|
||||
/// Returns `None` for non-Kafka systems so the registry skips agent resolution.
|
||||
pub fn resolve_kafka_launch_spec(mqc: &MqAdminConfig, state: &AppState) -> Option<AgentLaunchSpec> {
|
||||
if mqc.system_kind != MqSystemKind::Kafka {
|
||||
return None;
|
||||
}
|
||||
let agent_state = state.agent_manager.load_state();
|
||||
let jre_key = agent_state
|
||||
.installed_drivers
|
||||
.get("kafka")
|
||||
.map(|driver| driver.jre.as_str())
|
||||
.unwrap_or(crate::agent_manager::DEFAULT_JRE_KEY);
|
||||
match state.agent_manager.resolve_agent_launch_spec(&agent_state, "kafka", jre_key) {
|
||||
Ok(launch) => Some(launch),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to resolve Kafka agent launch spec: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_connection_writable(state: &AppState, conn_id: &str, operation: &str) -> Result<(), String> {
|
||||
|
||||
@@ -49,6 +49,9 @@ pub struct MqCapabilities {
|
||||
pub supports_geo_replication: bool,
|
||||
pub supports_token_management: bool,
|
||||
pub supports_raw_admin_api: bool,
|
||||
/// Whether the adapter supports producing messages to topics.
|
||||
#[serde(default)]
|
||||
pub supports_send_message: bool,
|
||||
}
|
||||
|
||||
impl Default for MqCapabilities {
|
||||
@@ -71,6 +74,7 @@ impl Default for MqCapabilities {
|
||||
supports_geo_replication: false,
|
||||
supports_token_management: false,
|
||||
supports_raw_admin_api: false,
|
||||
supports_send_message: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,6 +324,29 @@ pub struct TopicStats {
|
||||
// Subscription / consumers / producers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Cluster-level information for the Broker monitoring panel.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClusterInfo {
|
||||
pub cluster_id: Option<String>,
|
||||
pub broker_count: u32,
|
||||
pub controller_id: Option<i32>,
|
||||
pub controller_host: Option<String>,
|
||||
pub brokers: Vec<BrokerNode>,
|
||||
/// Free-form extra fields from the adapter.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
|
||||
pub raw: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BrokerNode {
|
||||
pub id: i32,
|
||||
pub host: String,
|
||||
pub port: i32,
|
||||
pub rack: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionInfo {
|
||||
@@ -562,3 +589,48 @@ pub struct MqRawResponse {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send message (produce)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request to produce a message to a topic.
|
||||
///
|
||||
/// The payload is always base64-encoded so binary messages are preserved.
|
||||
/// An optional `payload_text` field provides a UTF-8 preview for the UI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SendMessageRequest {
|
||||
/// Target topic name.
|
||||
pub topic: String,
|
||||
/// Optional message key (used for partitioning in Kafka).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
/// Message payload encoded as base64.
|
||||
pub payload_base64: String,
|
||||
/// Optional UTF-8 text preview of the payload.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub payload_text: Option<String>,
|
||||
/// Optional message headers.
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
/// Optional target partition (Kafka). When `None`, the key-based partitioner
|
||||
/// is used.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub partition: Option<i32>,
|
||||
}
|
||||
|
||||
/// Result of a successful message production.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SendMessageResponse {
|
||||
/// Topic the message was written to.
|
||||
pub topic: String,
|
||||
/// Partition the message was written to.
|
||||
pub partition: i32,
|
||||
/// Offset of the produced message.
|
||||
pub offset: i64,
|
||||
/// Broker-assigned timestamp, if available.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp: Option<String>,
|
||||
}
|
||||
|
||||
@@ -141,7 +141,9 @@ fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
|
||||
.route("/mq/tokens/issue", post(routes::mq::issue_token))
|
||||
.route("/mq/tokens/list", post(routes::mq::list_token_records))
|
||||
.route("/mq/monitoring/backlog", post(routes::mq::get_backlog))
|
||||
.route("/mq/monitoring/cluster-info", post(routes::mq::get_cluster_info))
|
||||
.route("/mq/raw", post(routes::mq::raw_request))
|
||||
.route("/mq/send-message", post(routes::mq::send_message))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
|
||||
@@ -698,6 +698,21 @@ pub async fn get_backlog(
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ClusterInfoReq {
|
||||
connection_id: String,
|
||||
}
|
||||
|
||||
pub async fn get_cluster_info(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<ClusterInfoReq>,
|
||||
) -> Result<Json<dbx_core::mq::ClusterInfo>, AppError> {
|
||||
let result =
|
||||
dbx_core::mq::service::mq_get_cluster_info_core(&state.app, &req.connection_id).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn raw_request(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RawRequestReq>,
|
||||
@@ -709,3 +724,21 @@ pub async fn raw_request(
|
||||
dbx_core::mq::service::mq_raw_request_core(&state.app, &req.connection_id, req.req).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// ---- Message production ----
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SendMessageReq {
|
||||
connection_id: String,
|
||||
req: dbx_core::mq::SendMessageRequest,
|
||||
}
|
||||
|
||||
pub async fn send_message(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<SendMessageReq>,
|
||||
) -> Result<Json<dbx_core::mq::SendMessageResponse>, AppError> {
|
||||
let result =
|
||||
dbx_core::mq::service::mq_send_message_core(&state.app, &req.connection_id, req.req).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
@@ -735,7 +735,8 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
|
||||
#[cfg(feature = "mq-admin")]
|
||||
DatabaseType::MessageQueue => {
|
||||
let mqc = state.mq_admin_config_for_connection(connection_id, &config).await?;
|
||||
let adapter = state.mq_registry.build_transient_config(mqc).await?;
|
||||
let kafka_launch = dbx_core::mq::service::resolve_kafka_launch_spec(&mqc, &state);
|
||||
let adapter = state.mq_registry.build_transient_config(mqc, kafka_launch).await?;
|
||||
adapter.test_connection().await?;
|
||||
Ok("Connection successful".to_string())
|
||||
}
|
||||
@@ -1018,7 +1019,8 @@ pub async fn connect_db(state: State<'_, Arc<AppState>>, config: ConnectionConfi
|
||||
#[cfg(feature = "mq-admin")]
|
||||
DatabaseType::MessageQueue => {
|
||||
let mqc = state.mq_admin_config_for_connection(&id, &config).await?;
|
||||
let adapter = state.mq_registry.build_transient_config(mqc).await?;
|
||||
let kafka_launch = dbx_core::mq::service::resolve_kafka_launch_spec(&mqc, &state);
|
||||
let adapter = state.mq_registry.build_transient_config(mqc, kafka_launch).await?;
|
||||
adapter.test_connection().await?;
|
||||
PoolKind::MessageQueue
|
||||
}
|
||||
|
||||
@@ -433,6 +433,14 @@ pub async fn mq_get_backlog(
|
||||
dbx_core::mq::service::mq_get_backlog_core(&state, &connection_id, topic, sub).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_get_cluster_info(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> Result<dbx_core::mq::ClusterInfo, String> {
|
||||
dbx_core::mq::service::mq_get_cluster_info_core(&state, &connection_id).await
|
||||
}
|
||||
|
||||
// ---- Raw request (escape hatch) ----
|
||||
|
||||
#[tauri::command]
|
||||
@@ -446,3 +454,14 @@ pub async fn mq_raw_request(
|
||||
}
|
||||
dbx_core::mq::service::mq_raw_request_core(&state, &connection_id, req).await
|
||||
}
|
||||
|
||||
// ---- Message production ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mq_send_message(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
req: dbx_core::mq::SendMessageRequest,
|
||||
) -> Result<dbx_core::mq::SendMessageResponse, String> {
|
||||
dbx_core::mq::service::mq_send_message_core(&state, &connection_id, req).await
|
||||
}
|
||||
|
||||
@@ -935,7 +935,11 @@ pub fn run() {
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_get_backlog,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_get_cluster_info,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_raw_request,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
commands::mq_cmd::mq_send_message,
|
||||
commands::history::save_history,
|
||||
commands::history::load_history,
|
||||
commands::history::clear_history,
|
||||
|
||||
Reference in New Issue
Block a user