feat(mq): implement Apache Pulsar message queue admin

* feat(mq): implement Apache Pulsar message queue admin

- Add complete MQ admin adapter layer with version detection
  * Pulsar adapter with multi-version API support
  * OAuth2 token caching and authentication
  * JWT token signing for Pulsar clients
  * Transport layer (SSH/Proxy) integration

- Implement comprehensive admin operations
  * Tenant, namespace, topic management
  * Subscription and consumer/producer monitoring
  * Rate limits, backlog quotas, retention policies
  * Permissions and role-based access control
  * Raw admin API for advanced operations

- Add desktop UI components
  * MQ admin console with tabbed interface
  * Tenant, namespace, topic panels
  * Subscription management and cursor operations
  * Monitoring panel with stats visualization
  * Policy configuration UI

- Add web routes for MQ admin operations
- Add secret store support for MQ auth credentials
- Add comprehensive test coverage

* fix: del local test files

* fix(mq): mark message queue as unsupported mcpMode in driver manifest

MQ admin connections do not use the MCP query bridge - they use a
separate REST admin API. Mark mcpMode as 'unsupported' to align with
the test expectations and reflect the actual architecture.

Fixes CI test: driver-manifest.test.ts

* fix(mq): fix TypeScript errors in MQ components

- Fix typo in SubscriptionsPanel.vue: messagformatError(e)Id -> message.messageId
- Use formatError() helper in RawApiPanel.vue instead of accessing e?.message
- Fix undefined 'tab' reference in ConnectionTree.vue: use config.db_type instead

Fixes frontend CI TypeScript check errors.

* fix(mcp): add 'mq' to MCP server connection type description

The MCP server's connection type description must include all database
types from the driver manifest. Add 'mq' to the list to fix the test:
'add connection database type description includes every manifest database type'

Fixes: packages/mcp-server/tests/driver-manifest.test.ts

* fix(mq): add missing ConnectionConfig fields for Rust compilation

- Add gbase_server and keepalive_interval_secs fields to all ConnectionConfig
  test constructors in mq/config.rs, mq/service.rs, and storage.rs
- Import uses_bare_mysql_pool in connection.rs tests module

Fixes compilation errors from upstream ConnectionConfig changes.

* chore(ui): simplify Pulsar icon to logo-only version

Remove text from Pulsar icon (127x30 -> 35x30), keeping only
the wave logo symbol for a cleaner, more consistent appearance
with other database icons.

* fix(mq): return removed pools from drain_connection_pools

---------

Co-authored-by: t8y2 <1156263951@qq.com>
This commit is contained in:
miracleandt8y2 authored and GitHub committed 2026-06-16 21:57:17 +08:00
1 parent 8339d23df5
commit 2a59d3143e
83 files changed
+15979 -37

No files matched your search

