feat(kafka): 新增 Kafka 数据源连接支持

Refs #387
This commit is contained in:
Syngnat
2026-06-13 21:11:08 +08:00
parent d2f68acae8
commit 0ff17dc27c
37 changed files with 1989 additions and 12 deletions

View File

@@ -385,6 +385,7 @@ const ConnectionModal: React.FC<{
);
const disableLocalBackdropFilter = isMacLikePlatform();
const mysqlTopology = Form.useWatch("mysqlTopology", form) || "single";
const kafkaTopology = Form.useWatch("kafkaTopology", form) || "single";
const mongoTopology = Form.useWatch("mongoTopology", form) || "single";
const mongoSrv = Form.useWatch("mongoSrv", form) || false;
const redisTopology = Form.useWatch("redisTopology", form) || "single";
@@ -418,6 +419,7 @@ const ConnectionModal: React.FC<{
);
const isOceanBaseOracle = dbType === "oceanbase" && oceanBaseProtocol === "oracle";
const isMySQLLike = isMySQLCompatibleType(dbType) && !isOceanBaseOracle;
const isKafka = dbType === "kafka";
const supportsConnectionParams = supportsConnectionParamsForType(dbType);
const isSSLType = supportsSSLForType(dbType);
const supportsSSLCAPath = supportsSSLCAPathForType(dbType);
@@ -1629,6 +1631,62 @@ const ConnectionModal: React.FC<{
};
}
if (type === "kafka") {
const defaultPort = getDefaultPortByType(type);
const parsed =
parseMultiHostUri(trimmedUri, "kafka") ||
parseMultiHostUri(trimmedUri, "apache-kafka") ||
parseMultiHostUri(trimmedUri, "apache_kafka");
if (!parsed) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const hostList = normalizeAddressList(parsed.hosts, defaultPort);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(
hostList[0] || `localhost:${defaultPort}`,
defaultPort,
);
const tlsEnabled = normalizeUriBool(
parsed.params.get("tls") ||
parsed.params.get("ssl") ||
parsed.params.get("useSSL") ||
parsed.params.get("use_ssl"),
);
const skipVerify = normalizeUriBool(
parsed.params.get("skip_verify") || parsed.params.get("skipVerify"),
);
const topology = String(parsed.params.get("topology") || "")
.trim()
.toLowerCase();
const timeoutValue = Number(parsed.params.get("timeout"));
return {
host: primary?.host || "localhost",
port: primary?.port || defaultPort,
user: parsed.username,
password: parsed.password,
database: parsed.database || "",
useSSL: tlsEnabled,
sslMode: tlsEnabled ? (skipVerify ? "skip-verify" : "required") : "disable",
...extractSSLPathValuesFromParams(parsed.params, type),
kafkaTopology:
topology === "cluster" || hostList.length > 1 ? "cluster" : "single",
kafkaHosts: hostList.slice(1),
connectionParams: serializeConnectionParams(parsed.params),
timeout:
Number.isFinite(timeoutValue) && timeoutValue > 0
? Math.min(MAX_TIMEOUT_SECONDS, Math.trunc(timeoutValue))
: undefined,
};
}
if (type === "clickhouse") {
const httpValues = parseClickHouseHTTPUriToValues(trimmedUri);
if (httpValues) {
@@ -1877,6 +1935,9 @@ const ConnectionModal: React.FC<{
if (dbType === "iotdb") {
return "iotdb://root:root@127.0.0.1:6667/root.sg";
}
if (dbType === "kafka") {
return "kafka://user:pass@127.0.0.1:9092,127.0.0.2:9092/orders.events?topology=cluster&groupId=analytics&mechanism=scram-sha-256";
}
if (dbType === "redis") {
return "redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster 或 redis://:pass@10.0.0.1:26379,10.0.0.2:26379/0?topology=sentinel&master=mymaster";
}
@@ -1932,6 +1993,8 @@ const ConnectionModal: React.FC<{
return "timezone=Asia%2FShanghai";
case "iotdb":
return "fetchSize=1024&timeZone=Asia%2FShanghai";
case "kafka":
return "groupId=gonavi&mechanism=scram-sha-256&clientId=gonavi-desktop&startOffset=latest";
default:
return "key=value&another=value";
}
@@ -1994,6 +2057,36 @@ const ConnectionModal: React.FC<{
return `${scheme}://${encodedAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`;
}
if (type === "kafka") {
const primary = toAddress(host, port, defaultPort);
const brokers =
values.kafkaTopology === "cluster"
? normalizeAddressList(values.kafkaHosts, defaultPort)
: [];
const allBrokers = normalizeAddressList([primary, ...brokers], defaultPort);
const params = new URLSearchParams();
if (allBrokers.length > 1 || values.kafkaTopology === "cluster") {
params.set("topology", "cluster");
}
if (values.useSSL) {
const mode = String(values.sslMode || "preferred")
.trim()
.toLowerCase();
params.set("tls", "true");
if (mode === "skip-verify" || mode === "preferred") {
params.set("skip_verify", "true");
}
appendSSLPathParamsForUri(params, type, values);
}
if (Number.isFinite(timeout) && timeout > 0) {
params.set("timeout", String(timeout));
}
mergeConnectionParams(params, values.connectionParams);
const topicPath = database ? `/${encodeURIComponent(database)}` : "";
const query = params.toString();
return `kafka://${encodedAuth}${allBrokers.join(",")}${topicPath}${query ? `?${query}` : ""}`;
}
if (type === "redis") {
return buildRedisUriFromValues(values);
}
@@ -2364,6 +2457,8 @@ const ConnectionModal: React.FC<{
configType === "sphinx"
? normalizedHosts.slice(1)
: [];
const kafkaHosts =
configType === "kafka" ? normalizedHosts.slice(1) : [];
const mongoHosts =
configType === "mongodb" ? normalizedHosts.slice(1) : [];
const redisHosts =
@@ -2371,6 +2466,9 @@ const ConnectionModal: React.FC<{
const mysqlIsReplica =
String(config.topology || "").toLowerCase() === "replica" ||
mysqlReplicaHosts.length > 0;
const kafkaIsCluster =
String(config.topology || "").toLowerCase() === "cluster" ||
kafkaHosts.length > 0;
const mongoIsReplica =
String(config.topology || "").toLowerCase() === "replica" ||
mongoHosts.length > 0 ||
@@ -2443,6 +2541,8 @@ const ConnectionModal: React.FC<{
timeout: resolvedJvmTimeout,
mysqlTopology: mysqlIsReplica ? "replica" : "single",
mysqlReplicaHosts: mysqlReplicaHosts,
kafkaTopology: kafkaIsCluster ? "cluster" : "single",
kafkaHosts: kafkaHosts,
mysqlReplicaUser: config.mysqlReplicaUser || "",
mysqlReplicaPassword: config.mysqlReplicaPassword || "",
mongoTopology: mongoIsReplica ? "replica" : "single",
@@ -3401,6 +3501,23 @@ const ConnectionModal: React.FC<{
}
}
if (type === "kafka") {
const brokers =
mergedValues.kafkaTopology === "cluster"
? normalizeAddressList(mergedValues.kafkaHosts, defaultPort)
: [];
const allHosts = normalizeAddressList(
[`${primaryHost}:${primaryPort}`, ...brokers],
defaultPort,
);
if (mergedValues.kafkaTopology === "cluster" || allHosts.length > 1) {
hosts = allHosts;
topology = "cluster";
} else {
topology = "single";
}
}
if (type === "mongodb") {
mongoSrvEnabled = !!mergedValues.mongoSrv;
const extraHosts =
@@ -3637,6 +3754,7 @@ const ConnectionModal: React.FC<{
includeDatabases: undefined,
includeRedisDatabases: undefined,
mysqlTopology: "single",
kafkaTopology: "single",
redisTopology: "single",
mongoTopology: "single",
mongoSrv: false,
@@ -3646,6 +3764,7 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism: "",
savePassword: true,
mysqlReplicaHosts: [],
kafkaHosts: [],
redisHosts: [],
redisSentinelMaster: "",
redisSentinelUser: "",
@@ -3699,6 +3818,7 @@ const ConnectionModal: React.FC<{
httpTunnelUser: "",
httpTunnelPassword: "",
mysqlTopology: "single",
kafkaTopology: "single",
redisTopology: "single",
mongoTopology: "single",
mongoSrv: false,
@@ -3708,6 +3828,7 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism: "",
savePassword: true,
mysqlReplicaHosts: [],
kafkaHosts: [],
redisHosts: [],
redisSentinelMaster: "",
redisSentinelUser: "",
@@ -3722,7 +3843,7 @@ const ConnectionModal: React.FC<{
});
} else if (type !== "custom") {
const defaultUser =
type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant") ? "" : "root";
type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "kafka") ? "" : "root";
const sslCapableType = supportsSSLForType(type);
setUseSSL(false);
setUseHttpTunnel(false);
@@ -3741,6 +3862,7 @@ const ConnectionModal: React.FC<{
httpTunnelUser: "",
httpTunnelPassword: "",
mysqlTopology: "single",
kafkaTopology: "single",
redisTopology: "single",
mongoTopology: "single",
mongoSrv: false,
@@ -3750,6 +3872,7 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism: "",
savePassword: true,
mysqlReplicaHosts: [],
kafkaHosts: [],
redisHosts: [],
redisSentinelMaster: "",
redisSentinelUser: "",
@@ -4850,6 +4973,22 @@ const ConnectionModal: React.FC<{
),
})}
{dbType === "kafka" &&
renderConfigSectionCard({
sectionKey: "service",
icon: <DatabaseOutlined />,
children: (
<Form.Item
name="database"
label="默认 Topic可选"
help="留空时必须在 SQL 中显式指定 Topic填写后可直接执行 SHOW、CONSUME 或 SELECT 预览。"
style={{ marginBottom: 0 }}
>
<Input {...noAutoCapInputProps} placeholder="例如orders.events" />
</Form.Item>
),
})}
{(dbType === "oracle" || isOceanBaseOracle) &&
renderConfigSectionCard({
sectionKey: "service",
@@ -4902,6 +5041,48 @@ const ConnectionModal: React.FC<{
}),
})}
{isKafka &&
renderConfigSectionCard({
sectionKey: "connectionMode",
icon: <ClusterOutlined />,
children: renderChoiceCards({
fieldName: "kafkaTopology",
value: String(kafkaTopology),
options: [
{
value: "single",
label: "单 Broker",
description: "只配置一个 bootstrap broker适合本地或简单环境。",
},
{
value: "cluster",
label: "集群模式",
description: "配置多个 bootstrap broker提高发现与故障切换成功率。",
},
],
}),
})}
{isKafka &&
kafkaTopology === "cluster" &&
renderConfigSectionCard({
sectionKey: "replica",
icon: <ClusterOutlined />,
children: (
<Form.Item
name="kafkaHosts"
label="额外 Broker 地址"
help="可输入多个 broker 地址格式host:port回车确认"
>
<Select
mode="tags"
placeholder="例如10.10.0.12:9092、10.10.0.13:9092"
tokenSeparators={[",", ";", " "]}
/>
</Form.Item>
),
})}
{isMySQLLike &&
mysqlTopology === "replica" &&
renderConfigSectionCard({
@@ -5019,13 +5200,13 @@ const ConnectionModal: React.FC<{
name="user"
label="用户名"
rules={
(dbType === "mongodb" || dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant")
(dbType === "mongodb" || dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "kafka")
? []
: [createUriAwareRequiredRule("请输入用户名")]
}
style={{ marginBottom: 0 }}
>
<Input {...noAutoCapInputProps} placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant") ? "未开启认证可留空" : undefined} />
<Input {...noAutoCapInputProps} placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant" || dbType === "kafka") ? "未开启认证可留空" : undefined} />
</Form.Item>
<Form.Item
name="password"
@@ -5099,6 +5280,7 @@ const ConnectionModal: React.FC<{
{!isFileDb &&
!isRedis &&
!isKafka &&
renderConfigSectionCard({
sectionKey: "databaseScope",
icon: <DatabaseOutlined />,
@@ -5946,6 +6128,7 @@ const ConnectionModal: React.FC<{
connectionParams: "",
oceanBaseProtocol: "mysql",
mysqlTopology: "single",
kafkaTopology: "single",
redisTopology: "single",
mongoTopology: "single",
mongoSrv: false,
@@ -5953,6 +6136,7 @@ const ConnectionModal: React.FC<{
mongoAuthMechanism: "",
savePassword: true,
mysqlReplicaHosts: [],
kafkaHosts: [],
redisHosts: [],
redisSentinelMaster: "",
redisSentinelUser: "",