feat(kafka): support Kafka MQ admin client

This commit is contained in:
miracle
2026-06-30 17:01:24 +08:00
committed by GitHub
parent 3e7cf30ce5
commit 7cec4892bf
43 changed files with 4281 additions and 186 deletions
+1 -1
View File
@@ -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) }
+12
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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))
+2 -1
View File
@@ -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;
}
+2
View File
@@ -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");
+11
View File
@@ -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 });
}
+11
View File
@@ -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 } : {}),
+30 -12
View File
@@ -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);
+4 -1
View File
@@ -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,
+2
View File
@@ -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;
+34
View File
@@ -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
}
},
{
+9
View File
@@ -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);
+26 -11
View File
@@ -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> {
+2 -1
View File
@@ -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
}
+865
View File
@@ -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
View File
@@ -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,
},
}
}
+19 -1
View File
@@ -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 =
+22 -7
View File
@@ -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()),
}
}
+13
View File
@@ -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())
}
}
+44 -2
View File
@@ -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> {
+72
View File
@@ -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>,
}
+2
View File
@@ -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"))]
+33
View File
@@ -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))
}
+4 -2
View File
@@ -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
}
+19
View File
@@ -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
}
+4
View File
@@ -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,