@@ -14,6 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import type { ConnectionConfig, DatabaseType, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
@@ -25,6 +26,7 @@ import type { ConnectionDeepLinkDraft } from "@/lib/connectionDeepLink";
import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connectionPresentation";
import { h2ConnectionModeForConfig, h2FileJdbcUrl, h2FilePathFromJdbcUrl, type H2ConnectionMode } from "@/lib/h2Connection";
import { isLocalFileTypeDb } from "@/lib/connectionFile";
import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedVersion } from "@/lib/mqPinnedVersionOptions";
import { mongodbAuthFailureHint, mongoUrlParam, setMongoUrlParam } from "@/lib/mongoConnectionOptions";
import { copyToClipboard } from "@/lib/clipboard";
import { showAgentDriverInstallHint, type AgentDriverInstallState } from "@/lib/agentDriverInstallHint";
@@ -37,6 +39,7 @@ type DbCategory = { key: string; title: string; options: DbOption[] };
type DialogStep = "select" | "config";
type DbPickerView = "icon" | "list";
type ConfigTab = "connection" | "advanced" | "tls" | "transport";
type MqTokenSigningMode = "none" | "hs256" | "rs256";
type JdbcDriverSelectItem = {
id: string;
label: string;
@@ -132,6 +135,7 @@ const defaultForm = (): ConnectionForm => ({
redis_key_separator: ":",
etcd_endpoints: "",
gbase_server: "",
external_config: undefined,
read_only: false,
visible_databases: undefined,
});
@@ -279,6 +283,24 @@ const dialogStep = ref<DialogStep>("select");
const dbPickerView = ref<DbPickerView>("icon");
const dbSearchQuery = ref("");
const configTab = ref<ConfigTab>("connection");
type MqAuthKind = MqAuth["kind"];
const mqAdminUrl = ref("http://127.0.0.1:8080");
const mqSystemKind = ref<MqSystemKind>("pulsar");
const mqAuthKind = ref<MqAuthKind>("none");
const mqToken = ref("");
const mqBasicUsername = ref("");
const mqBasicPassword = ref("");
const mqApiKeyHeader = ref("Authorization");
const mqApiKeyValue = ref("");
const mqOauthIssuerUrl = ref("");
const mqOauthClientId = ref("");
const mqOauthClientSecret = ref("");
const mqOauthAudience = ref("");
const mqOauthScope = ref("");
const mqTlsSkipVerify = ref(false);
const mqPinnedVersion = ref(pinnedVersionToSelection(undefined));
const mqTokenSigningMode = ref<MqTokenSigningMode>("none");
const mqTokenSigningKey = ref("");
const colorOptions = [
{ value: "", class: "bg-transparent border-dashed", labelKey: "connection.colorNone" },
@@ -476,6 +498,7 @@ const driverProfiles: Record<
xugu: { type: "xugu", port: 5138, user: "", label: "虚谷 XuguDB", icon: "xugu" },
iotdb: { type: "iotdb", port: 6667, user: "root", label: "Apache IoTDB", icon: "iotdb" },
etcd: { type: "etcd", port: 2379, user: "", label: "etcd", icon: "etcd" },
mq: { type: "mq", port: 8080, user: "", label: "Apache Pulsar", icon: "pulsar", 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" },
custom_mysql: {
@@ -511,6 +534,104 @@ function selectedProfile() {
return driverProfiles[selectedType.value] ?? driverProfiles.mysql;
}
function resetMqFields(config?: Partial<MqAdminConfig>) {
mqSystemKind.value = "pulsar";
mqAdminUrl.value = config?.adminUrl?.trim() || "http://127.0.0.1:8080";
mqTlsSkipVerify.value = !!config?.tlsSkipVerify;
mqPinnedVersion.value = pinnedVersionToSelection(config?.pinnedVersion);
const auth = (config?.auth || { kind: "none" }) as MqAuth;
mqAuthKind.value = auth.kind || "none";
mqToken.value = auth.token || "";
mqBasicUsername.value = auth.username || "";
mqBasicPassword.value = auth.password || "";
mqApiKeyHeader.value = auth.header || "Authorization";
mqApiKeyValue.value = auth.value || "";
mqOauthIssuerUrl.value = auth.issuerUrl || "";
mqOauthClientId.value = auth.clientId || "";
mqOauthClientSecret.value = auth.clientSecret || "";
mqOauthAudience.value = auth.audience || "";
mqOauthScope.value = auth.scope || "";
const tokenSigning = config?.tokenSigning;
mqTokenSigningMode.value = tokenSigning?.algorithm === "hs256" || tokenSigning?.algorithm === "rs256" ? tokenSigning.algorithm : "none";
mqTokenSigningKey.value = tokenSigning?.key || "";
}
function hydrateMqFields(value: unknown) {
if (!value || typeof value !== "object") {
resetMqFields();
return;
}
resetMqFields(value as Partial<MqAdminConfig>);
}
function requireMqField(value: string, message: string): string {
const trimmed = value.trim();
if (!trimmed) throw new Error(message);
return trimmed;
}
function buildMqAuth(): MqAuth {
switch (mqAuthKind.value) {
case "token":
return { kind: "token", token: requireMqField(mqToken.value, "Token auth requires a token") };
case "basic":
return {
kind: "basic",
username: requireMqField(mqBasicUsername.value, "Basic auth requires a username"),
password: mqBasicPassword.value,
};
case "apiKey":
return {
kind: "apiKey",
header: requireMqField(mqApiKeyHeader.value, "API key auth requires a header"),
value: requireMqField(mqApiKeyValue.value, "API key auth requires a value"),
};
case "oauth2":
return {
kind: "oauth2",
issuerUrl: requireMqField(mqOauthIssuerUrl.value, "OAuth2 auth requires an issuer URL"),
clientId: requireMqField(mqOauthClientId.value, "OAuth2 auth requires a client ID"),
clientSecret: requireMqField(mqOauthClientSecret.value, "OAuth2 auth requires a client secret"),
audience: mqOauthAudience.value.trim() || undefined,
scope: mqOauthScope.value.trim() || undefined,
};
default:
return { kind: "none" };
}
}
function buildMqTokenSigning() {
if (mqTokenSigningMode.value === "none") return undefined;
return {
algorithm: mqTokenSigningMode.value,
key: requireMqField(mqTokenSigningKey.value, "Broker token signing key is required"),
};
}
function buildMqAdminConfig(): MqAdminConfig {
return {
systemKind: "pulsar",
adminUrl: requireMqField(mqAdminUrl.value, "MQ Admin URL is required"),
auth: buildMqAuth(),
tlsSkipVerify: mqTlsSkipVerify.value || undefined,
pinnedVersion: selectionToPinnedVersion(mqPinnedVersion.value),
tokenSigning: buildMqTokenSigning(),
};
}
function applyMqAdminUrl(config: LegacyConnectionConfig, adminUrl: string) {
let parsed: URL;
try {
parsed = new URL(adminUrl);
} catch {
throw new Error("MQ Admin URL is invalid");
}
const port = Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 8080);
config.host = parsed.hostname;
config.port = port;
config.ssl = parsed.protocol === "https:";
}
function isCustomCompatibleProfile() {
return selectedType.value === "custom_mysql" || selectedType.value === "custom_postgres";
}
@@ -547,6 +668,11 @@ function applyProfile(val: string, preserveConnectionFields = false) {
form.value.jdbc_driver_paths = [];
jdbcDriverPathsInput.value = "";
}
if (profile.type === "mq") {
resetMqFields();
form.value.database = undefined;
form.value.connection_string = undefined;
}
}
}
@@ -613,6 +739,11 @@ watch(
read_only: config.read_only || false,
visible_databases: config.visible_databases,
};
if (config.db_type === "mq") {
hydrateMqFields(config.external_config);
} else {
resetMqFields();
}
h2ConnectionMode.value = h2ConnectionModeForConfig(config);
customColorInput.value = config.color || "";
selectedTransportLayerId.value = form.value.transport_layers?.[0]?.id || null;
@@ -634,6 +765,7 @@ watch(
selectedTransportLayerId.value = null;
selectedType.value = "mysql";
customDriverName.value = "";
resetMqFields();
oceanbaseSubMode.value = "mysql";
h2ConnectionMode.value = "file";
dialogStep.value = "select";
@@ -767,6 +899,7 @@ const iconTypeMap: Record<string, string> = {
xugu: "xugu",
iotdb: "iotdb",
etcd: "etcd",
mq: "mq",
dm: "dm",
h2: "h2",
snowflake: "snowflake",
@@ -844,6 +977,7 @@ const dbOptions: DbOption[] = [
{ value: "xugu", label: "虚谷 XuguDB" },
{ value: "iotdb", label: "Apache IoTDB" },
{ value: "etcd", label: "etcd" },
{ value: "mq", label: "Apache Pulsar" },
{ value: "influxdb", label: "InfluxDB" },
{ value: "iris", label: "IRIS" },
{ value: "jdbc", label: "JDBC" },
@@ -996,6 +1130,7 @@ const testResultMessage = computed(() => {
return testResult.value.ok ? t("connection.testSuccess") : testResult.value.message;
});
const hasRequiredConnectionTarget = computed(() => {
if (form.value.db_type === "mq") return !!mqAdminUrl.value.trim();
if (isH2FileMode.value) return !!(form.value.host.trim() || h2FilePathFromJdbcUrl(form.value.connection_string));
return !!(form.value.host || (mongoUseUrl.value && form.value.connection_string) || (form.value.db_type === "jdbc" && form.value.connection_string) || connectionUrlInput.value.trim());
});
@@ -1121,6 +1256,18 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
}
if (!config.one_time) config.one_time = undefined;
if (!config.read_only) config.read_only = undefined;
if (config.db_type === "mq") {
const mqConfig = buildMqAdminConfig();
config.external_config = mqConfig;
applyMqAdminUrl(config, mqConfig.adminUrl);
config.username = "";
config.password = "";
config.database = undefined;
config.connection_string = undefined;
config.url_params = "";
} else {
config.external_config = undefined;
}
if (config.db_type === "mongodb" && !mongoUseUrl.value) {
config.connection_string = undefined;
} else if (config.db_type === "mongodb") {
@@ -1575,6 +1722,7 @@ function resetForm() {
selectedType.value = "mysql";
customDriverName.value = "";
mongoUseUrl.value = false;
resetMqFields();
oceanbaseSubMode.value = "mysql";
jdbcDriverPathsInput.value = "";
selectedJdbcDriverPath.value = "";
@@ -2439,6 +2587,124 @@ function openExternalUrl(url: string) {
</template>
</template>
<!-- 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="text-right">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="text-right">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>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">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 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>
</div>
</div>
<template v-if="mqAuthKind === 'token'">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">Token</Label>
<Input v-model="mqToken" type="password" class="col-span-3" />
</div>
</template>
<template v-else-if="mqAuthKind === 'basic'">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.user") }}</Label>
<Input v-model="mqBasicUsername" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.password") }}</Label>
<Input v-model="mqBasicPassword" type="password" class="col-span-3" />
</div>
</template>
<template v-else-if="mqAuthKind === 'apiKey'">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">Header</Label>
<Input v-model="mqApiKeyHeader" class="col-span-3" placeholder="Authorization" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">Value</Label>
<Input v-model="mqApiKeyValue" type="password" class="col-span-3" />
</div>
</template>
<template v-else-if="mqAuthKind === 'oauth2'">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">Issuer URL</Label>
<Input v-model="mqOauthIssuerUrl" class="col-span-3" placeholder="https://issuer.example.com/oauth/token" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">Client ID</Label>
<Input v-model="mqOauthClientId" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">Client Secret</Label>
<Input v-model="mqOauthClientSecret" type="password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">Audience</Label>
<Input v-model="mqOauthAudience" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">Scope</Label>
<Input v-model="mqOauthScope" class="col-span-3" />
</div>
</template>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">TLS</Label>
<label class="col-span-3 inline-flex items-center gap-2">
<input type="checkbox" v-model="mqTlsSkipVerify" class="mr-0" />
<span class="text-xs text-muted-foreground">Skip certificate verification</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">Pinned Version</Label>
<Select v-model="mqPinnedVersion">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in MQ_PINNED_VERSION_OPTIONS" :key="option.value" :value="option.value">
<div class="grid gap-0.5 text-left">
<span>{{ option.label }}</span>
<span class="text-xs text-muted-foreground">{{ option.description }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">Broker Token 签发</Label>
<Select v-model="mqTokenSigningMode">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">不配置</SelectItem>
<SelectItem value="hs256">HS256 SECRET</SelectItem>
<SelectItem value="rs256">RS256 PRIVATE</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="mqTokenSigningMode !== 'none'" class="grid grid-cols-4 items-start gap-4">
<Label class="pt-2 text-right">签发密钥</Label>
<textarea
v-model="mqTokenSigningKey"
class="col-span-3 min-h-24 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="mqTokenSigningMode === 'hs256' ? 'Broker SECRET' : '-----BEGIN PRIVATE KEY-----'"
/>
</div>
<div v-if="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>
</template>
<!-- Redis: host, port, user, password, ssl -->
<template v-else-if="form.db_type === 'redis'">
<div class="grid grid-cols-4 items-center gap-4">