diff --git a/apps/desktop/public/icons/database/nacos.png b/apps/desktop/public/icons/database/nacos.png new file mode 100644 index 000000000..0d7626a51 Binary files /dev/null and b/apps/desktop/public/icons/database/nacos.png differ diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index effe31860..08b757ed9 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -765,6 +765,20 @@ async function openConnectionQuery(connectionId: string) { queryStore.openMqAdmin(connectionId); return; } + if (initialTarget.kind === "nacos-admin") { + try { + await connectionStore.ensureConnected(connectionId); + await connectionStore.loadNacosNamespaces(connectionId); + } catch (e: any) { + toast( + t("connection.connectFailed", { + message: translateBackendError(t, e?.message || String(e)), + }), + 5000, + ); + } + return; + } const tabId = queryStore.createTab(connectionId, initialTarget.database); try { await connectionStore.ensureConnected(connectionId); diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index e11b058b6..e1e193c11 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -15,6 +15,7 @@ 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 type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos"; import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; @@ -42,12 +43,17 @@ type DialogStep = "select" | "config"; type DbPickerView = "icon" | "list"; type ConfigTab = "connection" | "advanced" | "tls" | "transport"; type MqTokenSigningMode = "none" | "hs256" | "rs256"; +type NacosAuthKind = NacosAuthConfig["kind"]; type JdbcDriverSelectItem = { id: string; label: string; paths: string[]; }; +const NACOS_DEFAULT_CONSOLE_URL = "http://127.0.0.1:8085"; +const NACOS_LEGACY_SERVER_PORT = "8848"; +const NACOS_DOCKER_CONSOLE_PORT = "8085"; + type LegacyTransportFields = { ssh_enabled?: boolean; ssh_host?: string; @@ -307,6 +313,14 @@ const mqTlsSkipVerify = ref(false); const mqPinnedVersion = ref(pinnedVersionToSelection(undefined)); const mqTokenSigningMode = ref("none"); const mqTokenSigningKey = ref(""); +const nacosServerAddr = ref(NACOS_DEFAULT_CONSOLE_URL); +const nacosNamespace = ref(""); +const nacosContextPath = ref(""); +const nacosAuthKind = ref("none"); +const nacosUsername = ref("nacos"); +const nacosPassword = ref(""); +const nacosTlsSkipVerify = ref(false); +const nacosPageSize = ref(20); const colorOptions = [ { value: "", class: "bg-transparent border-dashed", labelKey: "connection.colorNone" }, @@ -509,6 +523,7 @@ const driverProfiles: Record< 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" }, + 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" }, custom_mysql: { @@ -574,6 +589,26 @@ function hydrateMqFields(value: unknown) { resetMqFields(value as Partial); } +function resetNacosFields(config?: Partial) { + nacosServerAddr.value = config?.serverAddr?.trim() || NACOS_DEFAULT_CONSOLE_URL; + nacosNamespace.value = config?.namespace || ""; + nacosContextPath.value = config?.contextPath || ""; + nacosTlsSkipVerify.value = !!config?.tlsSkipVerify; + nacosPageSize.value = Number(config?.pageSize) > 0 ? Number(config?.pageSize) : 20; + const auth = (config?.auth || { kind: "none" }) as NacosAuthConfig; + nacosAuthKind.value = auth.kind || "none"; + nacosUsername.value = auth.username || "nacos"; + nacosPassword.value = auth.password || ""; +} + +function hydrateNacosFields(value: unknown) { + if (!value || typeof value !== "object") { + resetNacosFields(); + return; + } + resetNacosFields(value as Partial); +} + function requireMqField(value: string, message: string): string { const trimmed = value.trim(); if (!trimmed) throw new Error(message); @@ -629,6 +664,65 @@ function buildMqAdminConfig(): MqAdminConfig { }; } +function buildNacosAuth(): NacosAuthConfig { + if (nacosAuthKind.value === "usernamePassword") { + return { + kind: "usernamePassword", + username: requireMqField(nacosUsername.value, t("connection.nacosUsernameRequired")), + password: nacosPassword.value, + }; + } + return { kind: "none" }; +} + +function buildNacosAdminConfig(): NacosAdminConfig { + return { + serverAddr: requireMqField(nacosServerAddr.value, t("connection.nacosConsoleUrlRequired")), + namespace: nacosNamespace.value.trim() || undefined, + contextPath: nacosContextPath.value.trim(), + auth: buildNacosAuth(), + tlsSkipVerify: nacosTlsSkipVerify.value || undefined, + pageSize: Number(nacosPageSize.value) > 0 ? Number(nacosPageSize.value) : 20, + }; +} + +function dockerNacosConsoleFallbackUrl(serverAddr: string): string | null { + let parsed: URL; + try { + parsed = new URL(serverAddr); + } catch { + return null; + } + const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80"); + const host = parsed.hostname.toLowerCase(); + if (port !== NACOS_LEGACY_SERVER_PORT || !["127.0.0.1", "localhost", "::1"].includes(host)) { + return null; + } + parsed.port = NACOS_DOCKER_CONSOLE_PORT; + return parsed.toString().replace(/\/$/, ""); +} + +function isNacosAdminEndpointNotFound(message: string): boolean { + return /Nacos admin endpoint was not found/i.test(message); +} + +async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalError: string): Promise { + if (config.db_type !== "nacos" || !isNacosAdminEndpointNotFound(originalError)) return null; + const fallbackUrl = dockerNacosConsoleFallbackUrl(nacosServerAddr.value); + if (!fallbackUrl || fallbackUrl === nacosServerAddr.value.trim()) return null; + + const previousUrl = nacosServerAddr.value; + nacosServerAddr.value = fallbackUrl; + try { + const fallbackConfig = connectionConfigForSubmit(config.id); + const message = await api.testConnection(fallbackConfig); + return `${message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`; + } catch { + nacosServerAddr.value = previousUrl; + return null; + } +} + function applyMqAdminUrl(config: LegacyConnectionConfig, adminUrl: string) { let parsed: URL; try { @@ -642,6 +736,19 @@ function applyMqAdminUrl(config: LegacyConnectionConfig, adminUrl: string) { config.ssl = parsed.protocol === "https:"; } +function applyNacosServerAddr(config: LegacyConnectionConfig, serverAddr: string) { + let parsed: URL; + try { + parsed = new URL(serverAddr); + } catch { + throw new Error("Nacos server address is invalid"); + } + const port = Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 8848); + config.host = parsed.hostname; + config.port = port; + config.ssl = parsed.protocol === "https:"; +} + function isCustomCompatibleProfile() { return selectedType.value === "custom_mysql" || selectedType.value === "custom_postgres"; } @@ -691,6 +798,12 @@ function applyProfile(val: string, preserveConnectionFields = false) { form.value.database = undefined; form.value.connection_string = undefined; } + if (profile.type === "nacos") { + resetNacosFields(); + form.value.database = undefined; + form.value.connection_string = undefined; + form.value.url_params = ""; + } } } @@ -763,6 +876,11 @@ watch( } else { resetMqFields(); } + if (config.db_type === "nacos") { + hydrateNacosFields(config.external_config); + } else { + resetNacosFields(); + } h2ConnectionMode.value = h2ConnectionModeForConfig(config); customColorInput.value = config.color || ""; selectedTransportLayerId.value = form.value.transport_layers?.[0]?.id || null; @@ -786,6 +904,7 @@ watch( selectedType.value = "mysql"; customDriverName.value = ""; resetMqFields(); + resetNacosFields(); oceanbaseSubMode.value = "mysql"; h2ConnectionMode.value = "file"; dialogStep.value = "select"; @@ -922,6 +1041,7 @@ const iconTypeMap: Record = { iotdb: "iotdb", etcd: "etcd", mq: "mq", + nacos: "nacos", dm: "dm", h2: "h2", snowflake: "snowflake", @@ -1004,6 +1124,7 @@ const dbOptions: DbOption[] = [ { value: "iotdb", label: "Apache IoTDB" }, { value: "etcd", label: "etcd" }, { value: "mq", label: "Apache Pulsar" }, + { value: "nacos", label: "Nacos" }, { value: "influxdb", label: "InfluxDB" }, { value: "iris", label: "IRIS" }, { value: "jdbc", label: "JDBC" }, @@ -1160,6 +1281,7 @@ const testResultMessage = computed(() => { }); const hasRequiredConnectionTarget = computed(() => { if (form.value.db_type === "mq") return !!mqAdminUrl.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)); return !!(form.value.host || (mongoUseUrl.value && form.value.connection_string) || (form.value.db_type === "jdbc" && form.value.connection_string) || connectionUrlInput.value.trim()); }); @@ -1209,8 +1331,8 @@ async function testConnection() { const runId = ++testRunId; isTesting.value = true; testResult.value = null; + const config = connectionConfigForSubmit(editingId.value || uuid()); try { - const config = connectionConfigForSubmit(editingId.value || uuid()); const msg = await api.testConnection(config); if (runId !== testRunId) return; if (config.db_type === "mongodb" && /legacy driver/i.test(msg)) { @@ -1219,7 +1341,10 @@ async function testConnection() { testResult.value = { ok: true, message: msg }; } catch (e: any) { if (runId !== testRunId) return; - testResult.value = { ok: false, message: mongodbAuthFailureHint(String(e)) }; + const message = mongodbAuthFailureHint(String(e)); + const fallbackMessage = await tryNacosDockerConsoleFallback(config, message); + if (runId !== testRunId) return; + testResult.value = fallbackMessage ? { ok: true, message: fallbackMessage } : { ok: false, message }; } finally { if (runId === testRunId) { isTesting.value = false; @@ -1301,6 +1426,15 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { config.database = undefined; config.connection_string = undefined; config.url_params = ""; + } else if (config.db_type === "nacos") { + const nacosConfig = buildNacosAdminConfig(); + config.external_config = nacosConfig; + applyNacosServerAddr(config, nacosConfig.serverAddr); + config.username = nacosAuthKind.value === "usernamePassword" ? nacosUsername.value.trim() : ""; + config.password = nacosAuthKind.value === "usernamePassword" ? nacosPassword.value : ""; + config.database = nacosConfig.namespace || undefined; + config.connection_string = undefined; + config.url_params = ""; } else { config.external_config = undefined; } @@ -2791,6 +2925,54 @@ function openExternalUrl(url: string) { + + + + +