Files
dbx/apps/desktop/src/components/connection/ConnectionDialog.vue
T

10682 lines
562 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { ObjectDirective } from "vue";
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { HelpTooltip, Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import type {
ConnectionConfig,
ConnectionTestResult,
DatabaseConnectionInfo,
DatabaseType,
HttpTunnelConfig,
IdentifierCase,
InstalledPlugin,
JdbcDriverInfo,
JdbcLocalBundleInfo,
JdbcMavenBundleInfo,
PluginConnectionAction,
PluginFormField,
PluginFormFieldValue,
ProxyTunnelConfig,
SshConfigHostEntry,
SshTunnelConfig,
TransportLayerConfig,
} from "@/types/database";
import { CONNECTION_PICKER_OPTIONS, CONNECTION_PROFILES, CONNECTION_PROFILE_ICONS, type ConnectionPickerOption, type ConnectionProfileCategory, type ConnectionProfileDefinition } from "@/types/generated/connectionProfiles";
import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
import type { VictoriaMetricsExternalConfig } from "@/types/victoriametrics";
import type { SalesforceAuthContext, SalesforceAuthMode, SalesforceEnvironment, SalesforceExternalConfig, SalesforceOAuthAuthorizeParams, SalesforceOAuthDevicePollResult } from "@/types/salesforce";
import { SALESFORCE_OAUTH_CALLBACK_URL } from "@/types/salesforce";
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
import type { MqttConnectionConfig } from "@/types/mqtt";
import type { NacosAdminConfig, NacosApiPlane, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosNamespaceInfo, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos";
import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles";
import { insertSqliteRemoteTransportLayer, isSqliteRemoteTransportLayerType, sqliteRemoteTransportError } from "@/lib/connection/sqliteRemoteTransport";
import { sanitizeConnectionCredentials } from "@/lib/connection/credentialSanitizer";
import { applySshAuthMethod, inferSshAuthMethod } from "@/lib/connection/sshAuthMethod";
import { applySshConfigHostAliasPrefill as prefillSshConfigHostAlias } from "@/lib/connection/sshConfigHosts";
import { canPersistConnectionTestResult, connectionEditDraftSyncAction } from "./connectionEditDraftSync";
import { createConnectionNoteVisibilityDraft, persistConnectionNoteVisibilityDraft as persistConnectionNoteVisibilityDraftState, resetConnectionNoteVisibilityDraft, setConnectionNoteVisibilityDraft, syncConnectionNoteVisibilityDraft } from "./connectionNoteVisibilityDraft";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT, REDIS_SCAN_PAGE_SIZE_MIN, REDIS_SCAN_PAGE_SIZE_MAX, REDIS_SCAN_PAGE_SIZE_OPTIONS } from "@/lib/redis/redisKeyPattern";
import { normalizeRedisKeyTemplates, redisKeyTemplatesToTextarea } from "@/lib/redis/redisKeyTemplates";
import { normalizeRedisDatabaseValue } from "@/lib/redis/redisDatabaseIndex";
import { normalizeGlobalConnectTimeoutSecs, normalizeGlobalQueryTimeoutSecs, useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import PluginConnectionFields from "@/components/plugins/PluginConnectionFields.vue";
import PluginIcon from "@/components/plugins/PluginIcon.vue";
import * as api from "@/lib/backend/api";
import {
buildPluginConnectionConfig,
createFrontendPluginRegistry,
parsePluginConnectionProviderOptionValue,
pluginConnectionActionsForDialog,
pluginConnectionConnectTimeoutDefault,
pluginConnectionFormValues,
pluginConnectionProviderIcon,
pluginConnectionProviderOptionValue,
} from "@/lib/plugins/frontendPlugin";
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { applyMeilisearchBasePathToExternalConfig, applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnectionUrl } from "@/lib/connection/connectionUrl";
import { hasXuguConnectionDatabase } from "@/lib/connection/xuguDatabase";
import { DEFAULT_QUERY_TIMEOUT_SECS, MAX_CONNECT_TIMEOUT_SECS, MAX_QUERY_TIMEOUT_SECS } from "@/lib/connection/timeoutLimits";
import { buildOracleTnsConnectionString, normalizeOracleTnsAdminPath, parseOracleTnsConnectionString } from "@/lib/connection/oracleTnsConnection";
import { applyConnectionDeepLinkUpdate, resolveConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLinkUpdate";
import { connectionDeepLinkServiceHydrationValue, parseConnectionDeepLink, parseConnectionDeepLinkUpdate, parseServiceConnectionUrl, type ConnectionDeepLinkDraft, type ConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLink";
import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connection/connectionPresentation";
import { parseGaussdbHosts, serializeGaussdbHosts, type GaussdbHostEntry } from "@/lib/connection/gaussdbHosts";
import { h2ConnectionModeForConfig, h2FileJdbcUrlWithPath, h2FilePathFromJdbcUrl, isH2SplitJdbcUrl, type H2ConnectionMode } from "@/lib/database/h2Connection";
import { firstZooKeeperEndpoint, normalizeZooKeeperConnectString } from "@/lib/zookeeper/zookeeperConnection";
import { setZooKeeperAuthScheme, zooKeeperAuthScheme as resolveZooKeeperAuthScheme, type ZooKeeperAuthScheme } from "@/lib/zookeeper/zookeeperConnectionOptions";
import { isLocalFileTypeDb } from "@/lib/connection/connectionFile";
import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedVersion } from "@/lib/mq/mqPinnedVersionOptions";
import { mongodbAuthFailureHint, mongoConnectionUsesOidc, mongoUrlParam, mongoUrlParamIsTrue, normalizeMongoTlsFormState, setMongoUrlParam, setMongoUrlParamBoolean } from "@/lib/mongo/mongoConnectionOptions";
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
import { mysqlCleartextPasswordAuthEnabled, setMysqlCleartextPasswordAuthEnabled } from "@/lib/database/mysqlConnectionOptions";
import { applyDamengSslUrlParams, damengSslFormConfig } from "@/lib/database/damengSslOptions";
import { DAMENG_BUILTIN_DRIVER_PROFILE, DAMENG_CUSTOM_DRIVER_PROFILE, DAMENG_DEFAULT_JDBC_DRIVER_CLASS, damengCustomJdbcUrl, damengDriverModeForConfig, defaultDamengJdbcUrl, type DamengDriverMode } from "@/lib/database/damengDriverOptions";
import { doltSystemTablesVisible, isDoltDriverProfile, setDoltSystemTablesVisible } from "@/lib/database/doltProfile";
import { DamengJvmSystemPropertyError, damengJvmSystemPropertiesText, parseDamengJvmSystemProperties } from "@/lib/database/damengJvmOptions";
import { copyToClipboard } from "@/lib/common/clipboard";
import { configuredDatabaseProductName, connectionConfigFingerprint, databaseInfoCopyText, databaseInfoRows, normalizeDatabaseConnectionInfo, type DatabaseInfoField } from "@/lib/connection/connectionDatabaseInfo";
import { agentDriverInstallKey, appendAgentDriverUpdateHint, connectionUsesSsh, hasAgentDriverUpdate, showAgentDriverInstallHint, type AgentDriverInstallState, type DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
import { prestoSqlBuiltinDriverPaths } from "@/lib/database/prestoSqlBuiltinDriver";
import { JDBCX_DEFAULT_URL, JDBCX_DRIVER_PROFILE, JDBCX_JDBC_DRIVER_CLASS, ensureJdbcxRuntimeDrivers, isJdbcxRuntimeBundle, isJdbcxRuntimePath, jdbcxHighPrivilegeExtensionsEnabled, setJdbcxHighPrivilegeExtensionsEnabled } from "@/lib/database/jdbcxBuiltinDriver";
import { notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents";
import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDetection";
import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connection/connectionAttemptTimeout";
import { consulAgentAddressesMatch } from "@/lib/consul/agentTarget";
import { appendConnectionErrorHints, isJdbcMissingRuntimeDependencyError } from "@/lib/connection/connectionErrorHints";
import { buildCassandraExternalConfig, cassandraTlsConfigFromExternalConfig, type CassandraTlsConfig } from "@/lib/connection/cassandraTlsOptions";
import { savedMysqlTlsFormFields, supportsMysqlTlsOptions as mysqlTlsOptionsSupported, supportsMysqlTlsTab } from "@/lib/connection/mysqlTlsCapabilities";
import { preventDialogDocumentSelectAll } from "@/lib/connection/dialogTextSelection";
import { postgresLegacyTlsEnabled, postgresTlsModeForForm, setPostgresLegacyTlsEnabled } from "@/lib/connection/postgresTlsMode";
import { buildMqKafkaConnectionExtra, mqKafkaConnectionTarget, resolveMqKafkaConnectionSource, type MqKafkaConnectionSource } from "@/lib/connection/mqKafkaConnection";
import { assertCompleteDatabaseCategories, databaseSelectionForCategory } from "@/lib/connection/databaseCategoryOptions";
import { loadConnectionPickerView, saveConnectionPickerView, type DbPickerView } from "@/lib/connection/connectionPickerViewPreference";
import { normalizeRocketmqNamesrvAddr } from "@/lib/connection/rocketmqNamesrv";
import { normalizeRabbitmqAddresses, parseRabbitmqAddress } from "@/lib/connection/rabbitmqAddresses";
import { pluginFieldIsRequired, pluginFieldIsVisible } from "@/lib/plugins/pluginFieldConditions";
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
import { driverInstallProgressChannel, driverInstallProgressPercent, isDriverInstallProgressForOperation, requestAgentInstallCancellation, resolveAgentInstallOutcome, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import { requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityConfig, sqlServerUsesLegacyCompatibility, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
import { normalizeNacosConsoleUrl, normalizeNacosEndpoint, normalizeNacosMetricsUrl, parseNacosManagedNamespaces } from "@/lib/nacos/nacosAdmin";
import { loadReadableNacosNamespaces, nacosNamespaceIdentity, normalizeNacosNamespaceSelection } from "@/lib/nacos/nacosNamespaceVisibility";
import {
ArrowLeft,
ArrowDown,
ArrowUp,
Check,
CheckSquare,
ChevronRight,
CircleHelp,
Copy,
Database as DatabaseLucide,
ExternalLink,
FilePlus2,
FolderOpen,
GripVertical,
Grid3X3,
KeyRound,
Link2,
List,
ListFilter,
Loader2,
Pencil,
Pipette,
Plus,
RefreshCw,
Search,
ShieldAlert,
ShieldCheck,
Square,
Trash2,
} from "@lucide/vue";
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleObjectFiltersNeedReset } from "@/lib/connection/connectionVisibleDatabases";
import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, filterSchemaNamesForVisiblePicker, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
import { isSchemaAware, isSingleDatabase, supportsDataDictionary } from "@/lib/database/databaseFeatureSupport";
import { normalizeConnectionScope, normalizeConnectionTimeouts } from "@/lib/connection/connectionSubmitNormalization";
import { databaseConnectionFormKind } from "@/lib/database/databaseDriverManifest";
import VisibleSchemasDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
import CloudflareD1ConnectionFields from "@/components/connection/CloudflareD1ConnectionFields.vue";
import SpannerConnectionFields from "@/components/connection/SpannerConnectionFields.vue";
import { oceanbaseModeConnectionPatch, oceanbaseSubModeFromConfig } from "@/lib/database/oceanbaseConnectionMode";
import { translateBackendError } from "@/i18n/backend-errors";
import { applyHiveKerberosSubmitConfig, hiveKerberosFormConfig, type HiveKerberosAuthMode } from "@/lib/database/hiveKerberosOptions";
import { hasCloudflareD1Credentials, isCloudflareD1Connection, normalizeCloudflareD1Connection } from "@/lib/connection/cloudflareD1";
import { hasSpannerResourcePath, isSpannerConnection, normalizeSpannerConnection } from "@/lib/connection/spannerResourcePath";
import {
buildElasticsearchExternalConfig,
elasticsearchConnectionModeFromConfig,
elasticsearchConnectivityCheckDisabledFromConfig,
elasticsearchConnectivityCheckPathFromConfig,
elasticsearchIndexGroupingPatternFromConfig,
elasticsearchKibanaBasePathFromConfig,
type ElasticsearchConnectionMode,
} from "@/lib/connection/elasticsearchKibanaProxy";
import {
GAUSSDB_M_JDBC_DRIVER_CLASS,
gaussdbConnectionMode,
gaussdbCountQueryDop,
gaussdbIdentifierQuoteStyle,
gaussdbTargetServerType,
setGaussdbConnectionMode,
setGaussdbCountQueryDop,
setGaussdbIdentifierQuoteStyle,
setGaussdbTargetServerType,
supportsGaussdbIdentifierQuoteStyle,
type GaussdbConnectionMode,
type GaussdbCountQueryDop,
type GaussdbIdentifierQuoteStyle,
type GaussdbTargetServerType,
} from "@/lib/database/jdbcDialect";
import { normalizeStoredConnectionDatabase } from "@/lib/database/sqliteNamespace";
import {
createJdbcProductConnectionFieldsByMode,
isJdbcProductDefaultDriverClass,
isJdbcProductManagedMavenCoordinate,
isJdbcProductManagedMavenPath,
jdbcProductConnectionDefaults,
jdbcProductManagedRuntimePaths,
jdbcProductMode,
jdbcProductRuntimeSelectionId,
rememberJdbcProductConnectionFields,
type JdbcProductConnectionFieldsByMode,
} from "@/lib/database/jdbcProductProfile";
import {
ensureRegisteredJdbcProductRuntimeDrivers,
isRegisteredJdbcProductRuntimeInstallError,
jdbcProductDriverProfiles,
jdbcProductIconTypes,
jdbcProductPickerOptions,
jdbcProductProfileDefinition,
jdbcProductProfileForConfig,
jdbcProductProfileIdsForCategory,
} from "@/lib/database/jdbcProductProfiles";
type DbOption = Omit<ConnectionPickerOption, "category"> & { category?: DbCategoryKey; plugin?: boolean; pluginId?: string; pluginIcon?: string };
type DbCategoryKey = ConnectionProfileCategory | "plugins";
type DbCategory = { key: DbCategoryKey; title: string; options: DbOption[] };
type DialogStep = "select" | "config";
export type ConfigTab = "connection" | "advanced" | "tls" | "transport";
type ProductionScope = "connection" | "databases";
type MqTokenSigningMode = "none" | "hs256" | "rs256";
type NacosAuthKind = NacosAuthConfig["kind"];
type NacosConnectionProfile = "v2" | "v3" | "rnacos";
type DremioConnectionMode = "arrow-flight-sql" | "legacy";
type JdbcDriverSelectItem = {
id: string;
label: string;
paths: string[];
jdbcxRuntime: boolean;
managedProductRuntime?: boolean;
};
const DREMIO_ARROW_FLIGHT_SQL_JDBC_URL = "jdbc:arrow-flight-sql://127.0.0.1:32010";
const DREMIO_ARROW_FLIGHT_SQL_JDBC_DRIVER_CLASS = "org.apache.arrow.driver.jdbc.ArrowFlightJdbcDriver";
const DREMIO_LEGACY_JDBC_URL = "jdbc:dremio:direct=127.0.0.1:31010";
const DREMIO_LEGACY_JDBC_DRIVER_CLASS = "com.dremio.jdbc.Driver";
const DEFAULT_SSH_USER = "root";
const DIRECT_SIDEBAR_OBJECT_TYPES = new Set<DatabaseType>(["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "milvus", "qdrant", "weaviate", "chromadb", "mq", "mqtt", "nacos", "plugin"]);
const ETCD_GRPC_MAX_INBOUND_DEFAULT_MIB = 32;
const ETCD_GRPC_MAX_INBOUND_MIN_MIB = 1;
const ETCD_GRPC_MAX_INBOUND_MAX_MIB = 256;
const ETCD_GRPC_MAX_INBOUND_PARAM = "grpc_max_inbound_message_size";
const NACOS_CONNECTION_PROFILES: ReadonlyArray<{ value: NacosConnectionProfile; title: string }> = [
{ value: "v2", title: "Nacos 2.x" },
{ value: "v3", title: "Nacos 3.x" },
{ value: "rnacos", title: "r-nacos" },
];
type IgniteConnectionProfile = "ignite" | "ignite3";
const IGNITE_CONNECTION_PROFILES: ReadonlyArray<{ value: IgniteConnectionProfile; title: string }> = [
{ value: "ignite", title: "Ignite 2.x" },
{ value: "ignite3", title: "Ignite 3.x" },
];
// The picker merges the Ignite 2.x/3.x cards into a single "Apache Ignite"
// entry; the version is picked inside the connection form instead.
const MERGED_PICKER_OPTION_FOR_TYPE: Record<string, string> = { ignite3: "ignite" };
const PICKER_SEARCH_ALIASES: Record<string, string[]> = { ignite: ["ignite3", "ignite 3"] };
type LegacyTransportFields = {
ssh_enabled?: boolean;
ssh_host?: string;
ssh_port?: number;
ssh_user?: string;
ssh_password?: string;
ssh_key_path?: string;
ssh_key_passphrase?: string;
ssh_expose_lan?: boolean;
ssh_connect_timeout_secs?: number;
ssh_tunnels?: SshTunnelConfig[];
proxy_enabled?: boolean;
proxy_type?: "socks5" | "http";
proxy_host?: string;
proxy_port?: number;
proxy_username?: string;
proxy_password?: string;
};
type LegacyConnectionConfig = ConnectionConfig & LegacyTransportFields;
type ConnectionForm = Omit<ConnectionConfig, "id">;
type ConnectionTestState = ConnectionTestResult & { ok: boolean; scope?: "connection" | "ssh" };
type PluginActionStatus = { ok: boolean; message: string };
type SaveConnectionOptions = { connectAfterSave?: boolean; closeOnSuccess?: boolean };
const { t, locale: appLocale } = useI18n();
const { toast } = useToast();
const settingsStore = useSettingsStore();
const connectionNoteVisibilityDraft = reactive(createConnectionNoteVisibilityDraft(settingsStore.editorSettings.sidebarShowConnectionNotes));
const showConnectionNotesInSidebar = computed({
get: () => connectionNoteVisibilityDraft.value,
set: (value: boolean) => setConnectionNoteVisibilityDraft(connectionNoteVisibilityDraft, value),
});
const editGlobalConnectTimeoutSecs = ref(settingsStore.editorSettings.globalConnectTimeoutSecs);
const editGlobalQueryTimeoutSecs = ref(settingsStore.editorSettings.globalQueryTimeoutSecs);
const open = defineModel<boolean>("open", { default: false });
const isDesktop = isTauriRuntime();
const props = defineProps<{
editConfig?: ConnectionConfig;
prefillConfig?: ConnectionDeepLinkDraft | null;
updatePrefill?: ConnectionDeepLinkUpdate | null;
pluginProvider?: PluginCenterFocus | null;
initialTab?: ConfigTab;
}>();
const emit = defineEmits<{
connectStarted: [name: string];
connectSucceeded: [name: string];
connectFailed: [message: string];
openDriverStore: [focus?: DriverStoreFocus];
openTunnelProfileSettings: [];
}>();
const store = useConnectionStore();
const UNGROUPED_CONNECTION_GROUP = "__dbx_ungrouped_connection_group__";
const selectedConnectionGroupId = ref<string | null>(null);
const connectionGroupSelectValue = computed({
get: () => selectedConnectionGroupId.value ?? UNGROUPED_CONNECTION_GROUP,
set: (value: string) => {
selectedConnectionGroupId.value = value === UNGROUPED_CONNECTION_GROUP ? null : value;
},
});
const connectionGroupOptions = computed(() => store.connectionGroupOptions.map((group) => ({ id: group.id, label: group.path.join(" / ") })));
function initialConnectionGroupId(): string | null {
const preferred = store.newConnectionGroupId ?? store.selectedConnectionGroupId;
return preferred && connectionGroupOptions.value.some((group) => group.id === preferred) ? preferred : null;
}
const tunnelProfileStore = useTunnelProfileStore();
const isTesting = ref(false);
const isTestingSshTunnel = ref(false);
const isSaving = ref(false);
const testResult = ref<ConnectionTestState | null>(null);
const testedConfigFingerprint = ref("");
const testedConfigId = ref("");
const testedGeneratedName = ref("");
const savedDatabaseInfo = ref<DatabaseConnectionInfo | null>(null);
const savedDatabaseInfoFingerprint = ref("");
const savedConnectionConfigFingerprint = ref("");
const showAgentInstallDialog = ref(false);
const agentInstallRunning = ref(false);
const agentInstallOperationId = ref<string | null>(null);
const agentInstallDriverKey = ref("");
const agentInstallLabel = ref("");
const agentInstallProgress = ref<DriverInstallProgress | null>(null);
const agentInstallError = ref("");
const agentInstallCancelError = ref("");
const agentInstallCancelling = ref(false);
/** Set when the user cancels from the modal, so the pending promise's
* "canceled by user" error is treated as a non-failure by its caller. */
const agentInstallCancelRequested = ref(false);
const showConnectionErrorDialog = ref(false);
const connectionErrorRawDetail = ref("");
const connectionErrorDetail = ref("");
const testResultCopied = ref(false);
const connectionErrorCopied = ref(false);
const editingId = ref<string | null>(null);
const pluginFormValues = ref<Record<string, PluginFormFieldValue>>({});
const pluginLoadError = ref("");
const runningPluginActionId = ref<string | null>(null);
const pluginActionStatus = ref<PluginActionStatus | null>(null);
const draftTestConnectionId = ref(uuid());
const showVisibleDatabasesDialog = ref(false);
const isLoadingVisibleDatabases = ref(false);
const visibleDatabaseNames = ref<string[]>([]);
const visibleDatabaseSelection = ref<Set<string>>(new Set());
const visibleDatabaseSearchText = ref("");
const visibleDatabaseError = ref("");
const visibleDatabaseShowSystem = ref(false);
const showVisibleNacosNamespacesDialog = ref(false);
const isLoadingVisibleNacosNamespaces = ref(false);
const visibleNacosNamespaces = ref<NacosNamespaceInfo[]>([]);
const visibleNacosNamespaceSelection = ref<Set<string>>(new Set());
const visibleNacosNamespaceSearchText = ref("");
const visibleNacosNamespaceError = ref("");
const visibleNacosNamespaceListingPermissionDenied = ref(false);
const isResolvingManualNacosNamespaces = ref(false);
const visibleNacosNamespaceAccessMode = ref<"automatic" | "manual">("automatic");
const nacosDynamicAllNamespaces = ref(false);
const visibleNacosNamespaceDynamicAllSupported = ref(false);
const showProductionDatabasesDialog = ref(false);
const isLoadingProductionDatabases = ref(false);
const productionDatabaseNames = ref<string[]>([]);
const productionDatabaseSelection = ref<Set<string>>(new Set());
const productionDatabaseSearchText = ref("");
const productionDatabaseError = ref("");
const productionProtectionEnabled = ref(false);
const showVisibleSchemasDialog = ref(false);
const isLoadingVisibleSchemas = ref(false);
const visibleSchemaNames = ref<string[]>([]);
const visibleSchemaInitialSelection = ref<string[]>([]);
const visibleSchemaError = ref("");
const installedPlugins = ref<InstalledPlugin[]>([]);
let testRunId = 0;
let unlistenAgentInstallProgress: (() => void) | null = null;
function initialConfigTab(): ConfigTab {
return props.initialTab ?? "connection";
}
const defaultForm = (): ConnectionForm => ({
name: "",
note: "",
db_type: "mysql",
driver_profile: "mysql",
driver_label: "MySQL",
url_params: "",
agent_java_options: [],
host: "127.0.0.1",
port: 3306,
username: "root",
password: "",
database: undefined,
color: "",
transport_layers: [],
connect_timeout_secs: settingsStore.editorSettings.globalConnectTimeoutSecs,
connect_timeout_inherit: true,
query_timeout_secs: settingsStore.editorSettings.globalQueryTimeoutSecs,
query_timeout_inherit: true,
idle_timeout_secs: 60,
keepalive_interval_secs: 30,
ssl: false,
ca_cert_path: "",
client_cert_path: "",
client_key_path: "",
sysdba: false,
oracle_connection_type: "service_name",
connection_string: undefined,
jdbc_driver_class: undefined,
jdbc_driver_paths: [],
redis_connection_mode: "standalone",
redis_sentinel_master: "",
redis_sentinel_nodes: "",
redis_sentinel_username: "",
redis_sentinel_password: "",
redis_sentinel_tls: false,
redis_cluster_nodes: "",
redis_key_separator: ":",
redis_scan_page_size: REDIS_SCAN_PAGE_SIZE_DEFAULT,
redis_key_templates: [],
etcd_endpoints: "",
gbase_server: "",
informix_server: "",
external_config: undefined,
init_script: undefined,
docs_notes_path: undefined,
read_only: false,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
is_production: false,
production_databases: [],
visible_databases: undefined,
save_password: true,
});
const cassandraTls = reactive<CassandraTlsConfig>(cassandraTlsConfigFromExternalConfig(undefined));
function resetCassandraTlsFields(externalConfig: unknown) {
Object.assign(cassandraTls, cassandraTlsConfigFromExternalConfig(externalConfig));
}
const elasticsearchConnectionMode = ref<ElasticsearchConnectionMode>("direct");
const elasticsearchKibanaBasePath = ref("");
const elasticsearchConnectivityCheckPath = ref("");
const elasticsearchConnectivityCheckDisabled = ref(false);
const elasticsearchIndexGroupingPattern = ref("");
const elasticsearchConnectionPorts = ref<Record<ElasticsearchConnectionMode, number>>({
direct: 9200,
kibana: 5601,
});
function resetElasticsearchProxyFields(externalConfig?: unknown) {
const mode = elasticsearchConnectionModeFromConfig(externalConfig);
elasticsearchConnectionMode.value = mode;
elasticsearchKibanaBasePath.value = elasticsearchKibanaBasePathFromConfig(externalConfig);
elasticsearchConnectivityCheckPath.value = elasticsearchConnectivityCheckPathFromConfig(externalConfig);
elasticsearchConnectivityCheckDisabled.value = elasticsearchConnectivityCheckDisabledFromConfig(externalConfig);
elasticsearchIndexGroupingPattern.value = elasticsearchIndexGroupingPatternFromConfig(externalConfig);
elasticsearchConnectionPorts.value = {
direct: mode === "direct" ? form.value.port : 9200,
kibana: mode === "kibana" ? form.value.port : 5601,
};
}
function switchElasticsearchConnectionMode(mode: ElasticsearchConnectionMode) {
if (mode === elasticsearchConnectionMode.value) return;
elasticsearchConnectionPorts.value[elasticsearchConnectionMode.value] = form.value.port;
form.value.port = elasticsearchConnectionPorts.value[mode];
elasticsearchConnectionMode.value = mode;
resetTestState();
}
function defaultSshTunnel(): SshTunnelConfig {
return {
id: uuid(),
name: "",
enabled: true,
host: "",
port: 22,
user: DEFAULT_SSH_USER,
password: "",
key_path: "",
key_passphrase: "",
connect_timeout_secs: 5,
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: "",
auth_method: "password",
allow_exec_channel_proxy: false,
};
}
function normalizeSshTunnel(hop: Partial<SshTunnelConfig>): SshTunnelConfig {
return {
id: hop.id || uuid(),
name: hop.name || "",
enabled: hop.enabled !== false,
host: hop.host || "",
port: Number(hop.port) || 22,
user: hop.user?.trim() || DEFAULT_SSH_USER,
password: hop.password || "",
key_path: hop.key_path || "",
key_passphrase: hop.key_passphrase || "",
connect_timeout_secs: Number(hop.connect_timeout_secs) || 5,
expose_lan: !!hop.expose_lan,
use_ssh_agent: !!hop.use_ssh_agent,
ssh_agent_sock_path: hop.ssh_agent_sock_path || "",
auth_method: hop.auth_method || inferSshAuthMethod(hop),
allow_exec_channel_proxy: !!hop.allow_exec_channel_proxy,
profile_id: hop.profile_id || undefined,
};
}
function defaultProxyTunnel(): ProxyTunnelConfig {
return {
id: uuid(),
name: "",
enabled: true,
proxy_type: "socks5",
host: "",
port: 1080,
username: "",
password: "",
};
}
function defaultHttpTunnel(): HttpTunnelConfig {
return {
id: uuid(),
name: "",
enabled: true,
url: "",
token: "",
connect_timeout_secs: 10,
};
}
function normalizeProxyTunnel(layer: Partial<ProxyTunnelConfig>): ProxyTunnelConfig {
return {
id: layer.id || uuid(),
name: layer.name || "",
enabled: layer.enabled !== false,
proxy_type: layer.proxy_type || "socks5",
host: layer.host || "",
port: Number(layer.port) || 1080,
username: layer.username || "",
password: layer.password || "",
profile_id: layer.profile_id || undefined,
};
}
function normalizeHttpTunnel(layer: Partial<HttpTunnelConfig>): HttpTunnelConfig {
return {
id: layer.id || uuid(),
name: layer.name || "",
enabled: layer.enabled !== false,
url: layer.url || "",
token: layer.token || "",
connect_timeout_secs: Number(layer.connect_timeout_secs) || 10,
profile_id: layer.profile_id || undefined,
};
}
function normalizeTransportLayer(layer: Partial<TransportLayerConfig>): TransportLayerConfig {
if (layer.type === "proxy") {
return { type: "proxy", ...normalizeProxyTunnel(layer) };
}
if (layer.type === "http_tunnel") {
return { type: "http_tunnel", ...normalizeHttpTunnel(layer) };
}
return { type: "ssh", ...normalizeSshTunnel(layer as Partial<SshTunnelConfig>) };
}
function transportLayersForConfig(config: LegacyConnectionConfig): TransportLayerConfig[] {
if (config.transport_layers?.length) {
return config.transport_layers.map(normalizeTransportLayer);
}
const layers: TransportLayerConfig[] = sshLayersForConfig(config).map((hop) => ({ type: "ssh", ...hop }));
if (config.proxy_enabled || config.proxy_host || config.proxy_username || config.proxy_password) {
layers.push({
type: "proxy",
...normalizeProxyTunnel({
id: "legacy-proxy",
enabled: true,
proxy_type: config.proxy_type || "socks5",
host: config.proxy_host || "",
port: config.proxy_port || 1080,
username: config.proxy_username || "",
password: config.proxy_password || "",
}),
});
}
return layers;
}
function sshLayersForConfig(config: LegacyConnectionConfig): SshTunnelConfig[] {
if (config.ssh_tunnels?.length) {
return config.ssh_tunnels.map(normalizeSshTunnel);
}
if (config.ssh_enabled || config.ssh_host || config.ssh_user || config.ssh_password || config.ssh_key_path || config.ssh_key_passphrase) {
return [
normalizeSshTunnel({
id: "legacy",
enabled: true,
host: config.ssh_host || "",
port: config.ssh_port || 22,
user: config.ssh_user || "",
password: config.ssh_password || "",
key_path: config.ssh_key_path || "",
key_passphrase: config.ssh_key_passphrase || "",
connect_timeout_secs: config.ssh_connect_timeout_secs || 5,
expose_lan: config.ssh_expose_lan || false,
use_ssh_agent: false,
ssh_agent_sock_path: "",
}),
];
}
return [];
}
const form = ref(defaultForm());
const supportsAutomaticTableLoading = computed(() => supportsDataDictionary(form.value.db_type) && !DIRECT_SIDEBAR_OBJECT_TYPES.has(form.value.db_type));
const redisKeyTemplatesText = ref("");
const noteTextareaRef = ref<HTMLTextAreaElement | null>(null);
const showGaussdbConnectionMode = computed(() => form.value.db_type === "gaussdb");
const gaussdbDriverMode = computed<GaussdbConnectionMode>({
get: () => gaussdbConnectionMode(form.value),
set: (mode) => {
setGaussdbConnectionMode(form.value, mode);
resetTestState();
},
});
const isGaussdbMJdbcConnection = computed(() => gaussdbDriverMode.value === "m-jdbc");
const showGaussdbIdentifierQuoteStyle = computed(() => supportsGaussdbIdentifierQuoteStyle(form.value));
const gaussdbQuoteStyle = computed<GaussdbIdentifierQuoteStyle>({
get: () => gaussdbIdentifierQuoteStyle(form.value),
set: (style) => {
setGaussdbIdentifierQuoteStyle(form.value, style);
resetTestState();
},
});
const gaussdbTargetServerTypeComputed = computed<GaussdbTargetServerType>({
get: () => gaussdbTargetServerType(form.value),
set: (value) => {
setGaussdbTargetServerType(form.value, value);
resetTestState();
},
});
const gaussdbCountQueryDopComputed = computed<GaussdbCountQueryDop>({
get: () => gaussdbCountQueryDop(form.value),
set: (value) => {
setGaussdbCountQueryDop(form.value, value);
resetTestState();
},
});
const gaussdbHostEntries = ref<GaussdbHostEntry[]>(parseGaussdbHosts(form.value.host, form.value.port));
watch(
() => form.value.db_type,
(dbType) => {
if (dbType === "gaussdb") {
gaussdbHostEntries.value = parseGaussdbHosts(form.value.host, form.value.port);
}
},
);
watch(
() => [form.value.host, form.value.port] as const,
([host, port]) => {
if (form.value.db_type === "gaussdb") {
gaussdbHostEntries.value = parseGaussdbHosts(host, port);
}
},
);
function addGaussdbHostEntry() {
const lastPort = gaussdbHostEntries.value.length > 0 ? gaussdbHostEntries.value[gaussdbHostEntries.value.length - 1].port : 5432;
gaussdbHostEntries.value.push({ host: "", port: lastPort });
}
function removeGaussdbHostEntry(idx: number) {
if (gaussdbHostEntries.value.length <= 1) return;
gaussdbHostEntries.value.splice(idx, 1);
}
function resizeNoteTextarea() {
const textarea = noteTextareaRef.value;
if (!textarea) return;
const style = window.getComputedStyle(textarea);
const lineHeight = Number.parseFloat(style.lineHeight) || 20;
const paddingHeight = (Number.parseFloat(style.paddingTop) || 0) + (Number.parseFloat(style.paddingBottom) || 0);
const borderHeight = (Number.parseFloat(style.borderTopWidth) || 0) + (Number.parseFloat(style.borderBottomWidth) || 0);
const maxContentHeight = lineHeight * 3 + paddingHeight;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, maxContentHeight) + borderHeight}px`;
textarea.style.overflowY = textarea.scrollHeight > maxContentHeight ? "auto" : "hidden";
}
const showJdbcDependencyDriverManagerAction = computed(() => form.value.db_type === "jdbc" && (isJdbcMissingRuntimeDependencyError(connectionErrorRawDetail.value) || isRegisteredJdbcProductRuntimeInstallError(form.value, connectionErrorRawDetail.value)));
function externalConfigRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record<string, unknown>) } : {};
}
function meilisearchConnectionUrl(config: Pick<ConnectionConfig, "host" | "port" | "ssl" | "url_params" | "external_config">): string {
const host = config.host.trim();
if (!host) return "";
const scheme = config.ssl ? "https" : "http";
const endpointHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
const externalConfig = externalConfigRecord(config.external_config);
const storedBasePath = externalConfig.basePath ?? externalConfig.base_path;
const basePathSegments = typeof storedBasePath === "string" ? storedBasePath.trim().split("/").filter(Boolean) : [];
const basePath = basePathSegments.length ? `/${basePathSegments.join("/")}` : "";
const urlParams = config.url_params?.trim().replace(/^\?/, "") || "";
return `${scheme}://${endpointHost}:${config.port}${basePath}${urlParams ? `?${urlParams}` : ""}`;
}
function syncMeilisearchHostInput(config: Pick<ConnectionConfig, "host" | "port" | "ssl" | "url_params" | "external_config">) {
meilisearchHostInput.value = meilisearchConnectionUrl(config);
appliedMeilisearchHostInput.value = meilisearchHostInput.value;
}
function resetMeilisearchHostInput() {
meilisearchHostInput.value = "";
appliedMeilisearchHostInput.value = "";
}
function sqlServerPortExplicitFromConfig(config: Pick<ConnectionConfig, "db_type" | "external_config">): boolean {
if (config.db_type !== "sqlserver") return false;
const external = externalConfigRecord(config.external_config);
return external.portExplicit === true || external.port_explicit === true;
}
function setSqlServerPortExplicit(config: Pick<ConnectionConfig, "db_type"> & { external_config?: unknown }, explicit: boolean) {
if (config.db_type !== "sqlserver") return;
const next = externalConfigRecord(config.external_config);
delete next.port_explicit;
if (explicit) {
next.portExplicit = true;
} else {
delete next.portExplicit;
}
config.external_config = Object.keys(next).length > 0 ? next : undefined;
}
function markSqlServerPortExplicit() {
setSqlServerPortExplicit(form.value, true);
}
const keepaliveEnabled = computed({
get: () => Number(form.value.keepalive_interval_secs) > 0,
set: (enabled: boolean) => {
if (enabled) {
const current = Number(form.value.keepalive_interval_secs);
form.value.keepalive_interval_secs = Number.isFinite(current) && current > 0 ? current : 30;
} else {
form.value.keepalive_interval_secs = 0;
}
},
});
const selectedTransportLayerId = ref<string | null>(null);
const draggedTransportLayerId = ref<string | null>(null);
const selectedType = ref("mysql");
const pluginRegistry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
const pluginConnectionProviders = computed(() => pluginRegistry.value.listConnectionProviders());
const selectedPluginProvider = computed(() => pluginProviderEntryForOption(selectedType.value));
const selectedPluginIcon = computed(() => (selectedPluginProvider.value ? pluginConnectionProviderIcon(selectedPluginProvider.value) : undefined));
const isPluginConnection = computed(() => form.value.db_type === "plugin");
const pluginFooterActions = computed<PluginConnectionAction[]>(() => (selectedPluginProvider.value ? pluginConnectionActionsForDialog(selectedPluginProvider.value.contribution, !!editingId.value) : []));
const pluginFooterBusy = computed(() => isTesting.value || isSaving.value || !!runningPluginActionId.value);
const customDriverName = ref("");
const mongoUseUrl = ref(false);
const jdbcDriverPathsInput = ref("");
const jdbcDrivers = ref<JdbcDriverInfo[]>([]);
const jdbcMavenBundles = ref<JdbcMavenBundleInfo[]>([]);
const jdbcLocalBundles = ref<JdbcLocalBundleInfo[]>([]);
const sshConfigHosts = ref<SshConfigHostEntry[]>([]);
const agentDrivers = ref<AgentDriverInstallState[]>([]);
const selectedJdbcDriverPath = ref("");
const jdbcManualClasspathOpen = ref(false);
const connectionUrlInput = ref("");
const appliedConnectionUrlInput = ref("");
const meilisearchHostInput = ref("");
const appliedMeilisearchHostInput = ref("");
const oracleTnsAdminPath = ref("");
const oceanbaseSubMode = ref<"mysql" | "oracle">("mysql");
const h2ConnectionMode = ref<H2ConnectionMode>("file");
const dremioConnectionMode = ref<DremioConnectionMode>("legacy");
const dremioConnectionUrls = ref<Record<DremioConnectionMode, string>>({
"arrow-flight-sql": DREMIO_ARROW_FLIGHT_SQL_JDBC_URL,
legacy: DREMIO_LEGACY_JDBC_URL,
});
const jdbcProductConnectionMode = ref("");
const jdbcProductConnectionFields = ref<JdbcProductConnectionFieldsByMode>({});
const activeJdbcProductProfile = computed(() => jdbcProductProfileForConfig(form.value));
const activeJdbcProductMode = computed(() => {
const profile = activeJdbcProductProfile.value;
return profile ? jdbcProductMode(profile, jdbcProductConnectionMode.value) : undefined;
});
const hiveAuthMode = ref<HiveKerberosAuthMode>("none");
const hivePrincipal = ref("");
const hiveKrb5ConfPath = ref("");
const hiveJaasConfigPath = ref("");
const hiveUseSubjectCredsOnlyFalse = ref(false);
const hiveExtraJavaOptions = ref("");
const damengJvmOptions = ref("");
const dialogStep = ref<DialogStep>("select");
const dbPickerView = ref<DbPickerView>(loadConnectionPickerView());
const dbSearchQuery = ref("");
const selectedDbCategory = ref<DbCategoryKey>("sql");
const configTab = ref<ConfigTab>("connection");
// 对话框拖动功能
const dragOffset = ref({ x: 0, y: 0 });
const isDraggingDialog = ref(false);
const dragStartPos = ref({ x: 0, y: 0 });
const dragStartOffset = ref({ x: 0, y: 0 });
const activePointerId = ref<number | null>(null);
// 计算对话框的定位样式(通过 transform 实现拖动)
const dialogContentStyle = computed(() => {
if (isDraggingDialog.value || dragOffset.value.x !== 0 || dragOffset.value.y !== 0) {
return {
transform: `translate(${dragOffset.value.x}px, ${dragOffset.value.y}px)`,
transition: isDraggingDialog.value ? "none" : "transform 0.15s ease-out",
};
}
return {};
});
// 开始拖动(在 DialogHeader 上按下鼠标/触摸)
function onDialogHeaderPointerDown(e: PointerEvent) {
if (e.button !== undefined && e.button !== 0) return;
isDraggingDialog.value = true;
activePointerId.value = e.pointerId;
dragStartPos.value = { x: e.clientX, y: e.clientY };
dragStartOffset.value = { ...dragOffset.value };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
// 拖动中
function onDialogHeaderPointerMove(e: PointerEvent) {
if (!isDraggingDialog.value || e.pointerId !== activePointerId.value) return;
const dx = e.clientX - dragStartPos.value.x;
const dy = e.clientY - dragStartPos.value.y;
dragOffset.value = {
x: dragStartOffset.value.x + dx,
y: dragStartOffset.value.y + dy,
};
}
// 结束拖动
function onDialogHeaderPointerEnd(e: PointerEvent) {
if (!isDraggingDialog.value || e.pointerId !== activePointerId.value) return;
isDraggingDialog.value = false;
activePointerId.value = null;
try {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
// 忽略 release 失败的错误
}
}
// 重置拖动位置
function resetDialogDragOffset() {
dragOffset.value = { x: 0, y: 0 };
isDraggingDialog.value = false;
activePointerId.value = null;
}
// 监听对话框 open 状态,重置位置
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => resetDialogDragOffset());
} else {
resetDialogDragOffset();
}
});
watch([() => form.value.note, configTab, dialogStep, open], () => {
void nextTick(resizeNoteTextarea);
});
const MQ_KAFKA_SECURITY_PROTOCOL_AUTO = "__auto";
const mqAdminUrl = ref("http://127.0.0.1:8080");
const mqSystemKind = ref<MqSystemKind>("pulsar");
const mqKafkaConnectionSource = ref<MqKafkaConnectionSource>("bootstrap");
const mqRocketmqNamesrvAddr = ref("127.0.0.1:9876");
const mqRocketmqClusterName = ref("");
const mqRabbitmqAddresses = ref("127.0.0.1:5672");
const mqRabbitmqVirtualHost = ref("/");
const mqKafkaBootstrapServers = ref("127.0.0.1:9092");
const mqKafkaZooKeeperServers = ref("");
const mqKafkaSecurityProtocol = ref(MQ_KAFKA_SECURITY_PROTOCOL_AUTO);
const mqKafkaSaslMechanism = ref("PLAIN");
const mqKafkaKerberosPrincipal = ref("");
const mqKafkaKerberosKeytabPath = ref("");
const mqKafkaKerberosServiceName = ref("kafka");
const mqKafkaKrb5ConfPath = ref("");
const mqAuthKind = ref<MqUiAuthKind>("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 MQ_DRIVER_LABELS: Record<MqSystemKind, string> = {
pulsar: "Apache Pulsar",
kafka: "Apache Kafka",
rocketmq: "Apache RocketMQ",
rabbitmq: "RabbitMQ",
};
function mqSystemKindFromProfile(profile: string): MqSystemKind {
if (profile === "kafka") return "kafka";
if (profile === "rocketmq") return "rocketmq";
if (profile === "rabbitmq") return "rabbitmq";
return "pulsar";
}
function syncMqSystemKindFromSelectedType() {
if (form.value.db_type !== "mq") return;
mqSystemKind.value = mqSystemKindFromProfile(selectedType.value);
}
function resolveMqSystemKind(config?: Partial<MqAdminConfig>): MqSystemKind {
if (config?.systemKind === "kafka" || config?.systemKind === "rocketmq" || config?.systemKind === "rabbitmq" || config?.systemKind === "pulsar") {
return config.systemKind;
}
return mqSystemKindFromProfile(selectedType.value);
}
const mqKafkaSecurityProtocolOptions = computed(() => [
{ value: MQ_KAFKA_SECURITY_PROTOCOL_AUTO, label: t("connection.mqSecurityAuto") },
{ value: "PLAINTEXT", label: "PLAINTEXT" },
{ value: "SSL", label: "SSL" },
{ value: "SASL_PLAINTEXT", label: "SASL_PLAINTEXT" },
{ value: "SASL_SSL", label: "SASL_SSL" },
]);
const mqKafkaConnectionSourceOptions = computed(() => [
{ value: "bootstrap" as const, label: t("connection.mqKafkaConnectionSourceBootstrap") },
{ value: "zookeeper" as const, label: t("connection.mqKafkaConnectionSourceZooKeeper") },
]);
const mqKafkaSaslMechanismOptions = [
{ value: "PLAIN", label: "PLAIN" },
{ value: "SCRAM-SHA-256", label: "SCRAM-SHA-256" },
{ value: "SCRAM-SHA-512", label: "SCRAM-SHA-512" },
];
const nacosImplementation = ref<NacosImplementation>("nacos");
// Nacos 2 and 3 expose different API planes. New connections must therefore
// choose an explicit version instead of relying on endpoint-shape guessing.
const nacosVersionMode = ref<NacosVersionMode>("v2");
const nacosApiPlane = ref<NacosApiPlane>("admin");
const nacosServerAddr = ref("");
const nacosContextPath = ref("");
const nacosConsoleUrl = ref("");
const nacosManagedNamespacesText = ref("");
const nacosRNacosConsoleAddr = ref("");
const nacosHistoryEnabled = ref(false);
const nacosConsoleAuthKind = ref<NacosRNacosConsoleAuth["kind"]>("inherit");
const nacosConsoleUsername = ref("");
const nacosConsolePassword = ref("");
const nacosAuthKind = ref<NacosAuthKind>("none");
const nacosUsername = ref("nacos");
const nacosPassword = ref("");
const nacosTlsSkipVerify = ref(false);
const nacosMetricsMode = ref<NacosMetricsMode>("auto");
const nacosMetricsUrl = ref("");
const nacosPageSize = ref(20);
let nacosScopeFingerprintBaseline = "";
function currentNacosScopeFingerprint(): string {
return JSON.stringify([nacosImplementation.value, nacosVersionMode.value, nacosApiPlane.value, nacosServerAddr.value.trim().replace(/\/+$/, ""), nacosContextPath.value.trim(), nacosAuthKind.value, nacosUsername.value.trim()]);
}
type ConsulConsistency = "default" | "stale" | "consistent";
const consulServerAddr = ref("http://127.0.0.1:8500");
const consulDatacenter = ref("");
const consulNamespace = ref("");
const consulPartition = ref("");
const consulConsistency = ref<ConsulConsistency>("default");
const consulTlsSkipVerify = ref(false);
const consulAgentTargetNode = ref("");
const consulAgentTargetAddress = ref("");
const consulMeshVisible = ref(false);
const consulOperatorVisible = ref(false);
const consulOperatorSnapshotRestoreEnabled = ref(false);
const consulOperatorAutopilotWriteEnabled = ref(false);
const consulOperatorRaftWriteEnabled = ref(false);
const consulOperatorKeyringWriteEnabled = ref(false);
const consulOperatorLicenseWriteEnabled = ref(false);
// --- MQTT-specific form fields ---
const mqttHost = ref("127.0.0.1");
const mqttPort = ref(1883);
const mqttClientId = ref("");
const mqttProtocolVersion = ref<"v3" | "v4" | "v5">("v5");
const mqttTransportMode = ref<"tcp" | "websocket">("tcp");
const mqttWsPath = ref("/mqtt");
const mqttAuthKind = ref<"none" | "password" | "certificate">("none");
const mqttUsername = ref("");
const mqttPassword = ref("");
const mqttCaCertPath = ref("");
const mqttClientCertPath = ref("");
const mqttClientKeyPath = ref("");
const mqttTls = ref(false);
const mqttTlsSkipVerify = ref(false);
const mqttKeepAliveSecs = ref(60);
const mqttConnectTimeoutSecs = ref(30);
const mqttMaxPacketSizeBytes = ref(16 * 1024 * 1024);
const mqttSavedTopics = ref<MqttConnectionConfig["savedTopics"]>([]);
const nacosPrimaryAddressPlaceholder = computed(() => {
if (nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console") {
return "http://127.0.0.1:8080";
}
return "http://127.0.0.1:8848/nacos";
});
const nacosWebConsoleUrlPlaceholder = computed(() => {
if (nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3") return "http://127.0.0.1:8080";
return "http://127.0.0.1:8848/nacos";
});
const nacosServiceAddressHint = computed(() => {
if (nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console") {
return t("nacos.nacosConsoleServiceAddressHint");
}
return t("nacos.nacosServiceAddressHint");
});
const nacosV3AdminEndpointWarning = computed(() => {
if (nacosImplementation.value !== "nacos" || nacosVersionMode.value !== "v3" || !nacosServerAddr.value.trim()) return "";
try {
const url = new URL(nacosServerAddr.value.trim());
if (nacosApiPlane.value === "admin" && url.port === "8080") {
return t("nacos.nacosV3ConsolePortWarning");
}
if (nacosApiPlane.value === "console" && url.port === "8848") return t("nacos.nacosV3AdminPortWarning");
} catch {
// The normal form validation will report an invalid URL on save.
}
return "";
});
const nacosMetricsUrlError = computed(() => {
if (nacosMetricsMode.value !== "custom") return "";
try {
normalizeNacosMetricsUrl(nacosMetricsUrl.value);
return "";
} catch {
return t("connection.nacosMetricsUrlInvalid");
}
});
const nacosConnectionProfile = computed<NacosConnectionProfile>(() => {
if (nacosImplementation.value === "rnacos") return "rnacos";
return nacosVersionMode.value === "v3" ? "v3" : "v2";
});
function selectNacosConnectionProfile(profile: NacosConnectionProfile) {
if (profile === "rnacos") {
nacosImplementation.value = "rnacos";
return;
}
nacosImplementation.value = "nacos";
nacosVersionMode.value = profile;
if (profile !== "v3") nacosApiPlane.value = "admin";
}
watch(nacosImplementation, (implementation) => {
if (implementation === "rnacos") nacosVersionMode.value = "v2";
if (implementation !== "rnacos") nacosHistoryEnabled.value = false;
});
const colorOptions = [
{ value: "", class: "bg-transparent border-dashed", labelKey: "connection.colorNone" },
{ value: "#22c55e", class: "bg-green-500", labelKey: "connection.colorGreen" },
{ value: "#eab308", class: "bg-yellow-500", labelKey: "connection.colorYellow" },
{ value: "#f97316", class: "bg-orange-500", labelKey: "connection.colorOrange" },
{ value: "#ef4444", class: "bg-red-500", labelKey: "connection.colorRed" },
{ value: "#3b82f6", class: "bg-blue-500", labelKey: "connection.colorBlue" },
{ value: "#a855f7", class: "bg-purple-500", labelKey: "connection.colorPurple" },
];
const isPresetColor = (color: string | undefined) => colorOptions.some((c) => c.value === (color || ""));
const customColorInput = ref("");
const customColorOpen = ref(false);
const jdbcDriverSelectItems = computed<JdbcDriverSelectItem[]>(() => {
const localBundles = jdbcLocalBundles.value.map((bundle) => ({
id: `local:${bundle.id}`,
label: bundle.name,
paths: bundle.artifacts.map((artifact) => artifact.path),
jdbcxRuntime: bundle.artifacts.some((artifact) => isJdbcxRuntimePath(artifact.path)),
}));
const productProfile = activeJdbcProductProfile.value;
const productMode = activeJdbcProductMode.value;
const bundles = jdbcMavenBundles.value
.filter((bundle) => !productProfile || !isJdbcProductManagedMavenCoordinate(productProfile, bundle.coordinate))
.map((bundle) => ({
id: `maven:${bundle.id}`,
label: bundle.coordinate,
paths: bundle.artifacts.map((artifact) => artifact.path),
jdbcxRuntime: isJdbcxRuntimeBundle(bundle),
}));
const manual = jdbcDrivers.value
.filter((driver) => !driver.bundle_id)
.map((driver) => ({
id: `manual:${driver.path}`,
label: driver.name,
paths: [driver.path],
jdbcxRuntime: isJdbcxRuntimePath(driver.path),
}));
const productPaths = productProfile && productMode ? jdbcProductManagedRuntimePaths(productProfile, jdbcMavenBundles.value, productMode.id) : [];
const managedProductRuntime: JdbcDriverSelectItem[] =
productProfile && productMode && productPaths.length > 0
? [
{
id: jdbcProductRuntimeSelectionId(productProfile, productMode.id),
label: t(productProfile.runtimeLabelKey, {
mode: t(productMode.labelKey),
}),
paths: productPaths,
jdbcxRuntime: false,
managedProductRuntime: true,
},
]
: [];
return [...managedProductRuntime, ...localBundles, ...bundles, ...manual].sort((left, right) => left.label.localeCompare(right.label));
});
const jdbcDriverSelectItemById = computed(() => new Map(jdbcDriverSelectItems.value.map((item) => [item.id, item])));
const jdbcManualClasspathCount = computed(() => parsedJdbcDriverPaths().length);
function parsedJdbcDriverPaths(): string[] {
return jdbcDriverPathsInput.value
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean);
}
function applyCustomColor(value: string) {
form.value.color = value;
customColorInput.value = value;
}
function handlePresetClick(color: string) {
form.value.color = color;
customColorInput.value = "";
}
function handleCustomColorPicked(value: string) {
applyCustomColor(value);
}
function handleCustomColorInput(value: string) {
applyCustomColor(value);
}
const driverProfiles: Record<string, ConnectionProfileDefinition> = {
...CONNECTION_PROFILES,
...jdbcProductDriverProfiles(),
};
function profileForConfig(config: ConnectionConfig) {
if (config.db_type === "plugin" && config.plugin_id && config.plugin_connection_provider) {
return pluginConnectionProviderOptionValue(config.plugin_id, config.plugin_connection_provider);
}
if (config.db_type === "oracle") return "oracle";
if (config.driver_profile && driverProfiles[config.driver_profile]) {
if (config.driver_profile === "oceanbase-oracle") return "oceanbase";
return config.driver_profile;
}
if (config.db_type === "mq") {
const kind = (config.external_config as MqAdminConfig | undefined)?.systemKind;
if (kind === "kafka") return "kafka";
if (kind === "rocketmq") return "rocketmq";
if (kind === "rabbitmq") return "rabbitmq";
return "mq";
}
if (config.db_type === "dameng") return "dm";
if (config.db_type === "oceanbase-oracle") return "oceanbase";
return config.db_type;
}
function selectedProfile() {
if (form.value.db_type === "plugin") {
const entry = selectedPluginProvider.value;
const providerId = parsePluginConnectionProviderOptionValue(selectedType.value)?.providerId;
return {
type: "plugin" as DatabaseType,
port: form.value.port || 0,
user: form.value.username || "",
label: entry?.contribution.label || form.value.driver_label || providerId || "Plugin",
icon: "plugin",
};
}
const profile = selectedType.value === "gbase" && (form.value.driver_profile === "gbase8a" || form.value.driver_profile === "gbase8s") ? form.value.driver_profile : selectedType.value;
return driverProfiles[profile] ?? driverProfiles.mysql;
}
function pluginProviderEntry(pluginId: string, providerId: string) {
return pluginConnectionProviders.value.find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === providerId) || null;
}
function pluginProviderEntryForOption(value: string) {
const target = parsePluginConnectionProviderOptionValue(value);
return target ? pluginProviderEntry(target.pluginId, target.providerId) : null;
}
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 mqExtraProperties(extra: Record<string, unknown>): Record<string, unknown> {
const value = extra.properties;
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function mqExtraPropertyString(extra: Record<string, unknown>, key: string): string {
const value = mqExtraProperties(extra)[key];
return typeof value === "string" ? value : "";
}
function jaasStringValue(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
function parseJaasStringProperty(value: string, key: string): string {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = value.match(new RegExp(`${escapedKey}\\s*=\\s*"((?:\\\\.|[^"\\\\])*)"`, "i"));
if (!match) return "";
return match[1].replace(/\\(["\\])/g, "$1");
}
function resetMqFields(config?: Partial<MqAdminConfig>) {
const systemKind = resolveMqSystemKind(config);
const extra = mqExtraRecord(config);
const properties = mqExtraProperties(extra);
const jaasConfig = mqExtraPropertyString(extra, "sasl.jaas.config");
mqSystemKind.value = systemKind;
const storedAdminUrl = config?.adminUrl?.trim() || (config ? mqExtraString(config as Record<string, unknown>, "admin_url").trim() : "");
mqAdminUrl.value = storedAdminUrl || (systemKind === "kafka" || systemKind === "rocketmq" || systemKind === "rabbitmq" ? "" : "http://127.0.0.1:8080");
mqKafkaConnectionSource.value = resolveMqKafkaConnectionSource(extra);
mqKafkaBootstrapServers.value = mqExtraString(extra, "bootstrapServers") || "127.0.0.1:9092";
mqKafkaZooKeeperServers.value = mqExtraString(extra, "zookeeperServers");
mqRocketmqNamesrvAddr.value = mqExtraString(extra, "namesrvAddr") || mqExtraString(extra, "namesrv_addr") || "127.0.0.1:9876";
mqRocketmqClusterName.value = mqExtraString(extra, "clusterName") || mqExtraString(extra, "cluster_name");
mqRabbitmqAddresses.value = mqExtraString(extra, "addresses") || "127.0.0.1:5672";
mqRabbitmqVirtualHost.value = mqExtraString(extra, "virtualHost") || "/";
mqKafkaSecurityProtocol.value = mqExtraString(extra, "securityProtocol") || MQ_KAFKA_SECURITY_PROTOCOL_AUTO;
mqKafkaSaslMechanism.value = mqExtraString(extra, "saslMechanism") || "PLAIN";
mqKafkaKerberosPrincipal.value = parseJaasStringProperty(jaasConfig, "principal");
mqKafkaKerberosKeytabPath.value = parseJaasStringProperty(jaasConfig, "keyTab");
mqKafkaKerberosServiceName.value = typeof properties["sasl.kerberos.service.name"] === "string" ? properties["sasl.kerberos.service.name"] : "kafka";
mqKafkaKrb5ConfPath.value = typeof properties["java.security.krb5.conf"] === "string" ? properties["java.security.krb5.conf"] : "";
mqTlsSkipVerify.value = !!config?.tlsSkipVerify;
mqPinnedVersion.value = pinnedVersionToSelection(config?.pinnedVersion);
const auth = (config?.auth || { kind: "none" }) as MqAuth;
mqAuthKind.value = detectMqUiAuthKind({
systemKind,
authKind: auth.kind,
saslMechanism: mqKafkaSaslMechanism.value,
jaasConfig,
});
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 defaultMqFieldsForProfile(profile: string): Partial<MqAdminConfig> | undefined {
if (profile === "kafka") {
return {
systemKind: "kafka",
adminUrl: "",
auth: { kind: "none" },
extra: { bootstrapServers: "127.0.0.1:9092" },
};
}
if (profile === "rocketmq") {
return {
systemKind: "rocketmq",
adminUrl: "",
auth: { kind: "none" },
extra: { namesrvAddr: "127.0.0.1:9876" },
};
}
if (profile === "rabbitmq") {
return {
systemKind: "rabbitmq",
adminUrl: "",
auth: { kind: "none" },
extra: { addresses: "127.0.0.1:5672", virtualHost: "/" },
};
}
return undefined;
}
function hydrateMqFields(value: unknown) {
if (!value || typeof value !== "object") {
resetMqFields();
return;
}
resetMqFields(value as Partial<MqAdminConfig>);
}
watch(selectedType, () => {
syncMqSystemKindFromSelectedType();
});
watch(mqSystemKind, (kind) => {
if (kind === "kafka") {
if (mqKafkaConnectionSource.value === "bootstrap" && !mqKafkaBootstrapServers.value.trim()) mqKafkaBootstrapServers.value = "127.0.0.1:9092";
if (!isMqAuthKindAllowedForSystem(kind, mqAuthKind.value)) mqAuthKind.value = "none";
return;
}
if (kind === "rocketmq") {
if (!mqRocketmqNamesrvAddr.value.trim()) mqRocketmqNamesrvAddr.value = "127.0.0.1:9876";
if (!isMqAuthKindAllowedForSystem(kind, mqAuthKind.value)) mqAuthKind.value = "none";
return;
}
if (kind === "rabbitmq") {
if (!mqRabbitmqAddresses.value.trim()) mqRabbitmqAddresses.value = "127.0.0.1:5672";
if (!mqRabbitmqVirtualHost.value.trim()) mqRabbitmqVirtualHost.value = "/";
if (!isMqAuthKindAllowedForSystem(kind, mqAuthKind.value)) mqAuthKind.value = "none";
return;
}
if (!mqAdminUrl.value.trim()) mqAdminUrl.value = "http://127.0.0.1:8080";
});
watch(mqAuthKind, (kind) => {
if (mqSystemKind.value === "kafka" && kind === "basic" && mqKafkaSaslMechanism.value.toUpperCase() === "GSSAPI") {
mqKafkaSaslMechanism.value = "PLAIN";
}
});
function resetNacosFields(config?: Partial<NacosAdminConfig>) {
nacosImplementation.value = config?.implementation || (config?.rnacosConsoleAddr ? "rnacos" : "nacos");
// Saved `auto` profiles are legacy records. The form always saves an
// explicit selection and no longer relies on a separate Console address.
nacosVersionMode.value = config?.versionMode === "v3" ? "v3" : "v2";
nacosApiPlane.value = config?.versionMode === "v3" ? config?.apiPlane || "admin" : "admin";
const serverAddr = config?.serverAddr?.trim() || "";
const contextPath = config?.contextPath?.trim() || "";
nacosServerAddr.value = serverAddr;
nacosContextPath.value = contextPath;
nacosConsoleUrl.value = config?.consoleUrl?.trim() || "";
nacosManagedNamespacesText.value = (config?.managedNamespaces || []).join("\n");
nacosDynamicAllNamespaces.value = !!config && !config.managedNamespaces?.length && !Array.isArray(form.value.visible_databases);
nacosRNacosConsoleAddr.value = config?.rnacosConsoleAddr?.trim() || "";
nacosHistoryEnabled.value = config?.rnacosHistoryEnabled ?? !!config?.rnacosConsoleAddr;
const consoleAuth = config?.rnacosConsoleAuth || { kind: "inherit" };
nacosConsoleAuthKind.value = consoleAuth.kind;
nacosConsoleUsername.value = consoleAuth.kind === "usernamePassword" ? consoleAuth.username : "";
nacosConsolePassword.value = consoleAuth.kind === "usernamePassword" ? consoleAuth.password : "";
nacosTlsSkipVerify.value = !!config?.tlsSkipVerify;
nacosMetricsMode.value = config?.metricsMode || "auto";
nacosMetricsUrl.value = config?.metricsUrl || "";
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 || "";
nacosScopeFingerprintBaseline = currentNacosScopeFingerprint();
}
function hydrateNacosFields(value: unknown) {
if (!value || typeof value !== "object") {
resetNacosFields();
return;
}
resetNacosFields(value as Partial<NacosAdminConfig>);
}
function resetConsulFields(value?: Record<string, unknown>) {
consulServerAddr.value = String(value?.serverAddr || value?.server_addr || "http://127.0.0.1:8500");
consulDatacenter.value = String(value?.datacenter || value?.consulDatacenter || value?.consul_datacenter || "");
consulNamespace.value = String(value?.namespace || value?.consulNamespace || value?.consul_namespace || "");
consulPartition.value = String(value?.partition || value?.consulPartition || value?.consul_partition || "");
const consistency = String(value?.consistency || value?.consulConsistency || value?.consul_consistency || "default");
consulConsistency.value = (["default", "stale", "consistent"].includes(consistency) ? consistency : "default") as ConsulConsistency;
consulTlsSkipVerify.value = Boolean(value?.tlsSkipVerify || value?.tls_skip_verify || value?.consulTlsSkipVerify || value?.consul_tls_skip_verify);
const agentTarget = value?.agentTarget || value?.agent_target;
const target = agentTarget && typeof agentTarget === "object" ? (agentTarget as Record<string, unknown>) : undefined;
consulAgentTargetNode.value = String(target?.node || "");
consulAgentTargetAddress.value = String(target?.address || "");
consulMeshVisible.value = Boolean(value?.consulMeshVisible);
consulOperatorVisible.value = Boolean(value?.consulOperatorVisible);
consulOperatorSnapshotRestoreEnabled.value = Boolean(value?.consulOperatorSnapshotRestoreEnabled);
consulOperatorAutopilotWriteEnabled.value = Boolean(value?.consulOperatorAutopilotWriteEnabled);
consulOperatorRaftWriteEnabled.value = Boolean(value?.consulOperatorRaftWriteEnabled);
consulOperatorKeyringWriteEnabled.value = Boolean(value?.consulOperatorKeyringWriteEnabled);
consulOperatorLicenseWriteEnabled.value = Boolean(value?.consulOperatorLicenseWriteEnabled);
}
function hydrateConsulFields(value: unknown) {
resetConsulFields(value && typeof value === "object" ? (value as Record<string, unknown>) : undefined);
}
function resetMqttFields(config?: Partial<MqttConnectionConfig>) {
mqttHost.value = config?.host?.trim() || "127.0.0.1";
mqttPort.value = config?.port || 1883;
mqttClientId.value = config?.clientId || "";
mqttProtocolVersion.value = config?.protocolVersion || "v5";
mqttTransportMode.value = config?.transport || "tcp";
mqttWsPath.value = config?.wsPath || "/mqtt";
mqttTls.value = config?.tls || false;
mqttTlsSkipVerify.value = config?.tlsSkipVerify || false;
mqttKeepAliveSecs.value = Math.max(1, config?.keepAliveSecs || 60);
mqttConnectTimeoutSecs.value = Math.max(1, config?.connectTimeoutSecs || 30);
mqttMaxPacketSizeBytes.value = Math.min(268435455, Math.max(1024, config?.maxPacketSizeBytes || 16 * 1024 * 1024));
mqttSavedTopics.value = config?.savedTopics ?? [];
const auth = config?.auth;
if (auth && auth.kind === "password") {
mqttAuthKind.value = "password";
mqttUsername.value = auth.username || "";
mqttPassword.value = auth.password || "";
mqttCaCertPath.value = "";
mqttClientCertPath.value = "";
mqttClientKeyPath.value = "";
} else if (auth && auth.kind === "certificate") {
mqttAuthKind.value = "certificate";
mqttUsername.value = "";
mqttPassword.value = "";
mqttCaCertPath.value = auth.caCertPath || "";
mqttClientCertPath.value = auth.clientCertPath || "";
mqttClientKeyPath.value = auth.clientKeyPath || "";
} else {
mqttAuthKind.value = "none";
mqttUsername.value = "";
mqttPassword.value = "";
mqttCaCertPath.value = "";
mqttClientCertPath.value = "";
mqttClientKeyPath.value = "";
}
}
function hydrateMqttFields(value: unknown) {
if (!value || typeof value !== "object") {
resetMqttFields();
return;
}
resetMqttFields(value as Partial<MqttConnectionConfig>);
}
function buildMqttExternalConfig(): MqttConnectionConfig {
const auth: MqttConnectionConfig["auth"] =
mqttAuthKind.value === "password"
? { kind: "password", username: mqttUsername.value, password: mqttPassword.value }
: mqttAuthKind.value === "certificate"
? { kind: "certificate", caCertPath: mqttCaCertPath.value || undefined, clientCertPath: mqttClientCertPath.value || undefined, clientKeyPath: mqttClientKeyPath.value || undefined }
: { kind: "none" };
return {
host: mqttHost.value.trim(),
port: mqttPort.value,
clientId: mqttClientId.value.trim() || `dbx-${Math.random().toString(36).slice(2, 10)}`,
protocolVersion: mqttProtocolVersion.value,
transport: mqttTransportMode.value,
tls: mqttTls.value,
tlsSkipVerify: mqttTlsSkipVerify.value,
auth,
keepAliveSecs: Math.max(1, mqttKeepAliveSecs.value),
connectTimeoutSecs: Math.max(1, mqttConnectTimeoutSecs.value),
maxPacketSizeBytes: Math.min(268435455, Math.max(1024, mqttMaxPacketSizeBytes.value)),
savedTopics: mqttSavedTopics.value,
wsPath: mqttTransportMode.value === "websocket" ? mqttWsPath.value || "/mqtt" : undefined,
};
}
const INFLUXDB_V1V2_DEFAULT_PORT = 8086;
const INFLUXDB_V3_DEFAULT_PORT = 8181;
const influxDbVersion = ref<InfluxDbVersion>("1");
const influxDbOrg = ref("");
const victoriaMetricsApiPath = ref("/prometheus");
const victoriaMetricsLookback = ref("1h");
function resetInfluxDbFields(config?: Partial<InfluxDbExternalConfig>, versionHint?: InfluxDbVersion) {
const version = versionHint ?? (config?.version === "2" ? "2" : config?.version === "3" ? "3" : "1");
influxDbVersion.value = version;
influxDbOrg.value = config?.org?.trim() || "";
}
function hydrateInfluxDbFields(value: unknown, versionHint?: InfluxDbVersion) {
if (!value || typeof value !== "object") {
resetInfluxDbFields(undefined, versionHint);
return;
}
resetInfluxDbFields(value as Partial<InfluxDbExternalConfig>, versionHint);
}
function resetHiveKerberosFields(config?: Pick<ConnectionConfig, "url_params" | "agent_java_options">) {
const kerberos = hiveKerberosFormConfig(config?.url_params, config?.agent_java_options);
hiveAuthMode.value = kerberos.authMode;
hivePrincipal.value = kerberos.principal;
hiveKrb5ConfPath.value = kerberos.krb5ConfPath;
hiveJaasConfigPath.value = kerberos.jaasConfigPath;
hiveUseSubjectCredsOnlyFalse.value = kerberos.useSubjectCredsOnlyFalse;
hiveExtraJavaOptions.value = kerberos.extraJavaOptions;
}
function resetDamengJvmOptions(config?: Pick<ConnectionConfig, "agent_java_options">) {
damengJvmOptions.value = damengJvmSystemPropertiesText(config?.agent_java_options);
}
function buildInfluxDbExternalConfig(): InfluxDbExternalConfig {
// InfluxDB 3 Core can run with --without-auth; the driver treats an empty
// password as "no Authorization header", so the token stays optional here.
if (influxDbVersion.value === "3") return { version: "3" };
if (influxDbVersion.value !== "2") return { version: "1" };
const org = influxDbOrg.value.trim();
if (!org) throw new Error("InfluxDB 2.x organization is required");
if (!form.value.password.trim()) throw new Error("InfluxDB 2.x token is required");
if (!form.value.database?.trim()) throw new Error("InfluxDB 2.x bucket is required");
return { version: "2", org };
}
function resetVictoriaMetricsFields(config?: Partial<VictoriaMetricsExternalConfig>) {
victoriaMetricsApiPath.value = config?.apiPath?.trim() || "/prometheus";
victoriaMetricsLookback.value = config?.lookback?.trim() || "1h";
}
function hydrateVictoriaMetricsFields(value: unknown) {
if (!value || typeof value !== "object") {
resetVictoriaMetricsFields();
return;
}
const config = value as Partial<VictoriaMetricsExternalConfig> & { api_path?: string };
resetVictoriaMetricsFields({
apiPath: config.apiPath || config.api_path,
lookback: config.lookback,
});
}
function buildVictoriaMetricsExternalConfig(): VictoriaMetricsExternalConfig {
const apiPath = victoriaMetricsApiPath.value.trim().replace(/\/+$/, "");
if (apiPath && !apiPath.startsWith("/")) throw new Error(t("connection.victoriametricsInvalidApiPath"));
const lookback = victoriaMetricsLookback.value.trim();
if (!/^\d+(?:ms|[smhdwy])$/.test(lookback) || lookback.startsWith("0")) {
throw new Error(t("connection.victoriametricsInvalidLookback"));
}
return { apiPath, lookback };
}
// ---------------------------------------------------------------------------
// Salesforce OAuth / device-code flow state
// ---------------------------------------------------------------------------
const salesforceAuthMode = ref<SalesforceAuthMode>("token");
const salesforceEnvironment = ref<SalesforceEnvironment>("production");
const salesforceLoginUrl = ref("");
const salesforceClientId = ref("");
const salesforceClientSecret = ref("");
// Username-password (ROPC) mode credentials. The password is never
// round-tripped from the backend; blank on edit unless re-typed.
const salesforceUsername = ref("");
const salesforceUserPassword = ref("");
const salesforceOauthRunning = ref(false);
const salesforceOauthError = ref("");
const salesforceOauthSuccess = ref("");
// When editing an existing connection whose credentials live in the backend's
// secret store, refreshToken/clientSecret are intentionally not sent back to
// the UI. Track "we have previously authorized" separately from whether we
// still hold the tokens in-memory so the form can show a status chip.
const salesforceOauthPreviouslyAuthorized = ref(false);
type SalesforceDevicePhase = "idle" | "polling" | "success" | "expired" | "denied" | "error";
const salesforceDevicePhase = ref<SalesforceDevicePhase>("idle");
const salesforceDeviceUserCode = ref("");
const salesforceDeviceVerificationUri = ref("");
const salesforceDeviceCode = ref("");
const salesforceDeviceIntervalSecs = ref(5);
const salesforceDeviceSecondsLeft = ref(0);
const salesforceDeviceError = ref("");
const salesforceDeviceUserCodeCopied = ref(false);
const salesforceDeviceVerificationCopied = ref(false);
let salesforceDevicePollTimer: ReturnType<typeof setTimeout> | null = null;
let salesforceDevicePollAbortId = 0;
let salesforceDeviceExpiresTimer: ReturnType<typeof setInterval> | null = null;
function salesforceAuthModeFromConfig(externalConfig: unknown, fallbackPassword?: string): SalesforceAuthMode {
const cfg = (externalConfig as SalesforceExternalConfig | undefined) ?? undefined;
const mode = cfg?.auth?.mode;
if (mode === "oauth" || mode === "device" || mode === "password") return mode;
if (cfg?.auth && (cfg.auth.refreshToken || cfg.auth.clientId || cfg.auth.authorizedAt)) return "oauth";
// No persisted auth context: if a password/token is present, treat as manual token.
if (fallbackPassword?.trim()) return "token";
return "token";
}
function resetSalesforceOAuthFields(externalConfig?: unknown, fallbackPassword?: string) {
const cfg = (externalConfig as SalesforceExternalConfig | undefined) ?? undefined;
const auth = cfg?.auth;
salesforceAuthMode.value = salesforceAuthModeFromConfig(externalConfig, fallbackPassword);
salesforceEnvironment.value = (auth?.environment as SalesforceEnvironment) || "production";
salesforceLoginUrl.value = auth?.loginUrl?.trim() || "";
salesforceClientId.value = auth?.clientId?.trim() || "";
// Client secret is never round-tripped from the backend for security; the
// input is blank on edit and only sent when the user re-types it.
salesforceClientSecret.value = "";
salesforceUsername.value = auth?.username?.trim() || "";
salesforceUserPassword.value = "";
salesforceOauthRunning.value = false;
salesforceOauthError.value = "";
salesforceOauthSuccess.value = "";
// A Client ID alone only means the form was filled in, not that anybody ever
// signed in; claiming "authorized" from it showed a false reassurance banner.
salesforceOauthPreviouslyAuthorized.value = !!(auth?.refreshToken || auth?.authorizedAt);
salesforceDevicePhase.value = "idle";
salesforceDeviceUserCode.value = "";
salesforceDeviceVerificationUri.value = "";
salesforceDeviceCode.value = "";
salesforceDeviceIntervalSecs.value = 5;
salesforceDeviceSecondsLeft.value = 0;
salesforceDeviceError.value = "";
salesforceDeviceUserCodeCopied.value = false;
salesforceDeviceVerificationCopied.value = false;
salesforceDeviceStopPolling();
}
function hydrateSalesforceOAuthFields(value: unknown, fallbackPassword?: string) {
resetSalesforceOAuthFields(value, fallbackPassword);
}
function salesforceBuildAuthorizeParams(): SalesforceOAuthAuthorizeParams {
const clientId = salesforceClientId.value.trim();
if (!clientId) throw new Error(t("connection.salesforceOauthClientIdRequired"));
const environment = salesforceEnvironment.value;
const loginUrl = environment === "custom" ? salesforceLoginUrl.value.trim() : undefined;
if (environment === "custom" && !loginUrl) {
throw new Error(t("connection.salesforceOauthLoginUrlRequired"));
}
const clientSecret = salesforceClientSecret.value.trim() || undefined;
return { environment, loginUrl, clientId, clientSecret };
}
function salesforceApplyTokenToForm(token: { accessToken: string; instanceUrl: string }) {
form.value.password = token.accessToken;
// The backend normalizes bare hostnames; we mirror it so the card preview
// shows the final instance URL even before the backend canonicalizes it.
let instanceUrl = token.instanceUrl.trim();
if (instanceUrl && !/^https?:\/\//i.test(instanceUrl)) {
instanceUrl = `https://${instanceUrl.replace(/\/+$/, "")}`;
}
if (instanceUrl) {
form.value.host = instanceUrl.replace(/\/+$/, "");
}
}
function salesforceBuildAuthContext(mode: SalesforceAuthMode, token?: { refreshToken?: string }): SalesforceAuthContext | undefined {
if (mode === "token") return undefined;
const context: SalesforceAuthContext = {
mode,
environment: salesforceEnvironment.value,
clientId: salesforceClientId.value.trim() || undefined,
authorizedAt: new Date().toISOString(),
};
if (salesforceEnvironment.value === "custom") {
context.loginUrl = salesforceLoginUrl.value.trim() || undefined;
}
// Only forward the client secret when the user explicitly typed it in this
// session — otherwise we would overwrite the backend-stored secret with "".
if (salesforceClientSecret.value.trim()) {
context.clientSecret = salesforceClientSecret.value.trim();
}
if (mode === "password") {
context.username = salesforceUsername.value.trim() || undefined;
if (salesforceUserPassword.value) {
context.password = salesforceUserPassword.value;
}
}
if (token?.refreshToken) {
context.refreshToken = token.refreshToken;
}
return context;
}
function salesforceMergeAuthIntoExternalConfig(mode: SalesforceAuthMode, token?: { refreshToken?: string }) {
const existing = (form.value.external_config && typeof form.value.external_config === "object" ? (form.value.external_config as Record<string, unknown>) : {}) as SalesforceExternalConfig & Record<string, unknown>;
const auth = salesforceBuildAuthContext(mode, token);
if (!auth) {
const { auth: _dropped, ...rest } = existing;
form.value.external_config = Object.keys(rest).length > 0 ? rest : undefined;
return;
}
// Preserve an existing refreshToken when the user re-authorizes without the
// backend returning a new one (e.g. refresh flow that omits a rotation).
if (!auth.refreshToken && existing.auth?.refreshToken) {
auth.refreshToken = existing.auth.refreshToken;
}
// Same for the ROPC password: it is not round-tripped for display, so an
// edit-without-retype must not clobber the stored credential.
if (!auth.password && existing.auth?.password) {
auth.password = existing.auth.password;
}
form.value.external_config = { ...existing, auth };
}
async function salesforceStartBrowserAuthorize() {
salesforceOauthError.value = "";
salesforceOauthSuccess.value = "";
salesforceOauthPreviouslyAuthorized.value = false;
let params: SalesforceOAuthAuthorizeParams;
try {
params = salesforceBuildAuthorizeParams();
} catch (e) {
salesforceOauthError.value = errorMessage(e);
return;
}
salesforceOauthRunning.value = true;
try {
const token = await api.salesforceOauthBrowserAuthorize(params);
salesforceApplyTokenToForm(token);
salesforceMergeAuthIntoExternalConfig("oauth", token);
salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: token.instanceUrl });
salesforceOauthPreviouslyAuthorized.value = true;
} catch (e) {
salesforceOauthError.value = errorMessage(e);
} finally {
salesforceOauthRunning.value = false;
}
}
async function salesforceStartPasswordLogin() {
salesforceOauthError.value = "";
salesforceOauthSuccess.value = "";
salesforceOauthPreviouslyAuthorized.value = false;
let params: SalesforceOAuthAuthorizeParams;
try {
params = salesforceBuildAuthorizeParams();
} catch (e) {
salesforceOauthError.value = errorMessage(e);
return;
}
const username = salesforceUsername.value.trim();
if (!username) {
salesforceOauthError.value = t("connection.salesforceUsernameRequired");
return;
}
if (!salesforceUserPassword.value) {
salesforceOauthError.value = t("connection.salesforceUserPasswordRequired");
return;
}
salesforceOauthRunning.value = true;
try {
const token = await api.salesforceOauthPasswordLogin(params, username, salesforceUserPassword.value);
salesforceApplyTokenToForm(token);
salesforceMergeAuthIntoExternalConfig("password", token);
salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: token.instanceUrl });
salesforceOauthPreviouslyAuthorized.value = true;
} catch (e) {
salesforceOauthError.value = errorMessage(e);
} finally {
salesforceOauthRunning.value = false;
}
}
function salesforceDeviceStopPolling() {
salesforceDevicePollAbortId++;
if (salesforceDevicePollTimer) {
clearTimeout(salesforceDevicePollTimer);
salesforceDevicePollTimer = null;
}
if (salesforceDeviceExpiresTimer) {
clearInterval(salesforceDeviceExpiresTimer);
salesforceDeviceExpiresTimer = null;
}
}
function salesforceDeviceStartExpiryCountdown(expiresInSecs: number) {
salesforceDeviceSecondsLeft.value = Math.max(0, Math.floor(expiresInSecs));
if (salesforceDeviceExpiresTimer) clearInterval(salesforceDeviceExpiresTimer);
salesforceDeviceExpiresTimer = setInterval(() => {
salesforceDeviceSecondsLeft.value = Math.max(0, salesforceDeviceSecondsLeft.value - 1);
if (salesforceDeviceSecondsLeft.value <= 0 && salesforceDevicePhase.value === "polling") {
salesforceDeviceStopPolling();
salesforceDevicePhase.value = "expired";
}
}, 1000);
}
async function salesforceDeviceRequestCode() {
salesforceOauthError.value = "";
salesforceOauthSuccess.value = "";
salesforceOauthPreviouslyAuthorized.value = false;
salesforceDeviceError.value = "";
salesforceDeviceUserCode.value = "";
salesforceDeviceVerificationUri.value = "";
salesforceDeviceUserCodeCopied.value = false;
salesforceDeviceVerificationCopied.value = false;
let params: SalesforceOAuthAuthorizeParams;
try {
params = salesforceBuildAuthorizeParams();
} catch (e) {
salesforceDeviceError.value = errorMessage(e);
salesforceDevicePhase.value = "error";
return;
}
salesforceOauthRunning.value = true;
salesforceDevicePhase.value = "idle";
try {
const start = await api.salesforceOauthDeviceStart(params);
salesforceDeviceUserCode.value = start.userCode;
salesforceDeviceVerificationUri.value = start.verificationUri;
salesforceDeviceCode.value = start.deviceCode;
salesforceDeviceIntervalSecs.value = Math.max(1, start.intervalSecs || 5);
salesforceDevicePhase.value = "polling";
salesforceDeviceStartExpiryCountdown(start.expiresInSecs);
await salesforceDevicePollLoop(params, start.deviceCode, salesforceDeviceIntervalSecs.value, salesforceDevicePollAbortId);
} catch (e) {
salesforceDeviceError.value = errorMessage(e);
salesforceDevicePhase.value = "error";
salesforceDeviceStopPolling();
} finally {
salesforceOauthRunning.value = false;
}
}
async function salesforceDevicePollLoop(params: SalesforceOAuthAuthorizeParams, deviceCode: string, intervalSecs: number, abortId: number): Promise<void> {
while (salesforceDevicePhase.value === "polling" && abortId === salesforceDevicePollAbortId) {
await new Promise<void>((resolve) => {
salesforceDevicePollTimer = setTimeout(() => resolve(), intervalSecs * 1000);
});
if (abortId !== salesforceDevicePollAbortId) return;
let result: SalesforceOAuthDevicePollResult;
try {
result = await api.salesforceOauthDevicePoll(params, deviceCode, intervalSecs);
} catch (e) {
// Transient network error: keep polling until the code expires.
if (salesforceDeviceSecondsLeft.value <= 0) {
salesforceDevicePhase.value = "expired";
salesforceDeviceStopPolling();
return;
}
salesforceDeviceError.value = errorMessage(e);
continue;
}
if (abortId !== salesforceDevicePollAbortId) return;
if (result.status === "pending") {
salesforceDeviceIntervalSecs.value = Math.max(1, result.intervalSecs || intervalSecs);
continue;
}
salesforceDeviceStopPolling();
if (result.status === "success") {
salesforceApplyTokenToForm(result.token);
salesforceMergeAuthIntoExternalConfig("device", result.token);
salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: result.token.instanceUrl });
salesforceOauthPreviouslyAuthorized.value = true;
salesforceDevicePhase.value = "success";
return;
}
if (result.status === "expired") {
salesforceDevicePhase.value = "expired";
return;
}
if (result.status === "denied") {
salesforceDeviceError.value = result.reason || t("connection.salesforceDeviceDenied");
salesforceDevicePhase.value = "denied";
return;
}
}
}
function salesforceDeviceCancel() {
salesforceDeviceStopPolling();
salesforceDevicePhase.value = "idle";
salesforceDeviceUserCode.value = "";
salesforceDeviceVerificationUri.value = "";
salesforceDeviceCode.value = "";
}
function salesforceDeviceRestart() {
salesforceDeviceCancel();
salesforceDeviceError.value = "";
salesforceOauthError.value = "";
}
async function salesforceCopyUserCode() {
if (!salesforceDeviceUserCode.value) return;
await copyToClipboard(salesforceDeviceUserCode.value);
salesforceDeviceUserCodeCopied.value = true;
setTimeout(() => {
salesforceDeviceUserCodeCopied.value = false;
}, 1500);
}
async function salesforceCopyVerificationUri() {
if (!salesforceDeviceVerificationUri.value) return;
await copyToClipboard(salesforceDeviceVerificationUri.value);
salesforceDeviceVerificationCopied.value = true;
setTimeout(() => {
salesforceDeviceVerificationCopied.value = false;
}, 1500);
}
function salesforceOpenVerificationPage() {
if (!salesforceDeviceVerificationUri.value) return;
// Same pattern as the update-flow "open release notes" action: defer to the
// shell helper that picks the right opener for desktop vs. web.
void (async () => {
try {
if (isDesktop) {
const { open } = await import("@tauri-apps/plugin-shell");
await open(salesforceDeviceVerificationUri.value);
} else {
globalThis.open(salesforceDeviceVerificationUri.value, "_blank", "noopener,noreferrer");
}
} catch (e) {
salesforceDeviceError.value = errorMessage(e);
}
})();
}
function salesforceSetAuthMode(mode: SalesforceAuthMode) {
// Switching modes resets any in-flight state so stale tokens from one flow
// never leak into another.
if (salesforceOauthRunning.value && mode !== salesforceAuthMode.value) return;
salesforceAuthMode.value = mode;
salesforceOauthError.value = "";
salesforceOauthSuccess.value = "";
if (mode !== "device") {
salesforceDeviceCancel();
salesforceDeviceError.value = "";
}
}
watch(influxDbVersion, (version, previousVersion) => {
if (form.value.db_type !== "influxdb") return;
if (version === "2" || version === "3") {
form.value.username = "";
}
const port = form.value.port;
if (version === "3" && (!port || port === INFLUXDB_V1V2_DEFAULT_PORT)) {
form.value.port = INFLUXDB_V3_DEFAULT_PORT;
} else if (previousVersion === "3" && port === INFLUXDB_V3_DEFAULT_PORT) {
form.value.port = INFLUXDB_V1V2_DEFAULT_PORT;
}
});
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, t("connection.mqOauthIssuerRequired")),
clientId: requireMqField(mqOauthClientId.value, t("connection.mqOauthClientIdRequired")),
clientSecret: requireMqField(mqOauthClientSecret.value, t("connection.mqOauthClientSecretRequired")),
audience: mqOauthAudience.value.trim() || undefined,
scope: mqOauthScope.value.trim() || undefined,
};
default:
return { kind: "none" };
}
}
function buildKafkaKerberosJaasConfig(): string {
const principal = requireMqField(mqKafkaKerberosPrincipal.value, t("connection.kafkaKerberosPrincipalRequired"));
const keytab = requireMqField(mqKafkaKerberosKeytabPath.value, t("connection.kafkaKerberosKeytabRequired"));
return `com.sun.security.auth.module.Krb5LoginModule required useKeyTab=true storeKey=true keyTab="${jaasStringValue(keytab)}" principal="${jaasStringValue(principal)}";`;
}
function buildMqTokenSigning() {
if (mqTokenSigningMode.value === "none") return undefined;
return {
algorithm: mqTokenSigningMode.value,
key: requireMqField(mqTokenSigningKey.value, t("connection.mqTokenSigningKeyRequired")),
};
}
function buildMqAdminConfig(): MqAdminConfig {
const systemKind = mqSystemKind.value;
if (systemKind === "kafka") {
const configuredSecurityProtocol = mqKafkaSecurityProtocol.value === MQ_KAFKA_SECURITY_PROTOCOL_AUTO ? "" : mqKafkaSecurityProtocol.value;
const extra: Record<string, unknown> = buildMqKafkaConnectionExtra({
connectionSource: mqKafkaConnectionSource.value,
bootstrapServers: mqKafkaBootstrapServers.value,
zookeeperServers: mqKafkaZooKeeperServers.value,
securityProtocol: configuredSecurityProtocol,
});
const securityProtocol = mqExtraString(extra, "securityProtocol");
const saslMechanism = mqAuthKind.value === "kerberos" ? "GSSAPI" : mqKafkaSaslMechanism.value.trim();
const properties: Record<string, string> = {};
if (securityProtocol) extra.securityProtocol = securityProtocol;
if (mqAuthKind.value === "basic" && saslMechanism) extra.saslMechanism = saslMechanism;
if (mqAuthKind.value === "kerberos") {
extra.saslMechanism = "GSSAPI";
properties["sasl.jaas.config"] = buildKafkaKerberosJaasConfig();
properties["sasl.kerberos.service.name"] = mqKafkaKerberosServiceName.value.trim() || "kafka";
if (mqKafkaKrb5ConfPath.value.trim()) {
properties["java.security.krb5.conf"] = mqKafkaKrb5ConfPath.value.trim();
}
}
if (Object.keys(properties).length) extra.properties = properties;
return {
systemKind: mqSystemKind.value,
adminUrl: "",
auth: buildMqAuth(),
tlsSkipVerify: mqTlsSkipVerify.value || undefined,
extra,
};
}
if (systemKind === "rocketmq") {
const namesrvAddr = normalizeRocketmqNamesrvAddr(mqRocketmqNamesrvAddr.value);
const extra: Record<string, unknown> = { namesrvAddr };
if (mqRocketmqClusterName.value.trim()) extra.clusterName = mqRocketmqClusterName.value.trim();
if (mqAuthKind.value === "basic") {
extra.accessKey = mqBasicUsername.value.trim();
extra.secretKey = mqBasicPassword.value;
}
return {
systemKind: "rocketmq",
adminUrl: "",
auth: buildMqAuth(),
tlsSkipVerify: mqTlsSkipVerify.value || undefined,
extra,
};
}
if (systemKind === "rabbitmq") {
let addresses: string;
try {
addresses = normalizeRabbitmqAddresses(mqRabbitmqAddresses.value);
} catch {
throw new Error(t(mqRabbitmqAddresses.value.trim() ? "connection.mqRabbitmqAddressesInvalid" : "connection.mqRabbitmqAddressesRequired"));
}
const extra: Record<string, unknown> = {
addresses,
virtualHost: mqRabbitmqVirtualHost.value.trim() || "/",
};
return {
systemKind: "rabbitmq",
adminUrl: mqAdminUrl.value.trim(),
auth: buildMqAuth(),
tlsSkipVerify: mqTlsSkipVerify.value || undefined,
extra,
};
}
return {
systemKind: mqSystemKind.value,
adminUrl: requireMqField(mqAdminUrl.value, t("connection.mqAdminUrlRequired")),
auth: buildMqAuth(),
tlsSkipVerify: mqTlsSkipVerify.value || undefined,
pinnedVersion: selectionToPinnedVersion(mqPinnedVersion.value),
tokenSigning: buildMqTokenSigning(),
};
}
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 {
const primaryAddress = requireMqField(nacosServerAddr.value, t("connection.nacosConsoleUrlRequired"));
const normalized = normalizeNacosEndpoint(primaryAddress, {
implementation: nacosImplementation.value,
versionMode: nacosVersionMode.value,
apiPlane: nacosApiPlane.value,
contextPath: nacosContextPath.value,
});
if (nacosImplementation.value === "rnacos" && normalized.warnings.length) {
throw new Error(t("connection.nacosRNacosOpenApiRequired"));
}
const rnacosConsoleConfigured = nacosImplementation.value === "rnacos" && !!nacosRNacosConsoleAddr.value.trim();
if (nacosImplementation.value === "rnacos" && nacosHistoryEnabled.value && !rnacosConsoleConfigured) {
throw new Error(t("connection.nacosRNacosConsoleUrlRequired"));
}
let rnacosConsoleAuth: NacosRNacosConsoleAuth | undefined;
const managedNamespaces = nacosImplementation.value === "nacos" && nacosAuthKind.value === "usernamePassword" ? parseNacosManagedNamespaces(nacosManagedNamespacesText.value) : [];
let metricsUrl: string | undefined;
let consoleUrl: string | undefined;
const usesIndependentConsoleUrl = nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "admin";
if (usesIndependentConsoleUrl && nacosConsoleUrl.value.trim()) {
try {
consoleUrl = normalizeNacosConsoleUrl(nacosConsoleUrl.value);
} catch {
throw new Error(t("connection.nacosWebConsoleUrlInvalid"));
}
}
if (nacosMetricsMode.value === "custom") {
try {
metricsUrl = normalizeNacosMetricsUrl(nacosMetricsUrl.value);
} catch {
throw new Error(t("connection.nacosMetricsUrlInvalid"));
}
}
if (rnacosConsoleConfigured) {
if (nacosConsoleAuthKind.value === "inherit") {
if (nacosAuthKind.value !== "usernamePassword") throw new Error(t("connection.nacosConsoleAuthSeparateRequired"));
rnacosConsoleAuth = { kind: "inherit" };
} else {
rnacosConsoleAuth = {
kind: "usernamePassword",
username: requireMqField(nacosConsoleUsername.value, t("connection.nacosConsoleUsernameRequired")),
password: nacosConsolePassword.value,
};
}
}
return {
implementation: nacosImplementation.value,
versionMode: nacosImplementation.value === "nacos" ? nacosVersionMode.value : undefined,
apiPlane: nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" ? nacosApiPlane.value : undefined,
serverAddr: normalized.serverAddr,
contextPath: normalized.contextPath || undefined,
consoleUrl,
managedNamespaces: managedNamespaces.length ? managedNamespaces : undefined,
rnacosConsoleAddr: rnacosConsoleConfigured ? nacosRNacosConsoleAddr.value.trim() : undefined,
rnacosHistoryEnabled: nacosImplementation.value === "rnacos" ? nacosHistoryEnabled.value : undefined,
rnacosConsoleAuth,
auth: buildNacosAuth(),
tlsSkipVerify: nacosTlsSkipVerify.value || undefined,
metricsMode: nacosMetricsMode.value,
metricsUrl,
pageSize: Number(nacosPageSize.value) > 0 ? Number(nacosPageSize.value) : 20,
};
}
function buildConsulExternalConfig(): Record<string, unknown> {
let parsed: URL;
try {
parsed = new URL(consulServerAddr.value.trim());
} catch {
throw new Error(t("connection.consulAddressInvalid"));
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(t("connection.consulAddressInvalid"));
if (parsed.username || parsed.password || parsed.search || parsed.hash) throw new Error(t("connection.consulAddressInvalid"));
const targetNode = consulAgentTargetNode.value.trim();
const targetAddress = consulAgentTargetAddress.value.trim();
if (!!targetNode !== !!targetAddress) throw new Error(t("connection.consulAgentTargetIncomplete"));
if (targetAddress && !consulAgentAddressesMatch(targetAddress, parsed.hostname)) {
throw new Error(t("connection.consulAgentTargetAddressMismatch"));
}
return {
serverAddr: parsed.toString().replace(/\/$/, ""),
datacenter: consulDatacenter.value.trim() || undefined,
namespace: consulNamespace.value.trim() || undefined,
partition: consulPartition.value.trim() || undefined,
consistency: consulConsistency.value,
tlsSkipVerify: consulTlsSkipVerify.value || undefined,
agentTarget: targetNode ? { node: targetNode, address: targetAddress } : undefined,
consulMeshVisible: consulMeshVisible.value || undefined,
consulOperatorVisible: consulOperatorVisible.value || undefined,
consulOperatorSnapshotRestoreEnabled: consulOperatorSnapshotRestoreEnabled.value || undefined,
consulOperatorAutopilotWriteEnabled: consulOperatorAutopilotWriteEnabled.value || undefined,
consulOperatorRaftWriteEnabled: consulOperatorRaftWriteEnabled.value || undefined,
consulOperatorKeyringWriteEnabled: consulOperatorKeyringWriteEnabled.value || undefined,
consulOperatorLicenseWriteEnabled: consulOperatorLicenseWriteEnabled.value || undefined,
};
}
function applyConsulServerAddr(config: LegacyConnectionConfig, serverAddr: string) {
const parsed = new URL(serverAddr);
config.host = parsed.hostname;
config.port = Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 8500);
config.ssl = parsed.protocol === "https:";
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
function connectionErrorWithDriverUpdateHint(config: ConnectionConfig, message: string): string {
message = appendConnectionErrorHints(config, message, t);
if (!hasAgentDriverUpdate(config.db_type, agentDrivers.value, config.driver_profile, { ssh: connectionUsesSsh(config) })) return message;
return appendAgentDriverUpdateHint(message, t("connection.agentDriverUpdateConnectionHint"));
}
function installedAgentDriver(drivers: readonly AgentDriverInstallState[], key: string): AgentDriverInstallState | undefined {
return drivers.find((driver) => driver.db_type === key);
}
async function refreshLocalAgentDrivers(): Promise<AgentDriverInstallState[]> {
const drivers = await api.listInstalledAgentsLocal();
agentDrivers.value = drivers;
return drivers;
}
function beginAgentDriverInstall(driverKey: string, label: string): string {
agentInstallOperationId.value = uuid();
agentInstallDriverKey.value = driverKey;
agentInstallLabel.value = label;
agentInstallProgress.value = null;
agentInstallError.value = "";
agentInstallCancelError.value = "";
agentInstallCancelling.value = false;
agentInstallCancelRequested.value = false;
agentInstallRunning.value = true;
showAgentInstallDialog.value = true;
return agentInstallOperationId.value;
}
/**
* Clear the install dialog, unless a newer operation now owns it. Stale
* operation promises (cancelled, then retried before settling) must not
* finish/reset the retry's dialog state.
*/
function finishAgentDriverInstall(operationId?: string | null) {
if (operationId !== undefined && operationId !== null && agentInstallOperationId.value !== operationId) return;
agentInstallOperationId.value = null;
agentInstallRunning.value = false;
agentInstallProgress.value = null;
agentInstallError.value = "";
agentInstallCancelError.value = "";
agentInstallCancelling.value = false;
showAgentInstallDialog.value = false;
}
function failAgentDriverInstall(operationId: string | null | undefined, error: unknown) {
if (operationId !== undefined && operationId !== null && agentInstallOperationId.value !== operationId) return;
agentInstallOperationId.value = null;
agentInstallRunning.value = false;
agentInstallCancelError.value = "";
agentInstallCancelling.value = false;
agentInstallError.value = translateBackendError(t, error);
showAgentInstallDialog.value = true;
}
/**
* Abort an in-flight agent driver install from the modal's Cancel button.
* The backend stops the download; the pending `installAgent` promise resolves
* with a "canceled by user" error, which callers treat as a non-failure.
*/
async function cancelActiveAgentInstall() {
const operationId = agentInstallOperationId.value;
if (!agentInstallDriverKey.value || !operationId || agentInstallCancelling.value) return;
agentInstallCancelling.value = true;
agentInstallCancelError.value = "";
const result = await requestAgentInstallCancellation(() => api.cancelAgentInstall(agentInstallDriverKey.value, operationId));
if (agentInstallOperationId.value !== operationId) return;
agentInstallCancelling.value = false;
if (!result.ok) {
agentInstallCancelError.value = translateBackendError(t, result.error);
return;
}
agentInstallCancelRequested.value = true;
finishAgentDriverInstall(operationId);
}
function showConnectionError(message: string) {
connectionErrorRawDetail.value = message;
connectionErrorDetail.value = translateBackendError(t, message);
connectionErrorCopied.value = false;
showConnectionErrorDialog.value = true;
}
function setAgentInstallDialogOpen(value: boolean) {
if (value || canCloseAgentInstallDialog.value) {
showAgentInstallDialog.value = value;
}
}
function handleAgentInstallProgress(payload: DriverInstallProgress) {
if (!agentInstallRunning.value || !agentInstallDriverKey.value) return;
if (driverInstallProgressChannel(payload) !== "agent") return;
if (!isDriverInstallProgressForOperation(payload, agentInstallOperationId.value)) return;
if (payload.db_type && payload.db_type !== agentInstallDriverKey.value) return;
if (payload.step === "done" || payload.step === "all-done") {
agentInstallProgress.value = null;
return;
}
agentInstallProgress.value = payload;
}
function formatInstallSize(bytes: number): string {
if (!bytes) return "0 B";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
async function ensureRequiredAgentDriverInstalled(config: ConnectionConfig): Promise<void> {
if (requiresSqlServerLegacyCompatibilityComponent(config)) {
await installSqlServerLegacyCompatibilityComponentIfNeeded();
}
const driverKey = agentDriverInstallKey(config.db_type, config.driver_profile, { ssh: connectionUsesSsh(config) });
if (!driverKey) return;
let drivers = agentDrivers.value.length ? agentDrivers.value : await refreshLocalAgentDrivers();
if (!showAgentDriverInstallHint(config.db_type, drivers, config.driver_profile, { ssh: connectionUsesSsh(config) })) return;
if (installedAgentDriver(drivers, driverKey)?.installed === true) return;
drivers = await refreshLocalAgentDrivers();
if (installedAgentDriver(drivers, driverKey)?.installed === true) return;
const label = config.driver_label || driverKey;
testResult.value = { ok: true, message: `Installing ${label} driver...` };
const operationId = beginAgentDriverInstall(driverKey, label);
try {
await api.installAgent(driverKey, operationId);
notifyComponentUpdatesChanged();
await refreshLocalAgentDrivers();
// A stale promise (cancelled then retried) must not close the retry's dialog.
if (agentInstallOperationId.value === operationId) finishAgentDriverInstall(operationId);
} catch (error) {
const outcome = resolveAgentInstallOutcome(
{ ok: false, error },
{
operationId,
currentOperationId: agentInstallOperationId.value,
cancelRequested: agentInstallCancelRequested.value,
},
);
if (outcome.kind === "cancelled") {
// User cancelled: close silently, do not surface a failure.
if (outcome.ownsState) {
testResult.value = null;
finishAgentDriverInstall(operationId);
}
return;
}
if (!outcome.ownsState) {
// A newer operation owns the dialog; leave its state untouched.
return;
}
testResult.value = { ok: false, message: translateBackendError(t, error) };
failAgentDriverInstall(operationId, error);
throw error;
}
}
async function ensureRequiredJdbcxDriverInstalled(config: ConnectionConfig): Promise<void> {
const result = await ensureJdbcxRuntimeDrivers(config, api, () => {
testResult.value = { ok: true, message: "Installing JDBC plugin..." };
});
if (!result) return;
notifyComponentUpdatesChanged();
jdbcMavenBundles.value = result.bundles;
addJdbcDriverPaths(result.paths);
form.value.jdbc_driver_paths = [...(config.jdbc_driver_paths ?? [])];
selectedJdbcDriverPath.value = result.runtimeSelectionId;
if (result.paths.length > 0) {
jdbcManualClasspathOpen.value = false;
}
}
async function ensureRequiredJdbcProductRuntimeInstalled(config: ConnectionConfig): Promise<void> {
const result = await ensureRegisteredJdbcProductRuntimeDrivers(config, api);
if (!result) return;
notifyComponentUpdatesChanged();
jdbcMavenBundles.value = result.bundles;
jdbcDriverPathsInput.value = result.paths.join("\n");
form.value.jdbc_driver_paths = [...result.paths];
selectedJdbcDriverPath.value = result.runtimeSelectionId ?? "";
if (result.paths.length > 0) {
jdbcManualClasspathOpen.value = false;
}
}
async function ensureRequiredGaussdbMJdbcRuntime(config: ConnectionConfig): Promise<void> {
if (gaussdbConnectionMode(config) !== "m-jdbc") return;
if (!(config.jdbc_driver_paths ?? []).length) {
throw new Error(t("connection.gaussdbMJdbcDriverRequired"));
}
const status = await api.jdbcPluginStatus();
if (status.installed && status.compatible) return;
testResult.value = { ok: true, message: t("connection.gaussdbMJdbcPluginInstalling") };
await api.installJdbcPlugin();
notifyComponentUpdatesChanged();
}
async function installSqlServerLegacyCompatibilityComponentIfNeeded(): Promise<boolean> {
if (await api.isAgentInstalled(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY)) return true;
const label = t("connection.sqlServerLegacyCompatibilityComponent");
const operationId = beginAgentDriverInstall(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY, label);
try {
await api.installAgent(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY, operationId);
notifyComponentUpdatesChanged();
await refreshLocalAgentDrivers();
// A stale promise (cancelled then retried) must not close the retry's dialog.
if (agentInstallOperationId.value === operationId) finishAgentDriverInstall(operationId);
} catch (error) {
const outcome = resolveAgentInstallOutcome(
{ ok: false, error },
{
operationId,
currentOperationId: agentInstallOperationId.value,
cancelRequested: agentInstallCancelRequested.value,
},
);
if (outcome.kind === "cancelled") {
// User cancelled the download: the toggle's caller falls back to `auto`.
if (outcome.ownsState) finishAgentDriverInstall(operationId);
return false;
}
if (!outcome.ownsState) {
// A newer operation owns the dialog; leave its state untouched.
return false;
}
testResult.value = { ok: false, message: translateBackendError(t, error) };
failAgentDriverInstall(operationId, error);
throw error;
}
return true;
}
async function setSqlServerDriverMode(mode: "auto" | "legacy") {
if (form.value.db_type !== "sqlserver") return;
// The connection test may still be using the previous compatibility mode.
resetTestState();
if (mode === "auto") {
setSqlServerLegacyCompatibilityConfig(form.value, false);
return;
}
try {
const installed = await installSqlServerLegacyCompatibilityComponentIfNeeded();
if (!installed) {
// User cancelled the download: never leave the form in legacy mode
// without the component installed.
setSqlServerLegacyCompatibilityConfig(form.value, false);
return;
}
setSqlServerLegacyCompatibilityConfig(form.value, true);
testResult.value = null;
} catch {
setSqlServerLegacyCompatibilityConfig(form.value, false);
}
}
function isSqlServerTlsHandshakeFailure(message: string): boolean {
const text = message.toLowerCase();
return text.includes("sql server") && text.includes("tls") && (text.includes("handshake") || text.includes("eof") || text.includes("performing i/o"));
}
function clearTestedConnectionInfo() {
testedConfigFingerprint.value = "";
testedConfigId.value = "";
testedGeneratedName.value = "";
}
function clearSavedDatabaseInfo() {
savedDatabaseInfo.value = null;
savedDatabaseInfoFingerprint.value = "";
savedConnectionConfigFingerprint.value = "";
}
function applySavedDatabaseInfo(config: ConnectionConfig) {
clearSavedDatabaseInfo();
try {
const current = connectionConfigForSubmit(config.id, config.name);
savedConnectionConfigFingerprint.value = connectionConfigFingerprint(current, form.value.name);
const info = normalizeDatabaseConnectionInfo(config.database_info);
if (info) {
savedDatabaseInfo.value = info;
savedDatabaseInfoFingerprint.value = savedConnectionConfigFingerprint.value;
}
} catch {
clearSavedDatabaseInfo();
}
}
function applySuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string) {
testResult.value = { ok: true, ...result };
testedConfigFingerprint.value = connectionConfigFingerprint(config, sourceName);
testedConfigId.value = config.id;
testedGeneratedName.value = config.name;
}
async function persistSuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string, runId: number) {
if (!editingId.value || !result.databaseInfo || !savedConnectionConfigFingerprint.value) return;
const fingerprint = connectionConfigFingerprint(config, sourceName);
let currentDraftFingerprint: string;
try {
const currentDraft = connectionConfigForSubmit(editingId.value, form.value.name);
currentDraftFingerprint = connectionConfigFingerprint(currentDraft, form.value.name);
} catch {
return;
}
// An in-flight test must not publish its saved snapshot after the user edits,
// switches, or closes the draft that initiated it.
if (
!canPersistConnectionTestResult({
testConfigId: config.id,
activeDraftId: editingId.value,
testRunId: runId,
activeTestRunId: testRunId,
submittedFingerprint: fingerprint,
savedFingerprint: savedConnectionConfigFingerprint.value,
currentDraftFingerprint,
})
) {
return;
}
const persistedDraftId = editingId.value;
try {
await store.updateConnectionDatabaseInfo(persistedDraftId, result.databaseInfo);
if (runId !== testRunId || editingId.value !== persistedDraftId) return;
savedDatabaseInfo.value = { ...result.databaseInfo };
savedDatabaseInfoFingerprint.value = fingerprint;
} catch {
// The successful test remains valid even when optional metadata persistence fails.
}
}
async function testConnectionWithTimeout(config: ConnectionConfig, runId: number): Promise<ConnectionTestResult> {
await tunnelProfileStore.init();
const timeoutMs = connectionAttemptTimeoutMs(config, tunnelProfileStore.profileById);
const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs);
const promise = api.testConnectionWithInfo(config);
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
void promise.catch((error) => {
if (!timedOut) return;
if (runId !== testRunId) return;
clearTestedConnectionInfo();
testResult.value = {
ok: false,
message: connectionErrorWithDriverUpdateHint(config, connectionAttemptOriginalErrorMessage(timeoutMessage, errorMessage(error))),
};
});
try {
return await Promise.race([
promise,
new Promise<ConnectionTestResult>((_, reject) => {
timer = setTimeout(() => {
timedOut = true;
reject(new Error(timeoutMessage));
}, timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function applyMqRocketmqNamesrv(config: LegacyConnectionConfig, namesrvAddr: string) {
const first = normalizeRocketmqNamesrvAddr(namesrvAddr).split(";")[0];
if (!first) throw new Error(t("connection.rocketmqNamesrvAddrRequired"));
let parsed: URL;
try {
parsed = new URL(`rocketmq://${first}`);
} catch {
throw new Error(t("connection.rocketmqNamesrvAddrInvalid"));
}
config.host = parsed.hostname;
config.port = Number(parsed.port) || 9876;
config.ssl = false;
}
function applyMqAdminUrl(config: LegacyConnectionConfig, adminUrl: string) {
let parsed: URL;
try {
parsed = new URL(adminUrl);
} catch {
throw new Error(t("connection.mqAdminUrlInvalid"));
}
const port = Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 8080);
config.host = parsed.hostname;
config.port = port;
config.ssl = parsed.protocol === "https:";
}
function applyMqKafkaConnectionTarget(config: LegacyConnectionConfig, extra: Record<string, unknown>) {
const source = resolveMqKafkaConnectionSource(extra);
const target = mqKafkaConnectionTarget({
connectionSource: source,
bootstrapServers: mqExtraString(extra, "bootstrapServers"),
zookeeperServers: mqExtraString(extra, "zookeeperServers"),
securityProtocol: mqExtraString(extra, "securityProtocol"),
});
config.host = target.host;
config.port = target.port;
config.ssl = target.ssl;
}
function applyMqRabbitmqAddresses(config: LegacyConnectionConfig, addresses: string) {
const first = normalizeRabbitmqAddresses(addresses).split(",")[0];
if (!first) throw new Error(t("connection.mqRabbitmqAddressesRequired"));
const parsed = parseRabbitmqAddress(first);
config.host = parsed.host;
config.port = parsed.port;
config.ssl = false;
}
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 applyDremioConnectionMode(mode: DremioConnectionMode) {
rememberCurrentDremioConnectionUrl();
dremioConnectionMode.value = mode;
form.value.connection_string = dremioConnectionUrls.value[mode] || dremioDefaultConnectionUrl(mode);
if (isDremioGeneratedDefaultDriverClass(form.value.jdbc_driver_class)) {
form.value.jdbc_driver_class = dremioDefaultDriverClass(mode);
}
}
function rememberCurrentDremioConnectionUrl() {
if (form.value.driver_profile !== "dremio") return;
const url = form.value.connection_string?.trim();
dremioConnectionUrls.value[dremioConnectionMode.value] = url || dremioDefaultConnectionUrl();
}
function resetDremioConnectionUrls(mode: DremioConnectionMode = "legacy", url?: string) {
dremioConnectionUrls.value = {
"arrow-flight-sql": DREMIO_ARROW_FLIGHT_SQL_JDBC_URL,
legacy: DREMIO_LEGACY_JDBC_URL,
};
if (url?.trim()) {
dremioConnectionUrls.value[mode] = url.trim();
}
}
function dremioDefaultConnectionUrl(mode = dremioConnectionMode.value) {
return mode === "legacy" ? DREMIO_LEGACY_JDBC_URL : DREMIO_ARROW_FLIGHT_SQL_JDBC_URL;
}
function dremioDefaultDriverClass(mode = dremioConnectionMode.value) {
return mode === "legacy" ? DREMIO_LEGACY_JDBC_DRIVER_CLASS : DREMIO_ARROW_FLIGHT_SQL_JDBC_DRIVER_CLASS;
}
function isDremioGeneratedDefaultDriverClass(value: string | undefined) {
const driverClass = value?.trim() || "";
return !driverClass || driverClass === DREMIO_ARROW_FLIGHT_SQL_JDBC_DRIVER_CLASS || driverClass === DREMIO_LEGACY_JDBC_DRIVER_CLASS;
}
function restoreDremioConnectionDefaultsIfEmpty() {
if (form.value.driver_profile !== "dremio") return;
if (!form.value.connection_string?.trim()) {
form.value.connection_string = dremioDefaultConnectionUrl();
}
if (isDremioGeneratedDefaultDriverClass(form.value.jdbc_driver_class)) {
form.value.jdbc_driver_class = dremioDefaultDriverClass();
}
}
function syncDremioConnectionModeFromUrl() {
if (form.value.driver_profile !== "dremio") return;
restoreDremioConnectionDefaultsIfEmpty();
const nextMode = dremioConnectionModeForConfig({
connection_string: form.value.connection_string,
jdbc_driver_class: "",
});
dremioConnectionUrls.value[nextMode] = form.value.connection_string?.trim() || dremioDefaultConnectionUrl(nextMode);
if (nextMode === dremioConnectionMode.value) return;
dremioConnectionMode.value = nextMode;
if (isDremioGeneratedDefaultDriverClass(form.value.jdbc_driver_class)) {
form.value.jdbc_driver_class = dremioDefaultDriverClass(nextMode);
}
}
function dremioConnectionModeForConfig(config: Pick<ConnectionConfig, "connection_string" | "jdbc_driver_class">): DremioConnectionMode {
const haystack = `${config.connection_string || ""}\n${config.jdbc_driver_class || ""}`.toLowerCase();
return haystack.includes("jdbc:dremio:") || haystack.includes("com.dremio.jdbc.driver") ? "legacy" : "arrow-flight-sql";
}
function currentJdbcProductConnectionFields() {
return {
connectionString: form.value.connection_string || "",
driverClass: form.value.jdbc_driver_class || "",
};
}
function applyJdbcProductConnectionMode(modeId: string) {
const profile = activeJdbcProductProfile.value;
if (!profile) return;
jdbcProductConnectionFields.value = rememberJdbcProductConnectionFields(profile, jdbcProductConnectionFields.value, jdbcProductConnectionMode.value, currentJdbcProductConnectionFields());
jdbcProductConnectionMode.value = modeId;
const fields = jdbcProductConnectionFields.value[modeId] ?? jdbcProductConnectionDefaults(profile, modeId);
form.value.connection_string = fields.connectionString;
form.value.jdbc_driver_class = fields.driverClass;
resetTestState();
}
function resetJdbcProductConnectionFields(profile = activeJdbcProductProfile.value, config?: Pick<ConnectionConfig, "connection_string" | "jdbc_driver_class">) {
if (!profile) {
jdbcProductConnectionMode.value = "";
jdbcProductConnectionFields.value = {};
return;
}
jdbcProductConnectionMode.value = config ? profile.detectMode(config) : jdbcProductMode(profile, "").id;
jdbcProductConnectionFields.value = createJdbcProductConnectionFieldsByMode(profile, config);
}
function restoreJdbcProductConnectionDefaultsIfEmpty() {
const profile = activeJdbcProductProfile.value;
if (!profile) return;
const defaults = jdbcProductConnectionDefaults(profile, jdbcProductConnectionMode.value);
form.value.connection_string = form.value.connection_string?.trim() || defaults.connectionString;
form.value.jdbc_driver_class = form.value.jdbc_driver_class?.trim() || defaults.driverClass;
}
function syncJdbcProductConnectionModeFromUrl() {
const profile = activeJdbcProductProfile.value;
if (!profile) return;
restoreJdbcProductConnectionDefaultsIfEmpty();
const nextMode = profile.detectMode(form.value);
if (nextMode === jdbcProductConnectionMode.value) {
jdbcProductConnectionFields.value = rememberJdbcProductConnectionFields(profile, jdbcProductConnectionFields.value, nextMode, currentJdbcProductConnectionFields());
return;
}
if (isJdbcProductDefaultDriverClass(profile, form.value.jdbc_driver_class)) {
form.value.jdbc_driver_class = jdbcProductConnectionDefaults(profile, nextMode).driverClass;
}
jdbcProductConnectionMode.value = nextMode;
jdbcProductConnectionFields.value = rememberJdbcProductConnectionFields(profile, jdbcProductConnectionFields.value, nextMode, currentJdbcProductConnectionFields());
}
function syncJdbcProfileModeFromUrl() {
syncDremioConnectionModeFromUrl();
syncJdbcProductConnectionModeFromUrl();
}
function isCustomCompatibleProfile() {
return selectedType.value === "custom_mysql" || selectedType.value === "custom_postgres";
}
function connectionUrlPreferredProfile() {
if (selectedType.value === "oceanbase" && form.value.driver_profile === "oceanbase-oracle") {
return "oceanbase-oracle";
}
return selectedType.value;
}
function applyProfile(val: string, preserveConnectionFields = false) {
const profile = driverProfiles[val];
if (!profile) return;
const previousDatabaseType = form.value.db_type;
selectedType.value = val;
form.value.db_type = profile.type;
form.value.driver_profile = val;
form.value.driver_label = isCustomCompatibleProfile() ? customDriverName.value.trim() || profile.label : profile.label;
const preserveMeilisearchConfig = preserveConnectionFields && previousDatabaseType === "meilisearch" && profile.type === "meilisearch";
if (profile.type !== "sqlserver" && !preserveMeilisearchConfig) {
form.value.external_config = undefined;
}
if (profile.type !== "elasticsearch" || previousDatabaseType !== "elasticsearch") {
resetElasticsearchProxyFields();
}
if (profile.type !== "cassandra" || previousDatabaseType !== "cassandra") {
resetCassandraTlsFields(undefined);
}
if (!preserveConnectionFields) {
oracleTnsAdminPath.value = "";
form.value.port = profile.port;
setSqlServerPortExplicit(form.value, false);
form.value.username = profile.user;
form.value.url_params = profile.urlParams || "";
form.value.agent_java_options = [];
damengJvmOptions.value = "";
if (profile.host) {
form.value.host = profile.host;
}
if (profile.type === "sqlite" || profile.type === "duckdb" || profile.type === "access") {
form.value.host = "";
}
if (profile.type === "sqlite") {
form.value.database = undefined;
}
if (profile.type === "dynamodb") {
form.value.database = "us-east-1";
form.value.connection_string = undefined;
form.value.ssl = true;
}
if (profile.type === "h2") {
h2ConnectionMode.value = "file";
form.value.host = "";
form.value.port = 0;
form.value.connection_string = undefined;
}
if (profile.type === "dameng") {
form.value.connection_string = undefined;
form.value.jdbc_driver_class = undefined;
form.value.jdbc_driver_paths = [];
jdbcDriverPathsInput.value = "";
jdbcManualClasspathOpen.value = false;
}
if (profile.type === "jdbc") {
form.value.host = "";
form.value.connection_string = "";
form.value.jdbc_driver_class = "";
form.value.jdbc_driver_paths = [];
jdbcDriverPathsInput.value = "";
if (val === "dremio") {
resetDremioConnectionUrls();
applyDremioConnectionMode("legacy");
} else if (jdbcProductProfileDefinition(val)) {
const jdbcProductProfile = jdbcProductProfileDefinition(val)!;
resetJdbcProductConnectionFields(jdbcProductProfile);
const fields = jdbcProductConnectionDefaults(jdbcProductProfile, jdbcProductConnectionMode.value);
form.value.connection_string = fields.connectionString;
form.value.jdbc_driver_class = fields.driverClass;
} else if (val === JDBCX_DRIVER_PROFILE) {
form.value.connection_string = JDBCX_DEFAULT_URL;
form.value.jdbc_driver_class = JDBCX_JDBC_DRIVER_CLASS;
}
}
if (profile.type === "prestosql") {
form.value.connection_string = undefined;
form.value.jdbc_driver_class = "io.prestosql.jdbc.PrestoDriver";
form.value.jdbc_driver_paths = [];
jdbcDriverPathsInput.value = "";
jdbcManualClasspathOpen.value = true;
applyPrestoSqlBuiltinDriverPathsIfAvailable();
}
if (profile.type === "bigquery") {
form.value.connection_string = undefined;
form.value.jdbc_driver_class = "";
form.value.jdbc_driver_paths = [];
jdbcDriverPathsInput.value = "";
jdbcManualClasspathOpen.value = true;
}
if (profile.type === "spanner") {
// Google Cloud endpoints carry no host; the local emulator is opted into
// by typing host `localhost` and port 9010 explicitly.
form.value.host = "";
form.value.username = "";
form.value.password = "";
form.value.database = undefined;
form.value.connection_string = undefined;
}
if (profile.type === "mq") {
resetMqFields(defaultMqFieldsForProfile(val));
syncMqSystemKindFromSelectedType();
form.value.database = undefined;
form.value.connection_string = undefined;
}
if (profile.type === "zookeeper") {
form.value.database = undefined;
form.value.connection_string = "";
}
if (profile.type === "nacos") {
resetNacosFields();
form.value.database = undefined;
form.value.connection_string = undefined;
form.value.url_params = "";
}
if (profile.type === "consul") {
resetConsulFields();
form.value.database = undefined;
form.value.connection_string = undefined;
form.value.username = "";
form.value.password = "";
form.value.url_params = "";
}
if (profile.type === "mqtt") {
resetMqttFields();
form.value.database = undefined;
form.value.connection_string = undefined;
form.value.url_params = "";
}
if (profile.type === "influxdb") {
resetInfluxDbFields();
form.value.database = undefined;
form.value.password = "";
form.value.connection_string = undefined;
}
if (profile.type === "victoriametrics") {
resetVictoriaMetricsFields();
form.value.database = "metrics";
form.value.password = "";
form.value.connection_string = undefined;
form.value.url_params = "";
}
if (profile.type === "salesforce") {
resetSalesforceOAuthFields(form.value.external_config, form.value.password);
}
resetHiveKerberosFields(profile.type === "hive" || profile.type === "argo" || profile.type === "kyuubi" || profile.type === "impala" ? form.value : undefined);
}
if (profile.type === "meilisearch") {
syncMeilisearchHostInput(form.value);
} else {
resetMeilisearchHostInput();
}
}
function applyPluginProvider(value: string, preserveConnectionFields = false, existing?: ConnectionConfig): boolean {
const target = parsePluginConnectionProviderOptionValue(value);
if (!target) return false;
const entry = pluginProviderEntry(target.pluginId, target.providerId);
if (!entry) return false;
selectedType.value = value;
if (!preserveConnectionFields) {
form.value = {
...defaultForm(),
name: entry.contribution.label,
db_type: "plugin",
driver_profile: "plugin",
driver_label: entry.contribution.label,
host: "",
port: 0,
username: "",
password: "",
query_timeout_secs: 60,
};
} else {
form.value.db_type = "plugin";
form.value.driver_profile = "plugin";
form.value.driver_label = entry.contribution.label;
}
pluginFormValues.value = pluginConnectionFormValues(entry.contribution, existing);
if (!preserveConnectionFields) {
const nameField = entry.contribution.fields.find((field) => field.binding === "name");
const defaultName = nameField ? pluginFormValues.value[nameField.key] : undefined;
if (typeof defaultName === "string" && defaultName.trim()) form.value.name = defaultName.trim();
}
connectionUrlInput.value = "";
appliedConnectionUrlInput.value = "";
return true;
}
async function loadInstalledPlugins() {
pluginLoadError.value = "";
try {
installedPlugins.value = await api.listPlugins();
} catch (cause) {
installedPlugins.value = [];
pluginLoadError.value = cause instanceof Error ? cause.message : String(cause);
}
}
function applyRequestedPluginProvider(target: PluginCenterFocus | null | undefined) {
if (!target?.pluginId || !target.providerId) return false;
const value = pluginConnectionProviderOptionValue(target.pluginId, target.providerId);
if (!applyPluginProvider(value)) return false;
selectedDbCategory.value = "plugins";
dialogStep.value = "config";
configTab.value = "connection";
return true;
}
function switchOceanbaseMode(mode: "mysql" | "oracle") {
oceanbaseSubMode.value = mode;
if (mode === "mysql") {
applyProfile("oceanbase", false);
} else {
applyProfile("oceanbase-oracle", false);
selectedType.value = "oceanbase";
}
resetTestState();
}
function switchGbaseProfile(profile: "gbase8a" | "gbase8s") {
applyProfile(profile, false);
selectedType.value = "gbase";
resetTestState();
}
let applyingConnectionUpdate = false;
let appliedConnectionUpdate: ConnectionDeepLinkUpdate | null = null;
function finishApplyingConnectionUpdate() {
void nextTick(() => {
applyingConnectionUpdate = false;
});
}
// The external_config shaped by an update link must survive submit verbatim
// only while the submitted values are still the ones the link patched: once
// the user edits the port away from the patched value, or the patch targeted
// another connection, the flag is re-derived from the form like any edit.
function connectionUpdateExternalConfigPreserved(config: Pick<ConnectionConfig, "id" | "port">): boolean {
if (!appliedConnectionUpdate || appliedConnectionUpdate.connectionId !== config.id) return false;
const patchedPort = appliedConnectionUpdate.patch.port;
return patchedPort === undefined || patchedPort === config.port;
}
watch(
[() => props.editConfig, open],
([savedConfig, isOpen]) => {
const syncAction = connectionEditDraftSyncAction(savedConfig?.id ?? null, isOpen, editingId.value);
if (syncAction === "preserve") return;
// Hydrate a detached edit draft before form watchers observe it. Do not
// mutate the saved record or apply create defaults to an ID update. Only
// flag the update as applied when the prefill really targeted this
// connection, so an ID mismatch cannot freeze port-flag recomputation.
const connectionUpdate = savedConfig && props.updatePrefill?.connectionId === savedConfig.id ? props.updatePrefill : null;
const config = connectionUpdate && savedConfig ? applyConnectionDeepLinkUpdate(savedConfig, connectionUpdate) : savedConfig;
resetConnectionNoteVisibilityDraft(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes);
editGlobalConnectTimeoutSecs.value = settingsStore.editorSettings.globalConnectTimeoutSecs;
editGlobalQueryTimeoutSecs.value = settingsStore.editorSettings.globalQueryTimeoutSecs;
if (syncAction === "hydrate" && config) {
appliedConnectionUpdate = connectionUpdate;
if (connectionUpdate) applyingConnectionUpdate = true;
clearSavedDatabaseInfo();
const legacyConfig = config as LegacyConnectionConfig;
const profile = profileForConfig(config);
const oceanbaseMode = profile === "oceanbase" ? oceanbaseSubModeFromConfig(config) : "mysql";
const oceanbasePatch = profile === "oceanbase" ? oceanbaseModeConnectionPatch(oceanbaseMode) : null;
editingId.value = config.id;
const profileConfig = driverProfiles[profile];
form.value = {
name: config.name,
note: config.note || "",
db_type: oceanbasePatch?.db_type || profileConfig?.type || config.db_type,
driver_profile: config.db_type === "plugin" ? "plugin" : oceanbasePatch?.driver_profile || config.driver_profile || profile,
driver_label: config.driver_label || oceanbasePatch?.driver_label || driverProfiles[profile]?.label || config.db_type,
...savedMysqlTlsFormFields(config),
agent_java_options: config.agent_java_options || [],
host: config.db_type === "h2" && h2FilePathFromJdbcUrl(config.connection_string) ? h2FilePathFromJdbcUrl(config.connection_string) : config.host,
port: profile === "tdengine" && (config.port === 0 || config.port === 6030) ? 6041 : config.port,
username: config.username,
password: config.password,
// Show the index the backend actually connects with; legacy dirty values
// (e.g. redis-cli flags in the field) are healed when the form is saved.
database: config.db_type === "redis" ? normalizeRedisDatabaseValue(config.database) || "" : config.database,
color: config.color || "",
transport_layers: transportLayersForConfig(legacyConfig),
connect_timeout_secs: config.connect_timeout_inherit === true ? settingsStore.editorSettings.globalConnectTimeoutSecs : config.connect_timeout_secs || 10,
connect_timeout_inherit: config.connect_timeout_inherit === true,
query_timeout_secs: config.query_timeout_inherit === true ? settingsStore.editorSettings.globalQueryTimeoutSecs : (config.query_timeout_secs ?? DEFAULT_QUERY_TIMEOUT_SECS),
query_timeout_inherit: config.query_timeout_inherit === true,
idle_timeout_secs: config.idle_timeout_secs ?? 60,
keepalive_interval_secs: config.keepalive_interval_secs ?? 30,
sysdba: config.sysdba || isOracleSysUser(config),
oracle_connection_type: config.oracle_connection_type || "service_name",
connection_string: config.connection_string,
jdbc_driver_class: config.jdbc_driver_class,
jdbc_driver_paths: config.jdbc_driver_paths || [],
redis_connection_mode: config.redis_connection_mode || "standalone",
redis_sentinel_master: config.redis_sentinel_master || "",
redis_sentinel_nodes: config.redis_sentinel_nodes || "",
redis_sentinel_username: config.redis_sentinel_username || "",
redis_sentinel_password: config.redis_sentinel_password || "",
redis_sentinel_tls: config.redis_sentinel_tls || false,
redis_cluster_nodes: config.redis_cluster_nodes || "",
redis_key_separator: config.redis_key_separator ?? ":",
redis_scan_page_size: config.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT,
redis_key_templates: normalizeRedisKeyTemplates(config.redis_key_templates),
redis_key_grouping: config.redis_key_grouping,
etcd_endpoints: config.etcd_endpoints || "",
gbase_server: config.gbase_server || "",
informix_server: config.informix_server || "",
external_config: config.external_config,
attached_databases: config.attached_databases || [],
init_script: config.init_script,
docs_notes_path: config.docs_notes_path,
read_only: config.read_only || false,
show_system_schemas: config.show_system_schemas || false,
sidebar_auto_load_all_tables: config.sidebar_auto_load_all_tables === true,
is_production: config.is_production || false,
production_databases: config.production_databases || [],
visible_databases: config.visible_databases,
visible_schemas: config.visible_schemas,
save_password: config.save_password !== false,
};
redisKeyTemplatesText.value = redisKeyTemplatesToTextarea(form.value.redis_key_templates);
oracleTnsAdminPath.value = parseOracleTnsConnectionString(config.connection_string)?.tnsAdmin || "";
productionProtectionEnabled.value = !!config.is_production || (config.production_databases?.length ?? 0) > 0;
connectionUrlInput.value = config.db_type === "h2" && config.connection_string ? config.connection_string : "";
appliedConnectionUrlInput.value = connectionUrlInput.value.trim();
if (config.db_type === "meilisearch") {
syncMeilisearchHostInput(config);
} else {
resetMeilisearchHostInput();
}
if (config.db_type === "mq") {
hydrateMqFields(config.external_config);
} else {
resetMqFields();
}
resetCassandraTlsFields(config.db_type === "cassandra" ? config.external_config : undefined);
if (config.db_type === "nacos") {
hydrateNacosFields(config.external_config);
} else {
resetNacosFields();
}
if (config.db_type === "consul") {
hydrateConsulFields(config.external_config);
} else {
resetConsulFields();
}
if (config.db_type === "mqtt") {
hydrateMqttFields(config.external_config);
} else {
resetMqttFields();
}
if (config.db_type === "influxdb" || config.db_type === "influxdb3") {
// The influxdb3 engine is presented as the InfluxDB card with
// version = 3. Save-side (`applyConnectionFormToConfig`) swaps
// db_type back to `influxdb3` when saving.
const versionHint: InfluxDbVersion | undefined = config.db_type === "influxdb3" ? "3" : undefined;
hydrateInfluxDbFields(config.external_config, versionHint);
if (config.db_type === "influxdb3") {
form.value.db_type = "influxdb";
}
} else {
resetInfluxDbFields();
}
if (config.db_type === "victoriametrics") {
hydrateVictoriaMetricsFields(config.external_config);
} else {
resetVictoriaMetricsFields();
}
if (config.db_type === "salesforce") {
hydrateSalesforceOAuthFields(config.external_config, config.password);
} else {
resetSalesforceOAuthFields(undefined, undefined);
}
resetElasticsearchProxyFields(config.db_type === "elasticsearch" ? config.external_config : undefined);
resetHiveKerberosFields(config.db_type === "hive" || config.db_type === "argo" || config.db_type === "kyuubi" || config.db_type === "impala" ? config : undefined);
resetDamengJvmOptions(config.db_type === "dameng" ? config : undefined);
h2ConnectionMode.value = h2ConnectionModeForConfig(config);
customColorInput.value = config.color || "";
selectedTransportLayerId.value = form.value.transport_layers?.[0]?.id || null;
selectedType.value = profile;
if (config.db_type === "plugin") {
selectedDbCategory.value = "plugins";
const entry = config.plugin_id && config.plugin_connection_provider ? pluginProviderEntry(config.plugin_id, config.plugin_connection_provider) : null;
pluginFormValues.value = entry ? pluginConnectionFormValues(entry.contribution, config) : {};
} else {
pluginFormValues.value = {};
}
if (profile === "oceanbase") {
oceanbaseSubMode.value = oceanbaseMode;
}
if (profile === "gbase8a" || profile === "gbase8s") {
selectedType.value = "gbase";
}
dremioConnectionMode.value = profile === "dremio" ? dremioConnectionModeForConfig(config) : "legacy";
resetDremioConnectionUrls(dremioConnectionMode.value, profile === "dremio" ? config.connection_string : undefined);
resetJdbcProductConnectionFields(jdbcProductProfileForConfig(config), config);
mongoUseUrl.value = !!config.connection_string;
jdbcDriverPathsInput.value = (config.jdbc_driver_paths || []).join("\n");
jdbcManualClasspathOpen.value = supportsNativeAgentJdbcDriverConfigType(config.db_type) || (config.jdbc_driver_paths || []).length > 0;
customDriverName.value = isCustomCompatibleProfile() ? config.driver_label || "" : "";
dialogStep.value = "config";
configTab.value = initialConfigTab();
if (props.updatePrefill) finishApplyingConnectionUpdate();
// Form/profile watchers normalize derived fields in this flush. Capture
// the saved baseline afterwards so those initial changes are not treated
// as user edits that invalidate persisted database metadata.
void nextTick(() => {
if (open.value && props.editConfig?.id === config.id && !props.updatePrefill) applySavedDatabaseInfo(config);
});
} else {
appliedConnectionUpdate = null;
clearSavedDatabaseInfo();
editingId.value = null;
selectedConnectionGroupId.value = initialConnectionGroupId();
form.value = defaultForm();
redisKeyTemplatesText.value = "";
productionProtectionEnabled.value = false;
selectedTransportLayerId.value = null;
selectedType.value = "mysql";
pluginFormValues.value = {};
customDriverName.value = "";
resetMqFields();
resetCassandraTlsFields(undefined);
resetNacosFields();
resetInfluxDbFields();
resetElasticsearchProxyFields();
resetHiveKerberosFields();
resetDamengJvmOptions();
oceanbaseSubMode.value = "mysql";
h2ConnectionMode.value = "file";
dremioConnectionMode.value = "legacy";
resetDremioConnectionUrls();
resetJdbcProductConnectionFields(undefined);
dialogStep.value = "select";
configTab.value = "connection";
}
resetTestState();
},
{ immediate: true },
);
watch(
() => settingsStore.editorSettings.sidebarShowConnectionNotes,
(value) => syncConnectionNoteVisibilityDraft(connectionNoteVisibilityDraft, value),
);
const isEditing = ref(false);
watch(
() => editingId.value,
(v) => {
isEditing.value = !!v;
},
);
// 删除连接时若开启了「记住连接名与数据库」,新建同名**同类型**连接会自动选中记住的数据库。
// 只在数据库字段为空、或仍是上一次自动回填的值时才覆盖,避免抢走用户手输的内容。
const lastRememberedDatabaseAutofill = ref("");
watch(
() => [open.value, editingId.value, form.value.name, form.value.db_type] as const,
([isOpen, editing, rawName, dbType]) => {
if (!isOpen || editing) {
lastRememberedDatabaseAutofill.value = "";
return;
}
const name = (rawName ?? "").trim();
const remembered = name ? settingsStore.rememberedDatabaseForConnection(name, dbType) : "";
const current = (form.value.database ?? "").trim();
if (current === remembered) {
lastRememberedDatabaseAutofill.value = remembered;
return;
}
if (current && current !== lastRememberedDatabaseAutofill.value) return;
form.value.database = remembered || undefined;
lastRememberedDatabaseAutofill.value = remembered;
},
{ immediate: true },
);
const databaseLabel = computed(() => {
if (form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns") return t("connection.oracleTnsAlias");
if (form.value.db_type === "oracle") return t("connection.serviceName");
if (form.value.db_type === "influxdb" && influxDbVersion.value === "2") return "Bucket";
return t("connection.database");
});
const databasePlaceholder = computed(() => {
if (form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns") return t("connection.oracleTnsAliasPlaceholder");
if (form.value.db_type === "xugu") return t("connection.databasePlaceholderRequired");
if (form.value.db_type === "kingbase") return t("connection.databasePlaceholderRequired");
const fallback = defaultDatabaseForProfile();
if (!fallback) return t("connection.databasePlaceholder");
return t("connection.databasePlaceholderWithDefault", { database: fallback });
});
const transportLayers = computed(() => form.value.transport_layers || []);
const hasEnabledSshLayer = computed(() => transportLayers.value.some((layer) => layer.enabled !== false && layer.type === "ssh"));
const selectedTransportLayer = computed(() => {
const layers = transportLayers.value;
return layers.find((layer) => layer.id === selectedTransportLayerId.value) || layers[0] || null;
});
const selectedSshLayer = computed(() => (selectedTransportLayer.value?.type === "ssh" ? selectedTransportLayer.value : null));
const selectedProxyLayer = computed(() => (selectedTransportLayer.value?.type === "proxy" ? selectedTransportLayer.value : null));
const selectedHttpTunnelLayer = computed(() => (selectedTransportLayer.value?.type === "http_tunnel" ? selectedTransportLayer.value : null));
const tunnelProfiles = computed(() => {
const profiles = tunnelProfileStore.profiles;
if (!sqliteRemoteTransportRestricted.value) return profiles;
return profiles.filter((profile) => isSqliteRemoteTransportLayerType(profile.type));
});
const selectedLayerProfileId = computed(() => selectedTransportLayer.value?.profile_id || "");
const selectedLayerProfile = computed(() => tunnelProfileStore.profileById(selectedLayerProfileId.value));
function tunnelProfileOptionLabel(profile: (typeof tunnelProfiles.value)[number]): string {
const summary = tunnelProfileSummary(profile);
if (!profile.name?.trim()) return summary || profile.id;
return summary ? `${profile.name} (${summary})` : profile.name;
}
function applyTunnelProfileSelection(value: unknown) {
const selected = selectedTransportLayer.value;
if (!selected) return;
if (!value || value === "custom") {
if (!selected.profile_id) return;
const detached = detachTunnelProfileLayer(selected, tunnelProfileStore.profileById(selected.profile_id));
form.value.transport_layers = transportLayers.value.map((layer) => (layer.id === selected.id ? detached : layer));
} else {
const profile = tunnelProfileStore.profileById(String(value));
if (!profile) return;
const stub = tunnelProfileReferenceLayer(profile, selected);
form.value.transport_layers = transportLayers.value.map((layer) => (layer.id === selected.id ? stub : layer));
}
resetTestState();
}
function transportLayerDefaultName(layer: TransportLayerConfig, index: number): string {
if (layer.type === "proxy") return `Proxy ${index + 1}`;
if (layer.type === "http_tunnel") return t("connection.httpTunnelDefaultName", { index: index + 1 });
return t("connection.sshHopDefaultName", { index: index + 1 });
}
function transportLayerDisplayName(layer: TransportLayerConfig, index: number): string {
if (layer.profile_id) {
const profile = tunnelProfileStore.profileById(layer.profile_id);
if (profile) return profile.name?.trim() || tunnelProfileSummary(profile) || transportLayerDefaultName(layer, index);
return layer.name?.trim() || t("connection.tunnelProfileMissingName");
}
const target = layer.type === "http_tunnel" ? layer.url?.trim() : layer.host?.trim();
return layer.name?.trim() || target || transportLayerDefaultName(layer, index);
}
const transportPathSegments = computed(() => {
const layers = transportLayers.value.filter((layer) => layer.enabled !== false);
return ["DBX", ...layers.map(transportLayerDisplayName), form.value.host || "Database"];
});
function defaultDatabaseForProfile() {
if (form.value.db_type === "redshift") return "dev";
if (form.value.db_type === "redis") return "0";
if (form.value.db_type === "gaussdb") return "postgres";
if (form.value.db_type === "kwdb") return "defaultdb";
if (form.value.db_type === "databend") return "default";
if (selectedType.value === "cockroachdb") return "defaultdb";
if (form.value.db_type === "highgo") return "highgo";
if (form.value.db_type === "uxdb") return "uxdb";
if (form.value.db_type === "yashandb") return "yasdb";
if (form.value.db_type === "postgres" || form.value.db_type === "vastbase") return "postgres";
if (form.value.db_type === "sqlserver") return "master";
if (form.value.db_type === "oracle") return "ORCL";
if (form.value.db_type === "h2" && h2ConnectionMode.value === "tcp") return "test";
return "";
}
function onDbTypeChange(val: string) {
if (!editingId.value && val === selectedType.value) return;
if (!editingId.value) {
resetForm({ preservePickerState: true });
}
const category = dbCategoryForOption(val);
if (category) selectedDbCategory.value = category;
// Keep in sync with PLUGIN_CONNECTION_PROVIDER_OPTION_PREFIX in lib/plugins/frontendPlugin.
if (val.startsWith("plugin-provider:")) {
onPluginProviderOptionChange(val);
return;
}
customDriverName.value = "";
applyProfile(val, !!editingId.value);
resetTestState();
resetVisibleSchemasState();
}
function onPluginProviderOptionChange(val: string) {
const pluginTarget = parsePluginConnectionProviderOptionValue(val);
if (pluginTarget) {
const existing = props.editConfig?.db_type === "plugin" && props.editConfig.plugin_id === pluginTarget.pluginId && props.editConfig.plugin_connection_provider === pluginTarget.providerId ? props.editConfig : undefined;
applyPluginProvider(val, !!editingId.value, existing);
}
resetTestState();
resetVisibleSchemasState();
}
function selectIgniteConnectionProfile(profile: IgniteConnectionProfile) {
if (form.value.db_type === profile) return;
const category = dbCategoryForOption(profile);
if (category) selectedDbCategory.value = category;
customDriverName.value = "";
applyProfile(profile, true);
resetTestState();
resetVisibleSchemasState();
}
function switchH2ConnectionMode(mode: H2ConnectionMode) {
h2ConnectionMode.value = mode;
if (mode === "file") {
form.value.host = h2FilePathFromJdbcUrl(form.value.connection_string) || "";
form.value.port = 0;
} else {
form.value.host = form.value.host.trim() && !isH2FileJdbcUrlLikePath(form.value.host) ? form.value.host : "127.0.0.1";
form.value.port = form.value.port || 9092;
if (form.value.connection_string && h2FilePathFromJdbcUrl(form.value.connection_string)) {
form.value.connection_string = undefined;
}
}
resetTestState();
}
function switchEtcdApiVersion(profile: "etcd" | "etcd-v2") {
form.value.driver_profile = profile;
resetTestState();
}
function switchH2DriverProfile(profile: "h2" | "h2-v1" | "h2-v2" | "h2-v3" | "h2-custom") {
form.value.driver_profile = profile;
if (profile === "h2-custom") {
form.value.jdbc_driver_class = form.value.jdbc_driver_class?.trim() || "org.h2.Driver";
jdbcManualClasspathOpen.value = true;
} else {
form.value.jdbc_driver_class = undefined;
form.value.jdbc_driver_paths = [];
jdbcDriverPathsInput.value = "";
selectedJdbcDriverPath.value = "";
jdbcManualClasspathOpen.value = false;
}
resetTestState();
}
const damengDriverMode = computed(() => damengDriverModeForConfig(form.value));
const isDamengCustomDriver = computed(() => form.value.db_type === "dameng" && damengDriverMode.value === "custom");
function setDamengDriverMode(mode: DamengDriverMode) {
if (mode === "builtin") {
form.value.driver_profile = DAMENG_BUILTIN_DRIVER_PROFILE;
form.value.connection_string = undefined;
form.value.jdbc_driver_class = undefined;
form.value.jdbc_driver_paths = [];
jdbcDriverPathsInput.value = "";
selectedJdbcDriverPath.value = "";
jdbcManualClasspathOpen.value = false;
} else {
form.value.driver_profile = DAMENG_CUSTOM_DRIVER_PROFILE;
// Do not materialize connection_string here: submit regenerates it from
// the live form, and freezing the URL at switch time would keep the
// connection on the old host/port after later edits.
form.value.jdbc_driver_class = form.value.jdbc_driver_class?.trim() || DAMENG_DEFAULT_JDBC_DRIVER_CLASS;
jdbcManualClasspathOpen.value = true;
}
resetTestState();
}
function isH2FileJdbcUrlLikePath(value: string): boolean {
return /\.(mv|h2)\.db$/i.test(value.trim()) || value.includes("/") || value.includes("\\");
}
const iconTypeMap: Record<string, string> = {
...CONNECTION_PROFILE_ICONS,
...jdbcProductIconTypes(),
};
const dbCategoryMetadata: Array<{ key: DbCategoryKey; titleKey: string }> = [
{ key: "sql", titleKey: "connection.databaseCategorySql" },
{ key: "analytics", titleKey: "connection.databaseCategoryAnalytics" },
{ key: "domestic", titleKey: "connection.databaseCategoryDomestic" },
{ key: "lightweight", titleKey: "connection.databaseCategoryLightweight" },
{ key: "document", titleKey: "connection.databaseCategoryDocument" },
{ key: "graph_ai", titleKey: "connection.databaseCategoryGraphAi" },
{ key: "timeseries", titleKey: "connection.databaseCategoryTimeseries" },
{ key: "mq", titleKey: "connection.databaseCategoryMq" },
{ key: "registry_config", titleKey: "connection.databaseCategoryRegistryConfig" },
];
function jdbcProductCategory(profileId: string): ConnectionProfileCategory {
const category: DbCategoryKey | undefined = dbCategoryMetadata.find(({ key }) => jdbcProductProfileIdsForCategory(key).includes(profileId))?.key;
if (category !== "plugins" && category) return category;
throw new Error(`JDBC product profile ${profileId} has no connection picker category`);
}
// `influxdb3` is presented as a version option inside the InfluxDB card
// (see the version <Select> below), not as a standalone picker entry.
const dbOptions: DbOption[] = [...CONNECTION_PICKER_OPTIONS.filter((option) => option.value !== "influxdb3"), ...jdbcProductPickerOptions().map((option) => ({ ...option, category: jdbcProductCategory(option.value) }))];
const dbCategoryDefinitions = dbCategoryMetadata.map((category) => ({
...category,
optionValues: dbOptions.filter((option) => option.category === category.key).map((option) => option.value),
}));
// Keep the picker exhaustive as database drivers are added or reorganized.
assertCompleteDatabaseCategories(
dbOptions.map((option) => option.value),
dbCategoryDefinitions.map((category) => category.optionValues),
);
const hiddenPickerOptionTypes = new Set(Object.keys(MERGED_PICKER_OPTION_FOR_TYPE));
const dbCategories = computed<DbCategory[]>(() => {
const categories: DbCategory[] = dbCategoryDefinitions.map((category) => ({
key: category.key,
title: t(category.titleKey),
options: dbOptions.filter((option) => category.optionValues.includes(option.value) && !hiddenPickerOptionTypes.has(option.value)),
}));
const pluginOptions: DbOption[] = pluginConnectionProviders.value.map((entry) => ({
value: pluginConnectionProviderOptionValue(entry.plugin.manifest.id, entry.contribution.id),
label: entry.contribution.label,
plugin: true,
pluginId: entry.plugin.manifest.id,
pluginIcon: pluginConnectionProviderIcon(entry),
}));
if (pluginOptions.length) {
categories.push({ key: "plugins", title: t("connection.databaseCategoryPlugins"), options: pluginOptions });
}
return categories;
});
function matchesDbOption(option: DbOption, keyword: string, categoryTitle = "") {
const profile = driverProfiles[option.value];
return [option.label, option.value, profile?.label, profile?.type, categoryTitle, ...(PICKER_SEARCH_ALIASES[option.value] ?? [])].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword),
);
}
const isDbSearchActive = computed(() => !!dbSearchQuery.value.trim());
const filteredDbCategories = computed<DbCategory[]>(() => {
const keyword = dbSearchQuery.value.trim().toLowerCase();
if (!isDbSearchActive.value) return dbCategories.value;
return dbCategories.value
.map((category) => ({
...category,
options: category.options.filter((option) => matchesDbOption(option, keyword, category.title)),
}))
.filter((category) => category.options.length > 0);
});
const visibleDbCategories = computed<DbCategory[]>(() => {
if (isDbSearchActive.value) return filteredDbCategories.value;
return filteredDbCategories.value.filter((category) => category.key === selectedDbCategory.value);
});
const hasDbPickerResults = computed(() => visibleDbCategories.value.some((category) => category.options.length > 0));
function isPickerOptionSelected(optionValue: string): boolean {
return selectedType.value === optionValue || MERGED_PICKER_OPTION_FOR_TYPE[selectedType.value] === optionValue;
}
const selectedDbOptionIsVisible = computed(() => visibleDbCategories.value.some((category) => category.options.some((option) => isPickerOptionSelected(option.value))));
function selectDbCategory(category: DbCategoryKey) {
selectedDbCategory.value = category;
dbSearchQuery.value = "";
const categoryOptions = dbCategories.value.find((definition) => definition.key === category)?.options.map((option) => option.value) ?? [];
const nextSelection = databaseSelectionForCategory(selectedType.value, categoryOptions);
if (nextSelection && nextSelection !== selectedType.value) onDbTypeChange(nextSelection);
}
function selectDbPickerView(view: DbPickerView) {
dbPickerView.value = view;
saveConnectionPickerView(view);
}
function dbCategoryForOption(value: string): DbCategoryKey | undefined {
const pickerValue = MERGED_PICKER_OPTION_FOR_TYPE[value] ?? value;
return dbCategories.value.find((category) => category.options.some((option) => option.value === pickerValue))?.key;
}
const selectedDbIcon = computed(() => (isPluginConnection.value ? "plugin" : iconTypeMap[selectedType.value] || selectedProfile().icon || selectedType.value));
function supportsNativeAgentJdbcDriverConfigType(dbType: DatabaseType): boolean {
return dbType === "prestosql" || dbType === "bigquery" || dbType === "dameng";
}
const jdbcBackedDatabaseTypes = new Set<DatabaseType>(["jdbc", "prestosql", "bigquery"]);
const connectionFormKind = computed(() => databaseConnectionFormKind(form.value.db_type));
const isJdbcConnection = computed(() => connectionFormKind.value === "jdbc");
const isJdbcxConnection = computed(() => isJdbcConnection.value && form.value.driver_profile === JDBCX_DRIVER_PROFILE);
const isJdbcProductConnection = computed(() => Boolean(activeJdbcProductProfile.value));
const jdbcxHighPrivilegeExtensionsAllowed = computed({
get: () => jdbcxHighPrivilegeExtensionsEnabled(form.value),
set: (enabled: boolean) => {
setJdbcxHighPrivilegeExtensionsEnabled(form.value, enabled);
resetTestState();
},
});
const supportsNativeAgentJdbcDriverConfig = computed(() => supportsNativeAgentJdbcDriverConfigType(form.value.db_type) && (form.value.db_type !== "dameng" || isDamengCustomDriver.value));
const isH2FileMode = computed(() => form.value.db_type === "h2" && h2ConnectionMode.value === "file");
const isH2CustomDriver = computed(() => form.value.db_type === "h2" && form.value.driver_profile === "h2-custom");
const usesLocalFilePathInput = computed(() => isLocalFileTypeDb(form.value.db_type) && (form.value.db_type !== "h2" || isH2FileMode.value));
const connectionUrlPlaceholder = computed(() => getUrlPlaceholder(form.value.db_type, form.value.driver_profile));
const jdbcUsernamePlaceholder = computed(() => (form.value.driver_profile === "dremio" || isJdbcProductConnection.value ? "" : "sa"));
const filePathPlaceholder = computed(() => {
if (form.value.db_type === "duckdb") return "/path/to/database.duckdb or :memory:";
if (form.value.db_type === "access") return "/path/to/database.accdb";
if (form.value.db_type === "h2") return "/path/to/database.mv.db";
return "/path/to/database.db or :memory:";
});
const supportsMemoryDatabasePath = computed(() => form.value.db_type === "sqlite" || form.value.db_type === "duckdb");
const sqliteExtensionPaths = computed({
get: () => sqliteExtensionPathsFromParams(form.value.url_params),
set: (value: string) => {
form.value.url_params = setSqliteExtensionPaths(form.value.url_params, value);
},
});
const tlsCapableDatabaseTypes = new Set<DatabaseType>([
"mysql",
"starrocks",
"postgres",
"redshift",
"gaussdb",
"kwdb",
"opengauss",
"questdb",
"dameng",
"redis",
"etcd",
"consul",
"clickhouse",
"elasticsearch",
"easysearch",
"meilisearch",
"solr",
"hbase",
"qdrant",
"milvus",
"weaviate",
"chromadb",
"influxdb",
"victoriametrics",
"cassandra",
"zookeeper",
]);
const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type) || supportsMysqlTlsTab(form.value.db_type, selectedType.value));
const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse" || form.value.db_type === "victoriametrics");
const supportsGenericUrlParams = computed(() => form.value.db_type !== "manticoresearch" && form.value.db_type !== "hbase");
const showGenericUrlParamsHint = computed(() => form.value.db_type === "mysql" || form.value.db_type === "doris" || form.value.db_type === "starrocks");
const bareMysqlProfiles = new Set(["doris", "selectdb", "oceanbase"]);
const supportsMysqlTlsOptions = computed(() => mysqlTlsOptionsSupported(form.value.db_type, selectedType.value));
const supportsMysqlCleartextPasswordAuth = computed(() => form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value));
const supportsDoltSystemTables = computed(() => isDoltDriverProfile(form.value.driver_profile));
const showDoltSystemTables = computed({
get: () => doltSystemTablesVisible(form.value),
set: (visible: boolean) => setDoltSystemTablesVisible(form.value, visible),
});
const mysqlCleartextPasswordAuth = computed({
get: () => mysqlCleartextPasswordAuthEnabled(form.value.url_params),
set: (value: boolean) => {
form.value.url_params = setMysqlCleartextPasswordAuthEnabled(form.value.url_params, value);
},
});
// DM8 configures SSL through JDBC URL parameters, so the TLS form and Advanced tab share one source of truth.
const tlsEnabled = computed({
get: () => !!form.value.ssl || (form.value.db_type === "dameng" && damengSslFormConfig(form.value.url_params).enabled),
set: (enabled: boolean) => {
form.value.ssl = enabled;
if (form.value.db_type === "dameng" && !enabled) {
form.value.url_params = applyDamengSslUrlParams(form.value.url_params, false, "", "", "");
}
},
});
const damengSslFilesPath = computed({
get: () => damengSslFormConfig(form.value.url_params).sslFilesPath,
set: (value: string) => {
const current = damengSslFormConfig(form.value.url_params);
form.value.url_params = applyDamengSslUrlParams(form.value.url_params, true, value, current.sslKeystorePassword, current.sslProtocol);
},
});
const damengSslKeystorePassword = computed({
get: () => damengSslFormConfig(form.value.url_params).sslKeystorePassword,
set: (value: string) => {
const current = damengSslFormConfig(form.value.url_params);
form.value.url_params = applyDamengSslUrlParams(form.value.url_params, true, current.sslFilesPath, value, current.sslProtocol);
},
});
const damengSslProtocol = computed({
get: () => damengSslFormConfig(form.value.url_params).sslProtocol,
set: (value: string) => {
const current = damengSslFormConfig(form.value.url_params);
form.value.url_params = applyDamengSslUrlParams(form.value.url_params, true, current.sslFilesPath, current.sslKeystorePassword, value);
},
});
const mysqlTlsMode = computed({
get: () => mysqlTlsModeFromParams(form.value.url_params, form.value.ssl),
set: (value: string) => {
form.value.ssl = value !== "preferred" && value !== "disabled";
form.value.url_params = applyMysqlTlsMode(form.value.url_params, value);
},
});
const mysqlClientCertPath = computed({
get: () => getUrlParam(form.value.url_params, "ssl-cert") || getUrlParam(form.value.url_params, "sslcert"),
set: (value: string) => {
let next = setUrlParam(form.value.url_params, "sslcert", "");
form.value.url_params = setUrlParam(next, "ssl-cert", value);
},
});
const mysqlClientKeyPath = computed({
get: () => getUrlParam(form.value.url_params, "ssl-key") || getUrlParam(form.value.url_params, "sslkey"),
set: (value: string) => {
let next = setUrlParam(form.value.url_params, "sslkey", "");
form.value.url_params = setUrlParam(next, "ssl-key", value);
},
});
const nativePostgresTlsDatabaseTypes = new Set<DatabaseType>(["postgres", "redshift", "gaussdb", "kwdb", "opengauss"]);
const supportsPostgresTlsOptions = computed(() => nativePostgresTlsDatabaseTypes.has(form.value.db_type));
const postgresTlsMode = computed({
get: () => postgresTlsModeForForm(getUrlParam(form.value.url_params, "sslmode"), form.value.ssl),
set: (value: string) => {
form.value.ssl = value !== "disable";
form.value.url_params = setUrlParam(form.value.url_params, "sslmode", value);
},
});
const postgresLegacyTls = computed({
get: () => postgresLegacyTlsEnabled(form.value.url_params),
set: (value: boolean) => {
form.value.url_params = setPostgresLegacyTlsEnabled(form.value.url_params, value);
},
});
const postgresRootCertPath = computed({
get: () => getUrlParam(form.value.url_params, "sslrootcert"),
set: (value: string) => {
form.value.url_params = setUrlParam(form.value.url_params, "sslrootcert", value);
},
});
const postgresClientCertPath = computed({
get: () => getUrlParam(form.value.url_params, "sslcert"),
set: (value: string) => {
form.value.url_params = setUrlParam(form.value.url_params, "sslcert", value);
},
});
const postgresClientKeyPath = computed({
get: () => getUrlParam(form.value.url_params, "sslkey"),
set: (value: string) => {
form.value.url_params = setUrlParam(form.value.url_params, "sslkey", value);
},
});
const redisTlsInsecure = computed({
get: () => getUrlParam(form.value.url_params, "insecure").toLowerCase() === "true",
set: (value: boolean) => {
form.value.url_params = setUrlParam(form.value.url_params, "insecure", value ? "true" : "");
},
});
const etcdEndpointsLines = computed({
get: () => form.value.etcd_endpoints || "",
set: (value: string) => {
form.value.etcd_endpoints = normalizeEndpointLines(value);
},
});
const etcdGrpcMaxInboundMessageSizeMiB = computed({
get: () => {
const configuredBytes = Number(getUrlParam(form.value.url_params, ETCD_GRPC_MAX_INBOUND_PARAM));
if (!Number.isFinite(configuredBytes) || configuredBytes <= 0) return ETCD_GRPC_MAX_INBOUND_DEFAULT_MIB;
return Math.min(ETCD_GRPC_MAX_INBOUND_MAX_MIB, Math.max(ETCD_GRPC_MAX_INBOUND_MIN_MIB, Math.round(configuredBytes / (1024 * 1024))));
},
set: (value: number) => {
const configuredMiB = Number(value);
const normalizedMiB = Number.isFinite(configuredMiB) ? Math.min(ETCD_GRPC_MAX_INBOUND_MAX_MIB, Math.max(ETCD_GRPC_MAX_INBOUND_MIN_MIB, Math.round(configuredMiB))) : ETCD_GRPC_MAX_INBOUND_DEFAULT_MIB;
form.value.url_params = setUrlParam(form.value.url_params, ETCD_GRPC_MAX_INBOUND_PARAM, String(normalizedMiB * 1024 * 1024));
},
});
const zookeeperConnectString = computed({
get: () => form.value.connection_string || "",
set: (value: string) => {
form.value.connection_string = normalizeZooKeeperConnectString(value);
},
});
const zookeeperAuthScheme = computed<ZooKeeperAuthScheme>({
get: () => resolveZooKeeperAuthScheme(form.value.url_params),
set: (scheme) => {
form.value.url_params = setZooKeeperAuthScheme(form.value.url_params, scheme);
resetTestState();
},
});
const canUseTransportLayers = computed(() => {
if (form.value.db_type === "access" || isCloudflareD1Connection(form.value) || isH2FileMode.value || (form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns")) {
return false;
}
if (form.value.db_type === "sqlite") {
return isDesktop;
}
return true;
});
const sqliteRemoteTransportRestricted = computed(() => form.value.db_type === "sqlite");
const sqliteUsesSsh = computed(() => form.value.db_type === "sqlite" && connectionUsesSsh(form.value));
const sqliteWorkerPlacement = computed({
get: () => getUrlParam(form.value.url_params, "dbx_sqlite_worker") || "session",
set: (value: string) => {
const next = value === "session" ? "" : value;
form.value.url_params = setUrlParam(form.value.url_params, "dbx_sqlite_worker", next);
if (value !== "preplaced" && !getUrlParam(form.value.url_params, "dbx_sqlite_worker_path")) {
return;
}
if (value === "session") {
form.value.url_params = setUrlParam(form.value.url_params, "dbx_sqlite_worker_path", "");
}
},
});
const sqliteWorkerPath = computed({
get: () => getUrlParam(form.value.url_params, "dbx_sqlite_worker_path"),
set: (value: string) => {
form.value.url_params = setUrlParam(form.value.url_params, "dbx_sqlite_worker_path", value);
},
});
const sqliteWorkerPlacementOptions = [
{
value: "session",
labelKey: "connection.sqliteWorkerPlacementSession",
hintKey: "connection.sqliteWorkerPlacementSessionHint",
recommended: true,
},
{
value: "persist",
labelKey: "connection.sqliteWorkerPlacementPersist",
hintKey: "connection.sqliteWorkerPlacementPersistHint",
recommended: false,
},
{
value: "preplaced",
labelKey: "connection.sqliteWorkerPlacementPreplaced",
hintKey: "connection.sqliteWorkerPlacementPreplacedHint",
recommended: false,
},
] as const;
const shouldShowSqliteSshWorkerInstallHint = computed(() => form.value.db_type === "sqlite" && sqliteUsesSsh.value && showAgentDriverInstallHint("sqlite", agentDrivers.value, form.value.driver_profile, { ssh: true }));
const shouldShowAgentDriverInstallHint = computed(() => {
if (form.value.db_type === "sqlite" && sqliteUsesSsh.value) return false;
return showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile, { ssh: sqliteUsesSsh.value });
});
const agentDriverFocus = computed<DriverStoreFocus>(() => ({ target: "driver", driver: agentDriverInstallKey(form.value.db_type, form.value.driver_profile, { ssh: sqliteUsesSsh.value }) }));
const h2DriverMissing = computed(() => form.value.db_type === "h2" && isH2FileMode.value && agentDrivers.value.find((d) => d.db_type === "h2")?.installed !== true);
const canChooseVisibleNacosNamespaces = computed(() => form.value.db_type === "nacos");
const isNacosV3AdminPlane = computed(() => nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "admin");
const isNacosV3ConsolePlane = computed(() => nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console");
const canDetectNacosNamespaceAccess = computed(() => form.value.db_type === "nacos" && nacosAuthKind.value === "usernamePassword");
const nacosManualNamespaceLabelKey = computed(() => (isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespaces" : "nacos.nacosManagedNamespacesNameOrId"));
const nacosManualNamespacePlaceholderKey = computed(() => (isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespacesIdPlaceholder" : "nacos.nacosManagedNamespacesPlaceholder"));
const nacosManualNamespaceHintKey = computed(() => {
if (isNacosV3AdminPlane.value) return "nacos.nacosV3AdminManagedNamespacesHint";
if (isNacosV3ConsolePlane.value) return "nacos.nacosV3ConsoleManagedNamespacesHint";
return "nacos.nacosManagedNamespacesHint";
});
const nacosNamespacePickerTitleKey = computed(() => (canDetectNacosNamespaceAccess.value ? "nacos.nacosDetectAccessibleNamespaces" : "nacos.nacosVisibleNamespacesTitle"));
const nacosNamespacePickerDescriptionKey = computed(() => (canDetectNacosNamespaceAccess.value ? "nacos.nacosDetectAccessibleNamespacesHint" : "nacos.nacosVisibleNamespacesDescription"));
function hasNacosNamespaceScopeForSave(): boolean {
if (!canDetectNacosNamespaceAccess.value) return true;
if (nacosDynamicAllNamespaces.value) return true;
if (parseNacosManagedNamespaces(nacosManagedNamespacesText.value).length > 0) return true;
return Array.isArray(form.value.visible_databases) && form.value.visible_databases.length > 0;
}
const canChooseVisibleDatabases = computed(() => !canChooseVisibleNacosNamespaces.value && connectionCanChooseVisibleDatabases(form.value));
const visibleFilterUsesSchemas = computed(() => connectionUsesVisibleSchemaFilter(form.value));
const hasVisibleDatabaseFilter = computed(() => Array.isArray(form.value.visible_databases));
const visibleDatabaseSummary = computed(() => {
const configured = form.value.visible_databases;
if (!Array.isArray(configured)) return t("visibleDatabases.showAll");
return t("visibleDatabases.selectedCount", { selected: configured.length, total: visibleDatabaseNames.value.length });
});
const defaultListedVisibleDatabaseNames = computed(() => {
const connection = connectionConfigSnapshotForVisibleDatabases();
if (visibleFilterUsesSchemas.value) return filterSchemaNamesForVisiblePicker(visibleDatabaseNames.value, connection);
return filterDatabaseNamesForVisiblePicker(visibleDatabaseNames.value, connection);
});
const listedVisibleDatabaseNames = computed(() => (visibleDatabaseShowSystem.value ? visibleDatabaseNames.value : defaultListedVisibleDatabaseNames.value));
const filteredVisibleDatabaseNames = computed(() => {
const query = visibleDatabaseSearchText.value.trim().toLowerCase();
if (!query) return listedVisibleDatabaseNames.value;
return listedVisibleDatabaseNames.value.filter((name) => name.toLowerCase().includes(query));
});
const visibleDatabaseSelectedCount = computed(() => visibleDatabaseSelection.value.size);
const visibleDatabaseTotalCount = computed(() => listedVisibleDatabaseNames.value.length);
const visibleDatabaseCanSave = computed(() => canSaveVisibleDatabaseSelection([...visibleDatabaseSelection.value]));
const visibleDatabaseHasSystemObjects = computed(() => defaultListedVisibleDatabaseNames.value.length < visibleDatabaseNames.value.length);
const visibleSystemObjectsLabelKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.showSystemSchemas" : "visibleDatabases.showSystemDatabases"));
const filteredVisibleNacosNamespaces = computed(() => {
const query = visibleNacosNamespaceSearchText.value.trim().toLowerCase();
if (!query) return visibleNacosNamespaces.value;
return visibleNacosNamespaces.value.filter((namespace) => {
const label = namespace.namespaceShowName || namespace.namespace || "public";
return `${label} ${namespace.namespace}`.toLowerCase().includes(query);
});
});
const visibleNacosNamespaceSelectedCount = computed(() => visibleNacosNamespaceSelection.value.size);
const visibleNacosNamespaceCanSave = computed(() => (visibleNacosNamespaceAccessMode.value === "manual" ? parseNacosManagedNamespaces(nacosManagedNamespacesText.value).length > 0 : visibleNacosNamespaceSelection.value.size > 0));
const filteredProductionDatabaseNames = computed(() => {
const query = productionDatabaseSearchText.value.trim().toLowerCase();
if (!query) return productionDatabaseNames.value;
return productionDatabaseNames.value.filter((name) => name.toLowerCase().includes(query));
});
const productionDatabaseSelectedCount = computed(() => productionDatabaseSelection.value.size);
const productionDatabaseCanSave = computed(() => productionDatabaseNames.value.length > 0 && productionDatabaseSelection.value.size > 0);
const usesNacosProductionNamespaces = computed(() => form.value.db_type === "nacos");
const productionDisabledDescriptionKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaceDisabledDescription" : "production.disabledDescription"));
const productionConnectionDescriptionKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaceConnectionDescription" : "production.connectionDescription"));
const productionScopeAllLabelKey = computed(() => (usesNacosProductionNamespaces.value ? "production.allNamespaces" : "production.allDatabases"));
const productionScopeSelectedLabelKey = computed(() => (usesNacosProductionNamespaces.value ? "production.selectedNamespaces" : "production.selectedDatabases"));
const productionScopeResourceLabelKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaces" : "production.databases"));
const productionScopeDescriptionKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaceDescription" : "production.databaseDescription"));
const productionScopePickerLabelKey = computed(() => (usesNacosProductionNamespaces.value ? "production.selectNamespaces" : "production.selectDatabases"));
const productionPickerTitleKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespacePickerTitle" : "production.databasePickerTitle"));
const productionPickerDescriptionKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespacePickerDescription" : "production.databasePickerDescription"));
const productionPickerSearchPlaceholderKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaceSearchPlaceholder" : "production.databaseSearchPlaceholder"));
const productionPickerSelectionRequiredKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaceSelectionRequired" : "production.databaseSelectionRequired"));
const productionPickerLoadFailedKey = computed(() => (usesNacosProductionNamespaces.value ? "production.namespaceLoadFailed" : "production.databaseLoadFailed"));
const productionPickerEmptyKey = computed(() => (usesNacosProductionNamespaces.value ? "production.noNamespacesAvailable" : "production.noDatabasesAvailable"));
const productionDatabaseSummary = computed(() => {
const selected = form.value.production_databases?.length || 0;
if (!selected) return t("production.noDatabasesSelected");
if (!productionDatabaseNames.value.length) return t("production.databasesConfiguredCount", { count: selected });
return t("production.databasesSelectedCount", { selected, total: productionDatabaseNames.value.length });
});
const productionScope = computed<ProductionScope>({
get: () => (isSingleDatabase(form.value.db_type) || form.value.db_type === "mq" || form.value.db_type === "mqtt" || form.value.is_production ? "connection" : "databases"),
set: (scope) => {
form.value.is_production = isSingleDatabase(form.value.db_type) || form.value.db_type === "mq" || form.value.db_type === "mqtt" || scope === "connection";
},
});
// MQ/MQTT have no database list — production protection is always connection-scoped.
const canSelectProductionDatabases = computed(() => !isSingleDatabase(form.value.db_type) && form.value.db_type !== "mq" && form.value.db_type !== "mqtt");
function setProductionProtectionEnabled(enabled: boolean) {
productionProtectionEnabled.value = enabled;
if (!enabled) {
form.value.is_production = false;
form.value.production_databases = [];
} else if (!form.value.is_production && !form.value.production_databases?.length) {
// Enabling protection starts with the broadest scope until the user chooses a narrower one.
form.value.is_production = true;
}
}
const canChooseVisibleSchemas = computed(() => isSchemaAware(form.value.db_type));
const visibleSchemasDatabaseKey = computed(() => form.value.database || "");
const hasVisibleSchemaFilter = computed(() => {
const key = visibleSchemasDatabaseKey.value;
return Array.isArray(form.value.visible_schemas?.[key]);
});
const visibleSchemaObjectSelection = computed(() => {
const configured = form.value.visible_schemas?.[visibleSchemasDatabaseKey.value];
if (Array.isArray(configured)) return configured;
if (visibleFilterUsesSchemas.value && Array.isArray(form.value.visible_databases)) return form.value.visible_databases;
return undefined;
});
const visibleSchemaSummary = computed(() => {
const key = visibleSchemasDatabaseKey.value;
const configured = form.value.visible_schemas?.[key];
if (!Array.isArray(configured)) return t("visibleSchemas.showAll");
return t("visibleSchemas.selectedCount", { selected: configured.length, total: visibleSchemaNames.value.length });
});
const hasVisibleObjectFilter = computed(() => (visibleFilterUsesSchemas.value ? Array.isArray(visibleSchemaObjectSelection.value) : hasVisibleDatabaseFilter.value));
const visibleObjectSummary = computed(() => {
if (!visibleFilterUsesSchemas.value) return visibleDatabaseSummary.value;
const configured = visibleSchemaObjectSelection.value;
if (!Array.isArray(configured)) return t("visibleSchemas.showAll");
return t("visibleSchemas.selectedCount", { selected: configured.length, total: visibleDatabaseNames.value.length });
});
const visibleObjectTitleKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.title" : "visibleDatabases.title"));
const visibleObjectDescriptionKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.description" : "visibleDatabases.description"));
const visibleObjectSearchPlaceholderKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.searchPlaceholder" : "visibleDatabases.searchPlaceholder"));
const visibleObjectSelectedCountKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.selectedCount" : "visibleDatabases.selectedCount"));
const visibleObjectEmptySelectionKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.emptySelection" : "visibleDatabases.emptySelection"));
const visibleObjectLoadFailedKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.loadFailed" : "visibleDatabases.loadFailed"));
const visibleObjectSaveKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.save" : "visibleDatabases.save"));
const databaseInfoLabelKeys: Record<DatabaseInfoField, string> = {
productName: "connection.databaseInfo.productName",
productVersion: "connection.databaseInfo.productVersion",
currentDatabase: "connection.databaseInfo.currentDatabase",
serverComment: "connection.databaseInfo.serverComment",
serverCharset: "connection.databaseInfo.serverCharset",
serverCollation: "connection.databaseInfo.serverCollation",
unquotedIdentifierCase: "connection.databaseInfo.unquotedIdentifierCase",
quotedIdentifierCase: "connection.databaseInfo.quotedIdentifierCase",
driverName: "connection.databaseInfo.driverName",
driverVersion: "connection.databaseInfo.driverVersion",
jdbcVersion: "connection.databaseInfo.jdbcVersion",
};
function databaseInfoFieldLabel(field: DatabaseInfoField): string {
return t(databaseInfoLabelKeys[field]);
}
function databaseIdentifierCaseLabel(value: IdentifierCase): string {
return t(`connection.databaseInfo.identifierCase.${value}`);
}
const visibleTestDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => {
const result = testResult.value;
if (!result?.ok || !result.databaseInfo || !testedConfigFingerprint.value || !testedConfigId.value) return null;
try {
const current = connectionConfigForSubmit(testedConfigId.value, testedGeneratedName.value);
return connectionConfigFingerprint(current, form.value.name) === testedConfigFingerprint.value ? result.databaseInfo : null;
} catch {
return null;
}
});
const visibleSavedDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => {
if (!savedDatabaseInfo.value || !savedDatabaseInfoFingerprint.value || !editingId.value) return null;
try {
const current = connectionConfigForSubmit(editingId.value, form.value.name);
return connectionConfigFingerprint(current, form.value.name) === savedDatabaseInfoFingerprint.value ? savedDatabaseInfo.value : null;
} catch {
return null;
}
});
const configuredDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => {
const productName = configuredDatabaseProductName({
db_type: form.value.db_type,
driver_label: form.value.driver_label,
});
return normalizeDatabaseConnectionInfo(undefined, productName, form.value.database) ?? null;
});
const visibleDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value ?? configuredDatabaseInfo.value);
const databaseInfoVerified = computed(() => !!visibleTestDatabaseInfo.value || !!visibleSavedDatabaseInfo.value);
const databaseInfoStatusLabel = computed(() => (databaseInfoVerified.value ? t("connection.databaseInfo.sourceTested") : t("connection.databaseInfo.sourceConfigured")));
const databaseInfoDescription = computed(() => (databaseInfoVerified.value ? t("connection.databaseInfo.testedDescription") : t("connection.databaseInfo.configuredDescription")));
const databaseInfoDisplayRows = computed(() =>
visibleDatabaseInfo.value
? databaseInfoRows(visibleDatabaseInfo.value).map((row) => ({
...row,
label: databaseInfoFieldLabel(row.key),
displayValue: row.key === "unquotedIdentifierCase" || row.key === "quotedIdentifierCase" ? databaseIdentifierCaseLabel(row.value as IdentifierCase) : row.value,
}))
: [],
);
const databaseInfoCompactLabel = computed(() =>
databaseInfoDisplayRows.value
.filter((row) => row.key === "productName" || row.key === "productVersion" || row.key === "currentDatabase")
.slice(0, 3)
.map((row) => row.displayValue)
.join(" · "),
);
const testResultMessage = computed(() => {
if (!testResult.value) return "";
if (!testResult.value.ok) return translateBackendError(t, testResult.value.message);
return testResult.value.scope === "ssh" ? t("connection.sshTunnelTestSuccess") : t("connection.testSuccess");
});
const pluginActionStatusMessage = computed(() => {
if (!pluginActionStatus.value) return "";
return pluginActionStatus.value.ok ? pluginActionStatus.value.message : translateBackendError(t, pluginActionStatus.value.message);
});
const agentInstallPercent = computed(() => driverInstallProgressPercent(agentInstallProgress.value));
const agentInstallProgressLabel = computed(() => {
const progress = agentInstallProgress.value;
if (agentInstallError.value) return t("connection.driverInstall.statusFailed");
if (!agentInstallRunning.value) return t("connection.driverInstall.statusWaiting");
if (!progress) return t("connection.driverInstall.statusPreparing");
if (progress.step === "jre-extract") return t("connection.driverInstall.statusExtractingJre");
const label = progress.step === "jre" ? t("connection.driverInstall.stepJre") : progress.step === "driver" ? t("connection.driverInstall.stepDriver") : progress.step || t("connection.driverInstall.stepDefault");
if (!progress.total) return `${label}...`;
return `${label} ${formatInstallSize(progress.downloaded ?? 0)} / ${formatInstallSize(progress.total)} (${agentInstallPercent.value ?? 0}%)`;
});
const canCloseAgentInstallDialog = computed(() => !agentInstallRunning.value || !!agentInstallError.value);
const sqlServerDriverMode = computed<"auto" | "legacy">(() => (sqlServerUsesLegacyCompatibility(form.value) ? "legacy" : "auto"));
const shouldUseWideConnectionDialog = computed(
() => dialogStep.value === "config" && (canChooseVisibleDatabases.value || canChooseVisibleNacosNamespaces.value || (canChooseVisibleSchemas.value && !visibleFilterUsesSchemas.value) || (selectedPluginProvider.value?.contribution.fields.length ?? 0) >= 6),
);
const connectionDialogContentClass = computed(() => {
if (dialogStep.value === "select") return "connection-dialog-content--picker sm:h-[720px] sm:max-w-[880px]";
const widthClass = shouldUseWideConnectionDialog.value ? "connection-dialog-content--wide sm:max-w-[660px]" : "connection-dialog-content--standard sm:max-w-[560px]";
const scrollableAdvancedNacos = form.value.db_type === "nacos" && configTab.value === "advanced";
return `${widthClass} connection-dialog-content--config${scrollableAdvancedNacos ? " connection-dialog-content--scrollable" : ""}`;
});
const connectionLabelClass = "justify-self-start text-left";
const connectionLabelSmallClass = `${connectionLabelClass} text-xs`;
const connectionLabelTopClass = `${connectionLabelClass} mt-2`;
const connectionLabelSmallPaddedClass = `${connectionLabelClass} pt-2 text-xs`;
function pluginFieldValue(field: PluginFormField): PluginFormFieldValue {
if (field.binding === "name") return form.value.name;
return pluginFormValues.value[field.key] ?? field.default ?? undefined;
}
function pluginFieldHasValue(field: PluginFormField): boolean {
const value = pluginFieldValue(field);
return typeof value === "string" ? value.trim().length > 0 : value !== undefined;
}
function pluginFieldValueByKey(key: string): PluginFormFieldValue {
const field = selectedPluginProvider.value?.contribution.fields.find((candidate) => candidate.key === key);
return field ? pluginFieldValue(field) : undefined;
}
function pluginFieldVisible(field: PluginFormField): boolean {
return pluginFieldIsVisible(field, pluginFieldValueByKey, (key) => selectedPluginProvider.value?.contribution.fields.find((candidate) => candidate.key === key));
}
function pluginFieldRequired(field: PluginFormField): boolean {
return pluginFieldIsRequired(field, pluginFieldValueByKey);
}
function pluginActionLabel(action: PluginConnectionAction): string {
if (action.label) return action.label;
if (action.kind === "test") return t("connection.test");
if (action.kind === "save") return t("connection.save");
if (action.kind === "save-and-connect") return t("connection.saveAndConnect");
return action.id;
}
function pluginActionVariant(action: PluginConnectionAction): PluginConnectionAction["variant"] {
return action.variant || (action.kind === "save" || action.kind === "save-and-connect" ? "default" : "outline");
}
function pluginActionIsBusy(action: PluginConnectionAction): boolean {
if (action.kind === "test") return isTesting.value;
if (action.kind === "save" || action.kind === "save-and-connect") return isSaving.value;
return runningPluginActionId.value === action.id;
}
function pluginActionNeedsValidForm(action: PluginConnectionAction): boolean {
return action.kind !== "custom" || action.requires_valid_form !== false;
}
function pluginActionDisabled(action: PluginConnectionAction): boolean {
if (pluginFooterBusy.value) return true;
return pluginActionNeedsValidForm(action) && !hasRequiredConnectionTarget.value;
}
function updatePluginFormValues(values: Record<string, PluginFormFieldValue>) {
pluginFormValues.value = values;
resetTestState();
}
function applyPluginActionFieldValues(values: Record<string, PluginFormFieldValue | null> | undefined) {
if (!values) return;
const provider = selectedPluginProvider.value?.contribution;
if (!provider) return;
const nextValues = { ...pluginFormValues.value };
for (const [key, returnedValue] of Object.entries(values)) {
const field = provider.fields.find((candidate) => candidate.key === key);
if (!field) continue;
const value = returnedValue === null ? undefined : returnedValue;
if (field.binding === "name") {
form.value.name = typeof value === "string" ? value : "";
}
if (value === undefined) delete nextValues[key];
else nextValues[key] = value;
}
pluginFormValues.value = nextValues;
}
const hasRequiredConnectionTarget = computed(() => {
if (isPluginConnection.value) {
const entry = selectedPluginProvider.value;
return !!entry && entry.contribution.fields.every((field) => !pluginFieldVisible(field) || !pluginFieldRequired(field) || pluginFieldHasValue(field));
}
if (form.value.db_type === "mq") {
if (mqSystemKind.value === "kafka") return mqKafkaConnectionSource.value === "zookeeper" ? !!mqKafkaZooKeeperServers.value.trim() : !!mqKafkaBootstrapServers.value.trim();
if (mqSystemKind.value === "rocketmq") return !!mqRocketmqNamesrvAddr.value.trim();
if (mqSystemKind.value === "rabbitmq") return !!mqRabbitmqAddresses.value.trim();
return !!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 === "mqtt") return !!mqttHost.value.trim() && mqttPort.value > 0;
if (form.value.db_type === "nacos") return !!nacosServerAddr.value.trim();
if (form.value.db_type === "consul") return !!consulServerAddr.value.trim();
if (isCloudflareD1Connection(form.value)) return hasCloudflareD1Credentials(form.value);
// Cloud Spanner has no host to fall back on: the resource path is the target.
if (isSpannerConnection(form.value)) return hasSpannerResourcePath(form.value);
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());
});
const mongoAuthDatabase = computed({
get: () => mongoUrlParam(form.value.url_params, "authSource"),
set: (value: string) => {
form.value.url_params = setMongoUrlParam(form.value.url_params, "authSource", value);
},
});
const mongoAuthMechanism = computed({
get: () => mongoUrlParam(form.value.url_params, "authMechanism") || "default",
set: (value: string) => {
const previous = mongoUrlParam(form.value.url_params, "authMechanism");
let next = setMongoUrlParam(form.value.url_params, "authMechanism", value === "default" ? "" : value);
if (value === "MONGODB-OIDC") {
form.value.password = "";
mongoDriverMode.value = "auto";
next = setMongoUrlParam(next, "authSource", "$external");
} else if (previous === "MONGODB-OIDC" && mongoUrlParam(next, "authSource") === "$external") {
next = setMongoUrlParam(next, "authSource", "");
}
form.value.url_params = next;
},
});
const mongoUsesOidc = computed(() => mongoConnectionUsesOidc(mongoUseUrl.value ? undefined : form.value.url_params, mongoUseUrl.value ? form.value.connection_string : undefined));
const mongoTlsAllowInvalidCertificates = computed({
get: () => mongoUrlParamIsTrue(form.value.url_params, "tlsAllowInvalidCertificates"),
set: (value: boolean) => {
let next = setMongoUrlParamBoolean(form.value.url_params, "tlsAllowInvalidCertificates", value);
next = setMongoUrlParam(next, "tlsAllowInvalidHostnames", "");
form.value.url_params = next;
},
});
const mongoRetryWrites = computed({
get: () => mongoUrlParamIsTrue(form.value.url_params, "retryWrites", true),
set: (value: boolean) => {
form.value.url_params = setMongoUrlParamBoolean(form.value.url_params, "retryWrites", value, true);
},
});
const mongoDriverMode = computed({
get: () => (isMongoLegacyDriverProfile(form.value.driver_profile) ? "legacy" : "auto"),
set: (value: string) => {
form.value.driver_profile = value === "legacy" ? "mongodb-legacy" : "mongodb";
form.value.driver_label = value === "legacy" ? "MongoDB (Legacy)" : "MongoDB";
},
});
function goToConnectionStep(value = selectedType.value) {
if (value !== selectedType.value) {
onDbTypeChange(value);
}
dialogStep.value = "config";
configTab.value = "connection";
dbSearchQuery.value = "";
}
function backToDatabasePicker() {
const category = dbCategoryForOption(selectedType.value);
if (category) selectedDbCategory.value = category;
dialogStep.value = "select";
resetTestState();
}
const vConnectionDialogAutoFocus: ObjectDirective<HTMLInputElement> = {
mounted(input) {
input.focus({ preventScroll: true });
},
};
function handleDialogEscape(event: KeyboardEvent) {
if (dialogStep.value !== "config" || editingId.value) return;
event.preventDefault();
backToDatabasePicker();
}
watch(customDriverName, (value) => {
if (isCustomCompatibleProfile()) {
form.value.driver_label = value.trim() || selectedProfile().label;
}
});
async function testConnection() {
if (isTestingSshTunnel.value) return;
if (!ensureConnectionHostResolvedFromUrl()) return;
const runId = ++testRunId;
isTesting.value = true;
testResult.value = null;
testResultCopied.value = false;
let config: ConnectionConfig | null = null;
const submittedSourceName = form.value.name;
try {
config = connectionConfigForSubmit(editingId.value || draftTestConnectionId.value);
await ensureRequiredAgentDriverInstalled(config);
await ensureRequiredGaussdbMJdbcRuntime(config);
await ensureRequiredJdbcxDriverInstalled(config);
await ensureRequiredJdbcProductRuntimeInstalled(config);
const result = await testConnectionWithTimeout(config, runId);
if (runId !== testRunId) return;
let successfulConfig = config;
if (config.db_type === "mongodb" && /legacy driver/i.test(result.message)) {
mongoDriverMode.value = "legacy";
successfulConfig = connectionConfigForSubmit(config.id, config.name);
}
applySuccessfulConnectionTest(result, successfulConfig, submittedSourceName);
void persistSuccessfulConnectionTest(result, successfulConfig, submittedSourceName, runId);
clearEditedConnectionErrorAfterSuccessfulTest();
} catch (e: any) {
if (runId !== testRunId) return;
const rawMessage = mongodbAuthFailureHint(errorMessage(e));
const message = config ? connectionErrorWithDriverUpdateHint(config, rawMessage) : rawMessage;
const shouldShowSqlServerLegacyMode = config?.db_type === "sqlserver" && !sqlServerUsesLegacyCompatibility(config) && isSqlServerTlsHandshakeFailure(message);
if (shouldShowSqlServerLegacyMode) {
configTab.value = "advanced";
}
clearTestedConnectionInfo();
testResult.value = { ok: false, message };
showConnectionError(message);
} finally {
if (runId === testRunId) {
isTesting.value = false;
}
}
}
async function runPluginConnectionAction(action: PluginConnectionAction) {
if (!isPluginConnection.value || pluginFooterBusy.value) return;
pluginActionStatus.value = null;
if (action.kind === "test") {
await testConnection();
if (testResult.value?.ok && action.close_on_success === true) open.value = false;
return;
}
if (action.kind === "save" || action.kind === "save-and-connect") {
await save({
connectAfterSave: action.kind === "save-and-connect",
closeOnSuccess: action.close_on_success ?? true,
});
return;
}
if (!ensureConnectionHostResolvedFromUrl()) return;
runningPluginActionId.value = action.id;
resetTestState();
try {
const config = connectionConfigForSubmit(editingId.value || draftTestConnectionId.value, "", pluginActionNeedsValidForm(action));
const result = await api.invokePluginConnectionAction(config, action.id);
applyPluginActionFieldValues(result.fieldValues);
const message = result.message?.trim();
if (message) pluginActionStatus.value = { ok: true, message };
if (action.close_on_success === true) {
if (message) toast(message, 3000);
open.value = false;
}
} catch (error) {
const message = mongodbAuthFailureHint(errorMessage(error));
pluginActionStatus.value = { ok: false, message };
showConnectionError(message);
} finally {
runningPluginActionId.value = null;
}
}
async function testSshTunnel() {
if (isTesting.value || isTestingSshTunnel.value) return;
const runId = ++testRunId;
isTestingSshTunnel.value = true;
testResult.value = null;
testResultCopied.value = false;
try {
const config = connectionConfigForSshTunnelTest(editingId.value || draftTestConnectionId.value);
const message = await api.testSshTunnel(config);
if (runId !== testRunId) return;
testResult.value = { ok: true, message, scope: "ssh" };
} catch (error) {
if (runId !== testRunId) return;
const message = errorMessage(error);
testResult.value = { ok: false, message, scope: "ssh" };
showConnectionError(message);
} finally {
if (runId === testRunId) isTestingSshTunnel.value = false;
}
}
function clearEditedConnectionErrorAfterSuccessfulTest() {
if (editingId.value) store.clearConnectionError(editingId.value);
}
function applyConnectionUrlToForm(input: string): boolean {
try {
const update = parseConnectionDeepLinkUpdate(input);
if (update) {
if (update.connectionId !== editingId.value || props.editConfig?.one_time) throw new Error("Open the saved connection specified by this update link before applying it.");
const draft = applyConnectionDeepLinkUpdate(form.value, update);
applyingConnectionUpdate = true;
appliedConnectionUpdate = update;
form.value = draft;
finishApplyingConnectionUpdate();
resetTestState();
appliedConnectionUrlInput.value = input.trim();
return true;
}
const draft = parseConnectionDeepLink(input) ?? parseServiceConnectionUrl(input);
if (draft) {
applyConnectionDraftToForm({ ...draft, oneTime: undefined });
if (form.value.db_type === "meilisearch") {
syncMeilisearchHostInput(form.value);
} else {
resetMeilisearchHostInput();
}
resetTestState();
appliedConnectionUrlInput.value = input.trim();
return true;
}
const parsed = parseConnectionUrl(input, connectionUrlPreferredProfile());
form.value = applyParsedConnectionUrl(form.value, parsed);
if (form.value.db_type === "victoriametrics") {
hydrateVictoriaMetricsFields(form.value.external_config);
}
if (form.value.db_type === "meilisearch") {
syncMeilisearchHostInput(form.value);
} else {
resetMeilisearchHostInput();
}
oracleTnsAdminPath.value = parseOracleTnsConnectionString(parsed.connectionString)?.tnsAdmin || "";
if (parsed.driverProfile === "oceanbase-oracle") {
oceanbaseSubMode.value = "oracle";
selectedType.value = "oceanbase";
} else {
selectedType.value = parsed.driverProfile;
}
customDriverName.value = isCustomCompatibleProfile() ? parsed.driverLabel : "";
mongoUseUrl.value = !!parsed.useMongoUrl;
if (form.value.db_type === "h2") {
h2ConnectionMode.value = h2ConnectionModeForConfig(form.value);
}
if (!form.value.name.trim()) {
form.value.name = parsed.database || parsed.host || parsed.driverLabel;
}
resetTestState();
appliedConnectionUrlInput.value = input.trim();
return true;
} catch (e: any) {
toast(t("connection.parseConnectionUrlFailed", { message: e?.message || String(e) }), 5000);
return false;
}
}
function hasPendingConnectionUrlInput(): boolean {
const url = connectionUrlInput.value.trim();
return !!url && url !== appliedConnectionUrlInput.value;
}
function hasPendingMeilisearchHostInput(): boolean {
const url = meilisearchHostInput.value.trim();
return url !== appliedMeilisearchHostInput.value;
}
function applyMeilisearchHostInput(): boolean {
try {
const input = meilisearchHostInput.value.trim();
form.value = applyParsedConnectionUrl(form.value, parseConnectionUrl(input, "meilisearch"));
appliedMeilisearchHostInput.value = input;
resetTestState();
return true;
} catch (e: any) {
toast(t("connection.parseConnectionUrlFailed", { message: e?.message || String(e) }), 5000);
return false;
}
}
function ensureConnectionHostResolvedFromUrl(): boolean {
if (hasPendingConnectionUrlInput() && !applyConnectionUrlToForm(connectionUrlInput.value.trim())) return false;
if (form.value.db_type === "meilisearch" && hasPendingMeilisearchHostInput()) return applyMeilisearchHostInput();
return true;
}
function formValueForSubmit(): Omit<ConnectionConfig, "id"> {
const url = connectionUrlInput.value.trim();
if (url && url !== appliedConnectionUrlInput.value) {
const update = parseConnectionDeepLinkUpdate(url);
if (update) {
if (update.connectionId !== editingId.value || props.editConfig?.one_time) throw new Error("Open the saved connection specified by this update link before applying it.");
return applyConnectionDeepLinkUpdate(form.value, update);
}
const draft = parseConnectionDeepLink(url);
if (draft) {
return applyConnectionDraftToConfig(form.value, { ...draft, oneTime: undefined });
}
return applyParsedConnectionUrl(form.value, parseConnectionUrl(url, connectionUrlPreferredProfile()));
}
if (form.value.db_type === "meilisearch" && hasPendingMeilisearchHostInput()) {
return applyParsedConnectionUrl(form.value, parseConnectionUrl(meilisearchHostInput.value.trim(), "meilisearch"));
}
return form.value;
}
function applyDremioJdbcMetadata(config: LegacyConnectionConfig) {
config.connection_string = config.connection_string?.trim() || dremioDefaultConnectionUrl();
try {
const parsed = parseConnectionUrl(config.connection_string);
if (parsed.driverProfile !== "dremio") return;
config.host = parsed.host;
config.port = parsed.port;
config.database = config.database?.trim() || parsed.database;
config.connection_string = dremioConnectionStringForSubmit(config.connection_string, config.url_params, config.database);
config.url_params = "";
if (!config.username) config.username = parsed.username;
if (!config.password) config.password = parsed.password;
} catch {
// Keep custom JDBC input editable; the agent will surface driver-specific URL errors.
}
}
function dremioConnectionStringForSubmit(connectionString: string, urlParams: string | undefined, database: string | undefined) {
const params = dremioSubmitUrlParams(connectionString, urlParams, database);
if (!params) return connectionString;
return `${connectionString}${dremioSubmitUrlParamSeparator(connectionString)}${params}`;
}
function dremioSubmitUrlParams(connectionString: string | undefined, urlParams: string | undefined, database: string | undefined) {
const existingKeys = dremioUrlParamKeys(connectionString || "");
const extraParams = filterDremioUrlParams(urlParams || "", existingKeys);
if (database?.trim() && !existingKeys.has("schema") && !dremioUrlParamKeys(extraParams.join("&")).has("schema")) {
extraParams.push(`schema=${database.trim()}`);
}
return extraParams.join(dremioConnectionStringUsesLegacyUrlParams(connectionString || "") ? ";" : "&");
}
function dremioSubmitUrlParamSeparator(connectionString: string) {
if (dremioConnectionStringUsesLegacyUrlParams(connectionString)) {
return connectionString.endsWith(";") ? "" : ";";
}
return connectionString.includes("?") ? (connectionString.endsWith("?") || connectionString.endsWith("&") ? "" : "&") : "?";
}
function dremioConnectionStringUsesLegacyUrlParams(connectionString: string) {
if (/^jdbc:dremio:/i.test(connectionString)) return true;
if (/^jdbc:arrow-flight-sql:\/\//i.test(connectionString)) return false;
return dremioConnectionMode.value === "legacy";
}
function filterDremioUrlParams(urlParams: string, existingKeys: Set<string>) {
const result: string[] = [];
for (const part of urlParams.split(/[&;]/)) {
const param = part.trim();
if (!param) continue;
const key = param.split("=")[0]?.trim().toLowerCase();
if (!key || existingKeys.has(key)) continue;
result.push(param);
}
return result;
}
function dremioUrlParamKeys(value: string) {
const keys = new Set<string>();
const params = dremioUrlParamString(value);
for (const part of params.split(/[&;]/)) {
const key = part.split("=")[0]?.trim().toLowerCase();
if (key) keys.add(key);
}
return keys;
}
function dremioUrlParamString(value: string) {
if (/^jdbc:dremio:/i.test(value)) {
return value.split(";").slice(1).join(";");
}
const queryStart = value.indexOf("?");
if (queryStart < 0) return value;
const fragmentStart = value.indexOf("#", queryStart + 1);
return value.slice(queryStart + 1, fragmentStart < 0 ? undefined : fragmentStart);
}
function generateConnectionName(): string {
const label = selectedProfile().label;
const rand = Math.random().toString(36).slice(2, 6);
return `${label}_${rand}`;
}
function normalizeTransportLayersForSubmit(config: LegacyConnectionConfig) {
config.transport_layers = (config.transport_layers || []).map(normalizeTransportLayer);
config.transport_layers = config.transport_layers.map((layer) => {
if (layer.type !== "ssh") return layer;
const normalized = normalizeSshTunnel(layer);
const timeout = Number(normalized.connect_timeout_secs);
normalized.connect_timeout_secs = Number.isFinite(timeout) && timeout > 0 ? timeout : 5;
return { type: "ssh", ...normalized };
});
validateTransportLayers(config);
}
function connectionConfigForSshTunnelTest(id: string): ConnectionConfig {
const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig;
normalizeTransportLayersForSubmit(config);
return config;
}
function connectionConfigForSubmit(id: string, generatedName = "", validatePluginRequired = true): ConnectionConfig {
let config: LegacyConnectionConfig;
if (isPluginConnection.value) {
const entry = selectedPluginProvider.value;
if (!entry) throw new Error(pluginLoadError.value || t("connection.pluginProviderUnavailable"));
if (validatePluginRequired) {
const missingField = entry.contribution.fields.find((field) => pluginFieldVisible(field) && pluginFieldRequired(field) && !pluginFieldHasValue(field));
if (missingField) throw new Error(t("connection.pluginRequiredField", { field: missingField.label }));
}
const values = { ...pluginFormValues.value };
for (const field of entry.contribution.fields) {
if (field.binding === "name") values[field.key] = form.value.name;
}
const existing = props.editConfig?.db_type === "plugin" && props.editConfig.plugin_id === entry.plugin.manifest.id && props.editConfig.plugin_connection_provider === entry.contribution.id ? props.editConfig : undefined;
config = buildPluginConnectionConfig(entry.plugin.manifest.id, entry.contribution, values, existing) as LegacyConnectionConfig;
// buildPluginConnectionConfig already mirrored the provider's resolved
// connect_timeout_secs (declared default or advanced-form value) into the
// typed field; capture it before the generic form overwrite below.
const resolvedPluginConnectTimeout = pluginConnectionConnectTimeoutDefault(entry.contribution) === undefined ? undefined : config.connect_timeout_secs;
config.id = id;
config.name = form.value.name.trim() || config.name;
config.note = form.value.note;
config.color = form.value.color;
config.transport_layers = form.value.transport_layers || [];
config.connect_timeout_secs = form.value.connect_timeout_secs;
// buildPluginConnectionConfig rebuilds the config from scratch and drops
// the timeout inherit flags, so mirror the Advanced-tab radio state the
// same way the built-in branch keeps them via the form spread. Kept ahead
// of the resolvedPluginConnectTimeout override below, which intentionally
// forces connect inheritance off for providers declaring their own
// handshake timeout field.
config.connect_timeout_inherit = form.value.connect_timeout_inherit;
if (resolvedPluginConnectTimeout !== undefined) {
// A provider declaring its own connect_timeout_secs field makes it the
// single source of truth (declared default or advanced-form value): the
// typed timeout mirrors it, and the generic global/per-connection DBX
// timeout radios do not apply. Otherwise the host RPC deadline and the
// plugin's own handshake timeout could disagree and the host would kill
// slow connects first.
config.connect_timeout_secs = resolvedPluginConnectTimeout;
config.connect_timeout_inherit = false;
}
config.query_timeout_secs = form.value.query_timeout_secs;
config.query_timeout_inherit = form.value.query_timeout_inherit;
config.idle_timeout_secs = form.value.idle_timeout_secs;
config.keepalive_interval_secs = form.value.keepalive_interval_secs;
config.read_only = form.value.read_only;
config.save_password = form.value.save_password;
// 生产保护只拦截 SQL/数据编辑路径,插件连接走不到;表单已隐藏该区块,提交时清掉历史残留标志。
config.is_production = false;
config.production_databases = [];
} else {
config = { ...formValueForSubmit(), id } as LegacyConnectionConfig;
}
config.database_info = undefined;
config.database = normalizeStoredConnectionDatabase(config.db_type, config.database);
config.note = config.note?.trim() || undefined;
if (selectedType.value === "oceanbase" && (config.driver_profile === "oceanbase" || config.driver_profile === "oceanbase-oracle")) {
Object.assign(config, oceanbaseModeConnectionPatch(oceanbaseSubMode.value));
}
if (!config.name?.trim()) {
config.name = generatedName.trim() || generateConnectionName();
}
if (config.db_type === "xugu") {
config.database = config.database?.trim() || undefined;
if (!hasXuguConnectionDatabase(config.database, config.connection_string)) {
throw new Error(t("connection.xuguDatabaseRequired"));
}
}
if (config.db_type === "kingbase") {
config.database = config.database?.trim() || undefined;
if (!config.database) {
throw new Error(t("connection.kingbaseDatabaseRequired"));
}
}
if (config.db_type === "dynamodb") {
config.database = config.database?.trim() || "us-east-1";
config.username = config.username.trim();
config.password = config.password.trim();
config.connection_string = config.connection_string?.trim() || undefined;
if (!config.username || !config.password) {
throw new Error(t("connection.dynamodbCredentialsRequired"));
}
}
if (config.db_type === "gaussdb") {
const serialized = serializeGaussdbHosts(gaussdbHostEntries.value);
config.host = serialized.host;
config.port = serialized.port;
}
if (isCloudflareD1Connection(config)) {
normalizeCloudflareD1Connection(config);
if (!hasCloudflareD1Credentials(config)) {
throw new Error(t("connection.d1FieldsRequired"));
}
}
if (isSpannerConnection(config)) {
normalizeSpannerConnection(config);
if (!hasSpannerResourcePath(config)) {
throw new Error(t("connection.spannerFieldsRequired"));
}
}
normalizeTransportLayersForSubmit(config);
if (config.db_type === "oracle" && config.oracle_connection_type === "tns" && config.transport_layers?.some((layer) => layer.enabled !== false)) {
throw new Error(t("connection.oracleTnsTransportUnsupported"));
}
if (config.db_type === "oracle" && config.oracle_connection_type === "tns") {
const alias = config.database?.trim() || "";
const tnsAdmin = normalizeOracleTnsAdminPath(oracleTnsAdminPath.value);
if (!alias) throw new Error(t("connection.oracleTnsAliasRequired"));
if (!tnsAdmin) throw new Error(t("connection.oracleTnsAdminRequired"));
config.database = alias;
config.connection_string = buildOracleTnsConnectionString(alias, tnsAdmin);
} else if (config.db_type === "oracle" && parseOracleTnsConnectionString(config.connection_string)) {
// Only clear DBX-generated TNS URLs when switching modes; preserve custom
// service, SID, and descriptor JDBC strings exactly as before.
config.connection_string = undefined;
}
normalizeConnectionTimeouts(config, editGlobalConnectTimeoutSecs.value, editGlobalQueryTimeoutSecs.value);
if (config.db_type === "manticoresearch") {
config.url_params = "";
}
if (config.db_type === "dameng") {
const damengSsl = damengSslFormConfig(config.url_params);
config.ssl = !!config.ssl || damengSsl.enabled;
config.url_params = applyDamengSslUrlParams(config.url_params, config.ssl, damengSsl.sslFilesPath, damengSsl.sslKeystorePassword, damengSsl.sslProtocol);
}
if (config.db_type === "hive" || config.db_type === "argo" || config.db_type === "kyuubi" || config.db_type === "impala") {
if (hiveAuthMode.value === "kerberos" && !hivePrincipal.value.trim()) {
throw new Error(t("connection.hiveKerberosPrincipalRequired"));
}
const hiveKerberos = applyHiveKerberosSubmitConfig({
authMode: hiveAuthMode.value,
principal: hivePrincipal.value,
krb5ConfPath: hiveKrb5ConfPath.value,
jaasConfigPath: hiveJaasConfigPath.value,
useSubjectCredsOnlyFalse: hiveUseSubjectCredsOnlyFalse.value,
extraJavaOptions: hiveExtraJavaOptions.value,
urlParams: config.url_params,
});
config.url_params = hiveKerberos.urlParams;
config.agent_java_options = hiveKerberos.agentJavaOptions;
} else if (config.db_type === "dameng") {
try {
config.agent_java_options = parseDamengJvmSystemProperties(damengJvmOptions.value);
} catch (error) {
if (error instanceof DamengJvmSystemPropertyError) {
throw new Error(t("connection.damengJvmOptionsInvalid", { line: error.lineNumber }));
}
throw error;
}
} else if (!(config.db_type === "jdbc" && config.driver_profile === JDBCX_DRIVER_PROFILE)) {
config.agent_java_options = undefined;
}
if (config.db_type === "informix" && config.informix_server) {
// Strip INFORMIXSERVER from url_params to avoid duplicate when dedicated field is used
config.url_params = (config.url_params || "")
.replace(/(?:^|[;])\s*INFORMIXSERVER\s*=[^;]*/gi, "")
.replace(/^[;]|[;]$/g, "")
.trim();
}
normalizeConnectionScope(config);
if (form.value.db_type === "mq") {
const mqConfig = buildMqAdminConfig();
config.external_config = mqConfig;
config.driver_profile = mqConfig.systemKind;
config.driver_label = MQ_DRIVER_LABELS[mqConfig.systemKind];
if (mqConfig.systemKind === "kafka") {
const extra = mqExtraRecord(mqConfig);
applyMqKafkaConnectionTarget(config, extra);
} else if (mqConfig.systemKind === "rocketmq") {
const extra = mqExtraRecord(mqConfig);
applyMqRocketmqNamesrv(config, mqExtraString(extra, "namesrvAddr") || mqExtraString(extra, "namesrv_addr"));
} else if (mqConfig.systemKind === "rabbitmq") {
const extra = mqExtraRecord(mqConfig);
applyMqRabbitmqAddresses(config, mqExtraString(extra, "addresses"));
} else {
applyMqAdminUrl(config, mqConfig.adminUrl);
}
config.username = "";
config.password = "";
config.database = undefined;
config.connection_string = undefined;
config.url_params = "";
} else if (config.db_type === "cassandra") {
// Go 侧在配置了 truststore/keystore 时会自动启用 TLS,前端保持一致:有 store 配置就不丢弃 external_config。
const cassandraHasTlsStore = Boolean(cassandraTls.truststore_path.trim() || cassandraTls.truststore_password) || Boolean(cassandraTls.keystore_path.trim() || cassandraTls.keystore_password);
if (!config.ssl && !cassandraHasTlsStore) {
config.external_config = undefined;
} else {
if (cassandraTls.truststore_password && !cassandraTls.truststore_path.trim()) {
throw new Error(t("connection.cassandraTruststorePasswordRequiresPath"));
}
if (cassandraTls.keystore_password && !cassandraTls.keystore_path.trim()) {
throw new Error(t("connection.cassandraKeystorePasswordRequiresPath"));
}
config.external_config = buildCassandraExternalConfig(cassandraTls);
}
} 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 = undefined;
config.connection_string = undefined;
config.url_params = "";
} else if (config.db_type === "consul") {
const consulConfig = buildConsulExternalConfig();
config.external_config = consulConfig;
applyConsulServerAddr(config, String(consulConfig.serverAddr));
config.client_cert_path = config.client_cert_path?.trim() || "";
config.client_key_path = config.client_key_path?.trim() || "";
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
throw new Error(t("connection.etcdClientCertPairRequired"));
}
config.username = "";
config.password = config.password.trim();
config.database = undefined;
config.connection_string = undefined;
config.url_params = "";
} else if (config.db_type === "mqtt") {
const mqttConfig = buildMqttExternalConfig();
config.external_config = mqttConfig;
config.driver_profile = "mqtt";
config.driver_label = "MQTT";
config.host = mqttConfig.host;
config.port = mqttConfig.port;
config.ssl = mqttConfig.tls;
config.username = "";
config.password = "";
config.database = undefined;
config.connection_string = undefined;
config.url_params = "";
} else if (config.db_type === "influxdb") {
config.external_config = buildInfluxDbExternalConfig();
config.connection_string = undefined;
if (influxDbVersion.value === "2" || influxDbVersion.value === "3") {
config.username = "";
config.password = config.password.trim();
config.database = config.database?.trim() || undefined;
}
// Swap db_type to the standalone influxdb3 engine when the version
// picker is on 3; the form keeps db_type = influxdb so the same card
// renders every InfluxDB flavor.
if (influxDbVersion.value === "3") {
config.db_type = "influxdb3";
}
} else if (config.db_type === "victoriametrics") {
config.external_config = buildVictoriaMetricsExternalConfig();
config.connection_string = undefined;
config.database = "metrics";
config.username = config.username.trim();
} else if (config.db_type === "salesforce") {
// Token-mode connections never carry auth context (the access token itself is
// the credential in `password`). OAuth/device modes merge the auth context
// into `external_config.auth`; the backend handles refreshToken encryption.
config.connection_string = undefined;
config.database = undefined;
config.username = config.username.trim();
const existing = (config.external_config && typeof config.external_config === "object" ? (config.external_config as Record<string, unknown>) : {}) as SalesforceExternalConfig & Record<string, unknown>;
if (salesforceAuthMode.value === "token") {
const { auth: _dropped, ...rest } = existing;
config.external_config = Object.keys(rest).length > 0 ? rest : undefined;
} else {
// Preserve an existing refreshToken/clientSecret when the user saves the
// form without re-authorizing or re-typing them (they're not round-tripped
// from the backend). The hydrate step flagged this via
// `salesforceOauthPreviouslyAuthorized`.
const auth: SalesforceAuthContext = {
mode: salesforceAuthMode.value,
environment: salesforceEnvironment.value,
clientId: salesforceClientId.value.trim() || existing.auth?.clientId,
authorizedAt: existing.auth?.authorizedAt,
};
if (salesforceEnvironment.value === "custom") {
auth.loginUrl = salesforceLoginUrl.value.trim() || existing.auth?.loginUrl;
}
if (salesforceClientSecret.value.trim()) {
auth.clientSecret = salesforceClientSecret.value.trim();
} else if (existing.auth?.clientSecret) {
// Preserve secret indicator without leaking the value to the client.
auth.clientSecret = existing.auth.clientSecret;
}
if (salesforceAuthMode.value === "password") {
auth.username = salesforceUsername.value.trim() || existing.auth?.username;
if (salesforceUserPassword.value) {
auth.password = salesforceUserPassword.value;
} else if (existing.auth?.password) {
// Stored ROPC credential (scrubbed into the backend secret store);
// keep it when the user edits the connection without retyping.
auth.password = existing.auth.password;
}
} else if (existing.auth?.refreshToken) {
auth.refreshToken = existing.auth.refreshToken;
}
config.external_config = { ...existing, auth };
}
} else if (config.db_type === "elasticsearch") {
config.external_config = buildElasticsearchExternalConfig(elasticsearchConnectionMode.value, elasticsearchKibanaBasePath.value, elasticsearchConnectivityCheckPath.value, elasticsearchIndexGroupingPattern.value, elasticsearchConnectivityCheckDisabled.value);
} else if (config.db_type === "meilisearch") {
config.username = "";
config.password = config.password.trim();
config.database = undefined;
} else if (config.db_type === "sqlserver") {
if (!connectionUpdateExternalConfigPreserved(config)) config.external_config = sqlServerPortExplicitFromConfig(config) ? { portExplicit: true } : undefined;
} else if (supportsGaussdbIdentifierQuoteStyle(config)) {
const style = gaussdbIdentifierQuoteStyle(config);
const targetServerType = gaussdbTargetServerType(config);
const countQueryDop = gaussdbCountQueryDop(config);
config.external_config = undefined;
setGaussdbIdentifierQuoteStyle(config, style);
setGaussdbTargetServerType(config, targetServerType);
setGaussdbCountQueryDop(config, countQueryDop);
} else if (config.db_type !== "plugin" && !isDoltDriverProfile(config.driver_profile)) {
// Plugin connections keep `external_config`: the manifest-driven form
// fields land there via buildPluginConnectionConfig. Only the built-in
// drivers without an external-config payload get wiped here.
if (!connectionUpdateExternalConfigPreserved(config)) config.external_config = undefined;
}
if (config.db_type === "mongodb" && !mongoUseUrl.value) {
config.connection_string = undefined;
} else if (config.db_type === "mongodb") {
config.connection_string = normalizeMongoConnectionString(config.connection_string?.trim() || "");
}
if (config.db_type === "mongodb") {
const usesOidc = mongoConnectionUsesOidc(mongoUseUrl.value ? undefined : config.url_params, mongoUseUrl.value ? config.connection_string : undefined);
if (usesOidc) {
config.password = "";
config.driver_profile = "mongodb";
config.driver_label = "MongoDB";
} else if (isMongoLegacyDriverProfile(config.driver_profile)) {
config.driver_profile = "mongodb-legacy";
config.driver_label = "MongoDB (Legacy)";
} else {
config.driver_profile = "mongodb";
config.driver_label = "MongoDB";
}
}
if (config.db_type === "mongodb") {
const mongoTls = normalizeMongoTlsFormState(!!config.ssl, config.url_params, config.ca_cert_path);
config.url_params = mongoTls.urlParams;
config.ca_cert_path = mongoTls.caCertPath;
}
if (config.db_type !== "oracle") {
config.sysdba = undefined;
config.oracle_connection_type = undefined;
} else {
config.sysdba = !!config.sysdba || isOracleSysUser(config);
config.oracle_connection_type = config.oracle_connection_type || "service_name";
}
if (config.db_type !== "redis") {
config.redis_connection_mode = undefined;
config.redis_sentinel_master = undefined;
config.redis_sentinel_nodes = undefined;
config.redis_sentinel_username = undefined;
config.redis_sentinel_password = undefined;
config.redis_sentinel_tls = undefined;
config.redis_cluster_nodes = undefined;
config.redis_key_separator = undefined;
config.redis_scan_page_size = undefined;
config.redis_database_aliases = undefined;
config.redis_key_templates = undefined;
config.redis_key_grouping = undefined;
} else if (config.redis_connection_mode === "sentinel") {
config.redis_sentinel_master = config.redis_sentinel_master?.trim() || "";
config.redis_sentinel_nodes = normalizeRedisSentinelNodes(config.redis_sentinel_nodes || "");
config.redis_sentinel_username = config.redis_sentinel_username?.trim() || "";
config.redis_cluster_nodes = undefined;
const firstNode = firstRedisSentinelEndpoint(config.redis_sentinel_nodes);
if (firstNode) {
config.host = firstNode.host;
config.port = firstNode.port;
}
} else if (config.redis_connection_mode === "cluster") {
config.redis_sentinel_master = undefined;
config.redis_sentinel_nodes = undefined;
config.redis_sentinel_username = undefined;
config.redis_sentinel_password = undefined;
config.redis_sentinel_tls = undefined;
config.redis_cluster_nodes = normalizeRedisClusterNodes(config.redis_cluster_nodes || "");
const firstNode = firstRedisClusterEndpoint(config.redis_cluster_nodes);
if (firstNode) {
config.host = firstNode.host;
config.port = firstNode.port;
}
} else {
config.redis_connection_mode = "standalone";
config.redis_sentinel_master = undefined;
config.redis_sentinel_nodes = undefined;
config.redis_sentinel_username = undefined;
config.redis_sentinel_password = undefined;
config.redis_sentinel_tls = undefined;
config.redis_cluster_nodes = undefined;
}
if (config.db_type === "redis") {
config.redis_key_separator = config.redis_key_separator?.trim() ?? ":";
const scanSize = Number(config.redis_scan_page_size);
config.redis_scan_page_size = Number.isFinite(scanSize) && scanSize >= REDIS_SCAN_PAGE_SIZE_MIN && scanSize <= REDIS_SCAN_PAGE_SIZE_MAX ? Math.round(scanSize) : REDIS_SCAN_PAGE_SIZE_DEFAULT;
{
// A Redis database is a numeric index; dirty values (e.g. redis-cli flags
// pasted into the field) are stored as the index the backend connects with.
const database = normalizeRedisDatabaseValue(config.database);
config.database = database;
form.value.database = database || "";
}
{
const templates = normalizeRedisKeyTemplates(redisKeyTemplatesText.value);
config.redis_key_templates = templates.length > 0 ? templates : undefined;
form.value.redis_key_templates = templates;
}
}
if (config.db_type === "zookeeper") {
const normalizedConnectString = normalizeZooKeeperConnectString(config.connection_string || "");
config.connection_string = normalizedConnectString || undefined;
const firstEndpoint = firstZooKeeperEndpoint(normalizedConnectString || (config.host ? `${config.host}:${config.port || 2181}` : ""));
if (firstEndpoint) {
config.host = firstEndpoint.host;
config.port = firstEndpoint.port;
}
config.database = undefined;
}
if (config.db_type === "etcd") {
config.etcd_endpoints = normalizeEndpointLines(config.etcd_endpoints || "");
const firstEndpoint = firstEtcdEndpoint(config.etcd_endpoints);
if (firstEndpoint) {
config.host = firstEndpoint.host;
config.port = firstEndpoint.port;
config.ssl = firstEndpoint.scheme === "https" || !!config.ssl;
}
config.client_cert_path = config.client_cert_path?.trim() || "";
config.client_key_path = config.client_key_path?.trim() || "";
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
throw new Error(t("connection.etcdClientCertPairRequired"));
}
} else if (config.db_type === "zookeeper") {
config.etcd_endpoints = undefined;
config.client_cert_path = config.client_cert_path?.trim() || "";
config.client_key_path = config.client_key_path?.trim() || "";
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
throw new Error(t("connection.etcdClientCertPairRequired"));
}
} else if (form.value.db_type !== "consul" && config.db_type !== "elasticsearch" && config.db_type !== "easysearch" && config.db_type !== "solr") {
config.etcd_endpoints = undefined;
config.client_cert_path = undefined;
config.client_key_path = undefined;
}
if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "solr") {
config.client_cert_path = config.client_cert_path?.trim() || "";
config.client_key_path = config.client_key_path?.trim() || "";
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
throw new Error(t("connection.etcdClientCertPairRequired"));
}
}
if (
config.db_type !== "mysql" &&
config.db_type !== "clickhouse" &&
config.db_type !== "etcd" &&
config.db_type !== "consul" &&
config.db_type !== "starrocks" &&
config.db_type !== "doris" &&
config.db_type !== "mongodb" &&
config.db_type !== "victoriametrics" &&
config.db_type !== "zookeeper" &&
config.db_type !== "elasticsearch" &&
config.db_type !== "easysearch" &&
config.db_type !== "solr"
) {
config.ca_cert_path = undefined;
} else {
config.ca_cert_path = config.ca_cert_path?.trim() || "";
}
if (config.db_type === "dameng") {
if (damengDriverModeForConfig(config) === "custom") {
config.driver_profile = DAMENG_CUSTOM_DRIVER_PROFILE;
config.connection_string = damengCustomJdbcUrl(config);
config.jdbc_driver_class = config.jdbc_driver_class?.trim() || DAMENG_DEFAULT_JDBC_DRIVER_CLASS;
config.jdbc_driver_paths = parsedJdbcDriverPaths();
if (config.jdbc_driver_paths.length === 0) {
throw new Error(t("connection.damengCustomDriverRequired"));
}
} else {
config.driver_profile = DAMENG_BUILTIN_DRIVER_PROFILE;
config.connection_string = undefined;
config.jdbc_driver_class = undefined;
config.jdbc_driver_paths = [];
}
}
if (jdbcBackedDatabaseTypes.has(config.db_type) || gaussdbConnectionMode(config) === "m-jdbc") {
if (config.db_type === "jdbc") {
if (config.driver_profile === "dremio") {
applyDremioJdbcMetadata(config);
} else if (jdbcProductProfileForConfig(config)) {
const jdbcProductProfile = jdbcProductProfileForConfig(config)!;
const defaults = jdbcProductConnectionDefaults(jdbcProductProfile, jdbcProductProfile.detectMode(config));
config.host = "";
config.port = 0;
config.connection_string = config.connection_string?.trim() || defaults.connectionString;
config.jdbc_driver_class = config.jdbc_driver_class?.trim() || defaults.driverClass;
} else if (config.driver_profile === JDBCX_DRIVER_PROFILE) {
config.host = "";
config.port = 0;
config.connection_string = config.connection_string?.trim() || JDBCX_DEFAULT_URL;
config.jdbc_driver_class = config.jdbc_driver_class?.trim() || JDBCX_JDBC_DRIVER_CLASS;
} else {
config.host = "";
config.port = 0;
config.connection_string = config.connection_string?.trim() || "";
}
} else if (config.db_type === "prestosql") {
config.connection_string = undefined;
config.jdbc_driver_class = config.jdbc_driver_class?.trim() || "io.prestosql.jdbc.PrestoDriver";
applyPrestoSqlBuiltinDriverPathsIfAvailable();
} else if (config.db_type === "gaussdb") {
config.connection_string = undefined;
config.jdbc_driver_class = GAUSSDB_M_JDBC_DRIVER_CLASS;
}
config.jdbc_driver_class = config.jdbc_driver_class?.trim() || undefined;
config.jdbc_driver_paths = parsedJdbcDriverPaths();
} else if (config.db_type === "gaussdb") {
config.connection_string = undefined;
config.jdbc_driver_class = undefined;
config.jdbc_driver_paths = [];
}
if (config.db_type === "h2") {
if (config.driver_profile === "h2-custom") {
config.jdbc_driver_class = config.jdbc_driver_class?.trim() || "org.h2.Driver";
config.jdbc_driver_paths = parsedJdbcDriverPaths();
} else {
config.jdbc_driver_class = undefined;
config.jdbc_driver_paths = [];
}
const h2Mode = connectionUrlInput.value.trim() ? h2ConnectionModeForConfig(config) : h2ConnectionMode.value;
if (h2Mode === "file") {
const jdbcFilePath = h2FilePathFromJdbcUrl(config.connection_string);
const filePath = config.host?.trim() || jdbcFilePath || "";
if (!filePath) {
throw new Error(t("connection.h2FilePathRequired"));
}
config.host = filePath;
config.port = 0;
config.connection_string = isH2SplitJdbcUrl(config.connection_string) ? h2FileJdbcUrlWithPath(config.connection_string, filePath) : h2FileJdbcUrlWithPath(undefined, filePath);
config.transport_layers = [];
} else {
config.host = config.host?.trim() || "127.0.0.1";
config.port = Number(config.port) || 9092;
if (h2FilePathFromJdbcUrl(config.connection_string)) {
config.connection_string = undefined;
} else {
config.connection_string = config.connection_string?.trim() || undefined;
}
}
}
const legacy = config as LegacyConnectionConfig;
delete legacy.ssh_enabled;
delete legacy.ssh_host;
delete legacy.ssh_port;
delete legacy.ssh_user;
delete legacy.ssh_password;
delete legacy.ssh_key_path;
delete legacy.ssh_key_passphrase;
delete legacy.ssh_expose_lan;
delete legacy.ssh_connect_timeout_secs;
delete legacy.ssh_tunnels;
delete legacy.proxy_enabled;
delete legacy.proxy_type;
delete legacy.proxy_host;
delete legacy.proxy_port;
delete legacy.proxy_username;
delete legacy.proxy_password;
if (connectionUsesVisibleSchemaFilter(config)) {
config.visible_databases = undefined;
} else {
config.visible_databases = Array.isArray(config.visible_databases) && config.visible_databases.length > 0 ? config.visible_databases : undefined;
}
if (!config.show_system_schemas) config.show_system_schemas = undefined;
if (!config.sidebar_auto_load_all_tables) config.sidebar_auto_load_all_tables = undefined;
if (config.visible_schemas && Object.keys(config.visible_schemas).length === 0) config.visible_schemas = undefined;
if (config.agent_java_options && config.agent_java_options.length === 0) config.agent_java_options = undefined;
// Pasted credentials may carry invisible characters that trim() keeps (#9043).
sanitizeConnectionCredentials(config);
return config as ConnectionConfig;
}
function withSavedDatabaseInfo(config: ConnectionConfig, databaseInfo: DatabaseConnectionInfo | null): ConnectionConfig {
return {
...config,
database_info: databaseInfo ? { ...databaseInfo } : undefined,
};
}
function connectionConfigSnapshotForVisibleDatabases(): ConnectionConfig {
return {
...(form.value as ConnectionConfig),
id: editingId.value || "draft",
visible_databases: form.value.visible_databases,
};
}
function getUrlParam(params: string | undefined, key: string): string {
const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, ""));
return parsed.get(key) || "";
}
function sqliteExtensionPathsFromParams(params: string | undefined): string {
const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, ""));
return [...parsed.getAll("sqlite_extension"), ...parsed.getAll("sqlite_extensions").flatMap((value) => value.split(/\r?\n/))]
.map((value) => value.trim())
.filter(Boolean)
.join("\n");
}
function setSqliteExtensionPaths(params: string | undefined, paths: string): string {
const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, ""));
parsed.delete("sqlite_extension");
parsed.delete("sqlite_extensions");
paths
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean)
.forEach((value) => parsed.append("sqlite_extension", value));
return parsed.toString();
}
function setUrlParam(params: string | undefined, key: string, value: string): string {
const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, ""));
const normalized = value.trim();
if (normalized) {
parsed.set(key, normalized);
} else {
parsed.delete(key);
}
return parsed.toString();
}
function deleteUrlParams(params: string | undefined, keys: string[]): string {
const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, ""));
for (const key of keys) {
parsed.delete(key);
}
return parsed.toString();
}
function mysqlTlsModeFromParams(params: string | undefined, ssl: boolean | undefined): string {
const sslMode = getUrlParam(params, "ssl-mode") || getUrlParam(params, "sslmode");
switch (sslMode.trim().toLowerCase().replace("-", "_")) {
case "disabled":
case "disable":
return "disabled";
case "preferred":
case "prefer":
return "preferred";
case "required":
case "require":
return "required";
case "verify_ca":
return "verify_ca";
case "verify_identity":
return "verify_identity";
}
const jdbcUseSsl = getUrlParam(params, "useSSL").trim().toLowerCase();
const jdbcRequireSsl = getUrlParam(params, "requireSSL").trim().toLowerCase();
const jdbcVerifyServerCertificate = getUrlParam(params, "verifyServerCertificate").trim().toLowerCase();
const isTrue = (value: string) => ["true", "1", "yes", "on"].includes(value);
if (isTrue(jdbcVerifyServerCertificate) && (isTrue(jdbcUseSsl) || isTrue(jdbcRequireSsl))) return "verify_ca";
if (isTrue(jdbcRequireSsl)) return "required";
if (["false", "0", "no", "off"].includes(jdbcUseSsl)) return "disabled";
if (isTrue(jdbcUseSsl)) return "preferred";
if (!ssl && getUrlParam(params, "require_ssl").toLowerCase() !== "true") return "disabled";
if (getUrlParam(params, "verify_identity").toLowerCase() === "true") return "verify_identity";
if (getUrlParam(params, "verify_ca").toLowerCase() === "true") return "verify_ca";
return "required";
}
function applyMysqlTlsMode(params: string | undefined, mode: string): string {
let next = deleteUrlParams(params, ["ssl-mode", "sslmode", "sslMode", "require_ssl", "verify_ca", "verify_identity", "useSSL", "requireSSL", "verifyServerCertificate"]);
if (mode === "disabled") {
return setUrlParam(next, "ssl-mode", "disabled");
}
if (mode === "preferred") {
return setUrlParam(next, "ssl-mode", "preferred");
}
next = setUrlParam(next, "require_ssl", "true");
if (mode === "required") {
next = setUrlParam(next, "verify_ca", "false");
return setUrlParam(next, "verify_identity", "false");
}
if (mode === "verify_ca") {
next = setUrlParam(next, "verify_ca", "true");
return setUrlParam(next, "verify_identity", "false");
}
next = setUrlParam(next, "verify_ca", "true");
return setUrlParam(next, "verify_identity", "true");
}
function normalizeRedisSentinelNodes(value: string): string {
return normalizeRedisNodeList(value);
}
function normalizeRedisClusterNodes(value: string): string {
return normalizeRedisNodeList(value);
}
function normalizeRedisNodeList(value: string): string {
return normalizeEndpointLines(value);
}
function normalizeEndpointLines(value: string): string {
return value
.split(/[\n,;]+/)
.map((node) => node.trim())
.filter(Boolean)
.join("\n");
}
function firstRedisSentinelEndpoint(value?: string): { host: string; port: number } | null {
const first = normalizeRedisNodeList(value || "")
.split("\n")
.find(Boolean);
if (!first) return null;
return parseRedisEndpoint(first, 26379);
}
function firstRedisClusterEndpoint(value?: string): { host: string; port: number } | null {
const first = normalizeRedisNodeList(value || "")
.split("\n")
.find(Boolean);
if (!first) return null;
return parseRedisEndpoint(first, 6379);
}
function parseRedisEndpoint(value: string, defaultPort: number): { host: string; port: number } {
const endpoint = value
.trim()
.replace(/^rediss?:\/\//, "")
.replace(/^.*@/, "")
.replace(/[/?#].*$/, "");
if (endpoint.startsWith("[")) {
const end = endpoint.indexOf("]");
if (end > 0) {
const host = endpoint.slice(1, end);
const portText = endpoint.slice(end + 1).replace(/^:/, "");
const port = Number(portText);
return { host, port: Number.isFinite(port) && port > 0 ? port : defaultPort };
}
}
const parts = endpoint.split(":");
if (parts.length === 2) {
const port = Number(parts[1]);
return { host: parts[0], port: Number.isFinite(port) && port > 0 ? port : defaultPort };
}
return { host: endpoint, port: defaultPort };
}
function firstEtcdEndpoint(value?: string): { scheme?: string; host: string; port: number } | null {
const first = normalizeEndpointLines(value || "")
.split("\n")
.find(Boolean);
if (!first) return null;
return parseEtcdEndpoint(first);
}
function parseEtcdEndpoint(value: string): { scheme?: string; host: string; port: number } {
const trimmed = value.trim().replace(/^.*@/, "");
const schemeMatch = trimmed.match(/^(https?):\/\//i);
const scheme = schemeMatch?.[1].toLowerCase();
const endpoint = trimmed.replace(/^https?:\/\//i, "").replace(/[/?#].*$/, "");
if (endpoint.startsWith("[")) {
const end = endpoint.indexOf("]");
if (end > 0) {
const host = endpoint.slice(1, end);
const portText = endpoint.slice(end + 1).replace(/^:/, "");
const port = Number(portText);
return { scheme, host, port: Number.isFinite(port) && port > 0 ? port : 2379 };
}
}
const parts = endpoint.split(":");
if (parts.length === 2) {
const port = Number(parts[1]);
return { scheme, host: parts[0], port: Number.isFinite(port) && port > 0 ? port : 2379 };
}
return { scheme, host: endpoint, port: 2379 };
}
function isOracleSysUser(config: Pick<ConnectionConfig, "db_type" | "username">): boolean {
return config.db_type === "oracle" && config.username.trim().toLowerCase() === "sys";
}
function resetTestState() {
testRunId += 1;
isTesting.value = false;
isTestingSshTunnel.value = false;
testResult.value = null;
pluginActionStatus.value = null;
clearTestedConnectionInfo();
showConnectionErrorDialog.value = false;
connectionErrorRawDetail.value = "";
connectionErrorDetail.value = "";
testResultCopied.value = false;
connectionErrorCopied.value = false;
}
function resetVisibleDatabaseDraftState() {
showVisibleDatabasesDialog.value = false;
isLoadingVisibleDatabases.value = false;
visibleDatabaseNames.value = [];
visibleDatabaseSelection.value = new Set();
visibleDatabaseSearchText.value = "";
visibleDatabaseError.value = "";
visibleDatabaseShowSystem.value = false;
}
function resetVisibleNacosNamespaceDraftState() {
showVisibleNacosNamespacesDialog.value = false;
isLoadingVisibleNacosNamespaces.value = false;
visibleNacosNamespaces.value = [];
visibleNacosNamespaceSelection.value = new Set();
visibleNacosNamespaceSearchText.value = "";
visibleNacosNamespaceError.value = "";
visibleNacosNamespaceListingPermissionDenied.value = false;
isResolvingManualNacosNamespaces.value = false;
visibleNacosNamespaceAccessMode.value = "automatic";
visibleNacosNamespaceDynamicAllSupported.value = false;
}
function resetProductionDatabaseDraftState() {
showProductionDatabasesDialog.value = false;
isLoadingProductionDatabases.value = false;
productionDatabaseNames.value = [];
productionDatabaseSelection.value = new Set();
productionDatabaseSearchText.value = "";
productionDatabaseError.value = "";
productionProtectionEnabled.value = false;
}
/** Silently load database names so the summary count shows a real total. */
async function preloadVisibleDatabaseNames() {
if (!ensureConnectionHostResolvedFromUrl()) return;
if (visibleDatabaseNames.value.length > 0) return;
isLoadingVisibleDatabases.value = true;
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
try {
const draftConfig = {
...connectionConfigForSubmit(draftId),
id: draftId,
one_time: true,
};
await api.connectDb(draftConfig);
visibleDatabaseNames.value = await loadVisibleDatabaseNames(draftId, draftConfig);
} catch {
// silently fail
} finally {
await api.disconnectDb(draftId).catch(() => undefined);
isLoadingVisibleDatabases.value = false;
}
}
async function openVisibleDatabasesPicker() {
if (!ensureConnectionHostResolvedFromUrl()) return;
if (!canChooseVisibleDatabases.value || isLoadingVisibleDatabases.value) return;
isLoadingVisibleDatabases.value = true;
visibleDatabaseError.value = "";
visibleDatabaseSearchText.value = "";
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
try {
const draftConfig = {
...connectionConfigForSubmit(draftId),
id: draftId,
one_time: true,
};
await api.connectDb(draftConfig);
const names = await loadVisibleDatabaseNames(draftId, draftConfig);
visibleDatabaseNames.value = names;
visibleDatabaseShowSystem.value = false;
const configuredSchemas = visibleSchemaObjectSelection.value;
const initialSelection = visibleFilterUsesSchemas.value ? (Array.isArray(configuredSchemas) ? normalizeVisibleSchemaSelection(configuredSchemas, names) : filterSchemaNamesForVisiblePicker(names, draftConfig)) : initialVisibleDatabaseSelection(names, form.value.visible_databases, draftConfig);
visibleDatabaseSelection.value = new Set(initialSelection);
const defaultVisible = new Set(defaultListedVisibleDatabaseNames.value);
visibleDatabaseShowSystem.value = initialSelection.some((name) => !defaultVisible.has(name));
showVisibleDatabasesDialog.value = true;
} catch (e: any) {
visibleDatabaseNames.value = [];
visibleDatabaseSelection.value = new Set();
visibleDatabaseError.value = mongodbAuthFailureHint(errorMessage(e));
testResult.value = { ok: false, message: visibleDatabaseError.value };
showVisibleDatabasesDialog.value = true;
} finally {
await api.disconnectDb(draftId).catch(() => undefined);
isLoadingVisibleDatabases.value = false;
}
}
function nacosNamespaceValue(namespace: NacosNamespaceInfo): string {
return namespace.namespace || "";
}
function nacosNamespaceLabel(namespace: NacosNamespaceInfo): string {
return namespace.namespaceShowName || namespace.namespace || "public";
}
function normalizeVisibleNacosNamespaceSelection(selected: Iterable<string>, namespaces: NacosNamespaceInfo[]): string[] {
return normalizeNacosNamespaceSelection(selected, namespaces);
}
function normalizeManualNacosNamespaceNames(namespaces: string[], availableNamespaces: NacosNamespaceInfo[]): string[] {
const namespaceIds = new Map(availableNamespaces.map((namespace) => [nacosNamespaceIdentity(namespace.namespace), namespace.namespace]));
const namespaceNames = new Map<string, string[]>();
for (const namespace of availableNamespaces) {
const name = nacosNamespaceLabel(namespace).trim();
if (!name) continue;
namespaceNames.set(name, [...(namespaceNames.get(name) || []), namespace.namespace]);
}
const normalized: string[] = [];
const seen = new Set<string>();
for (const namespace of namespaces) {
const namespaceId = namespaceIds.get(nacosNamespaceIdentity(namespace));
const matchingNames = namespaceNames.get(namespace.trim()) || [];
// Display names are only safe to convert when the server returned one
// exact match. Keep unknown or ambiguous values intact so the regular
// permission check can report them instead of targeting a wrong namespace.
const value = namespaceId ?? (matchingNames.length === 1 ? matchingNames[0] : namespace);
const identity = nacosNamespaceIdentity(value);
if (!seen.has(identity)) {
seen.add(identity);
normalized.push(value);
}
}
return normalized;
}
async function resolveManualNacosNamespaceNames(namespaces: string[]): Promise<string[]> {
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
try {
const submittedConfig = connectionConfigForSubmit(draftId);
const draftConfig = {
...submittedConfig,
// Resolve display names against the authenticated directory rather than
// passing them as namespace IDs to the configuration and naming APIs.
external_config: submittedConfig.db_type === "nacos" ? { ...(submittedConfig.external_config as NacosAdminConfig), managedNamespaces: undefined } : submittedConfig.external_config,
id: draftId,
one_time: true,
};
await api.connectDb(draftConfig);
return normalizeManualNacosNamespaceNames(namespaces, await loadReadableNacosNamespaces(draftId, api));
} catch {
// Some Nacos deployments do not expose a readable namespace directory to
// ordinary users. Preserve manually entered IDs for the normal test path.
return namespaces;
} finally {
await api.disconnectDb(draftId).catch(() => undefined);
}
}
function isNacosNamespaceListingPermissionError(error: unknown): boolean {
const message = errorMessage(error);
if (/NACOS_ERROR\[rnacosNamespaceDirectoryUnavailable\]/.test(message)) return true;
if (/NACOS_ERROR\[(?:v3ManagedNamespacesRequired|managedNamespacesRequired)\]/.test(message)) return true;
return /\/v3\/(?:admin|console)\/core\/namespace\/list/.test(message) && /\b403\b/.test(message) && /authorization failed/i.test(message);
}
async function openVisibleNacosNamespacesPicker() {
if (!ensureConnectionHostResolvedFromUrl()) return;
if (!canChooseVisibleNacosNamespaces.value || isLoadingVisibleNacosNamespaces.value) return;
isLoadingVisibleNacosNamespaces.value = true;
visibleNacosNamespaceError.value = "";
visibleNacosNamespaceListingPermissionDenied.value = false;
visibleNacosNamespaceDynamicAllSupported.value = false;
visibleNacosNamespaceSearchText.value = "";
visibleNacosNamespaceAccessMode.value = "automatic";
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
try {
const submittedConfig = connectionConfigForSubmit(draftId);
const draftConfig = {
...submittedConfig,
// Automatic detection must not inherit a previously saved manual scope.
// Otherwise it would only rediscover that old subset instead of checking
// what the currently configured account can really access.
external_config: canDetectNacosNamespaceAccess.value && submittedConfig.db_type === "nacos" ? { ...(submittedConfig.external_config as NacosAdminConfig), managedNamespaces: undefined } : submittedConfig.external_config,
visible_databases: undefined,
id: draftId,
one_time: true,
};
await api.connectDb(draftConfig);
const namespaces = await loadReadableNacosNamespaces(draftId, api);
visibleNacosNamespaces.value = [...namespaces].sort((left, right) => nacosNamespaceLabel(left).localeCompare(nacosNamespaceLabel(right)));
try {
const sidebarSnapshot = await api.nacosSidebarSnapshot(draftId);
const readableIds = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaces.value.map(nacosNamespaceValue), visibleNacosNamespaces.value);
const sidebarIds = normalizeVisibleNacosNamespaceSelection(sidebarSnapshot.namespaces.map(nacosNamespaceValue), visibleNacosNamespaces.value);
const readableSet = new Set(readableIds.map(nacosNamespaceIdentity));
const sidebarSet = new Set(sidebarIds.map(nacosNamespaceIdentity));
visibleNacosNamespaceDynamicAllSupported.value = readableSet.size === sidebarSet.size && [...readableSet].every((namespace) => sidebarSet.has(namespace));
} catch {
visibleNacosNamespaceDynamicAllSupported.value = false;
}
const configured = form.value.visible_databases;
const initialSelection = Array.isArray(configured) ? normalizeVisibleNacosNamespaceSelection(configured, visibleNacosNamespaces.value) : visibleNacosNamespaces.value.map(nacosNamespaceValue);
visibleNacosNamespaceSelection.value = new Set(initialSelection);
showVisibleNacosNamespacesDialog.value = true;
} catch (e: any) {
visibleNacosNamespaces.value = [];
visibleNacosNamespaceSelection.value = new Set();
visibleNacosNamespaceListingPermissionDenied.value = isNacosNamespaceListingPermissionError(e);
visibleNacosNamespaceError.value = visibleNacosNamespaceListingPermissionDenied.value ? t("nacos.nacosManagedNamespacesRequired") : errorMessage(e);
testResult.value = { ok: false, message: visibleNacosNamespaceError.value };
showVisibleNacosNamespacesDialog.value = true;
} finally {
await api.disconnectDb(draftId).catch(() => undefined);
isLoadingVisibleNacosNamespaces.value = false;
}
}
function toggleVisibleNacosNamespace(namespace: string) {
const next = new Set(visibleNacosNamespaceSelection.value);
if (next.has(namespace)) next.delete(namespace);
else next.add(namespace);
visibleNacosNamespaceSelection.value = next;
}
function selectAllVisibleNacosNamespaces() {
visibleNacosNamespaceSelection.value = new Set(visibleNacosNamespaces.value.map(nacosNamespaceValue));
}
function clearVisibleNacosNamespaceSelection() {
visibleNacosNamespaceSelection.value = new Set();
}
function showAllVisibleNacosNamespaces() {
nacosDynamicAllNamespaces.value = true;
nacosManagedNamespacesText.value = "";
form.value.visible_databases = undefined;
resetVisibleNacosNamespaceDraftState();
}
async function saveVisibleNacosNamespaceSelection() {
if (!visibleNacosNamespaceCanSave.value) return;
if (visibleNacosNamespaceAccessMode.value === "manual") {
const manualNamespaces = parseNacosManagedNamespaces(nacosManagedNamespacesText.value);
isResolvingManualNacosNamespaces.value = true;
// r-nacos does not promise a namespace directory on its client OpenAPI.
// Preserve user-provided IDs directly so scoped config access does not
// require a separate console login.
const resolvedNamespaces = isNacosV3AdminPlane.value || nacosImplementation.value === "rnacos" ? manualNamespaces : await resolveManualNacosNamespaceNames(manualNamespaces);
isResolvingManualNacosNamespaces.value = false;
nacosManagedNamespacesText.value = resolvedNamespaces.join("\n");
form.value.visible_databases = resolvedNamespaces;
nacosDynamicAllNamespaces.value = false;
} else {
nacosManagedNamespacesText.value = "";
const selected = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaceSelection.value, visibleNacosNamespaces.value);
const all = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaces.value.map(nacosNamespaceValue), visibleNacosNamespaces.value);
const selectsEntireReadableList = selected.length === all.length && selected.every((namespace, index) => namespace === all[index]);
const useDynamicAll = selectsEntireReadableList && visibleNacosNamespaceDynamicAllSupported.value;
form.value.visible_databases = useDynamicAll ? undefined : selected;
nacosDynamicAllNamespaces.value = useDynamicAll;
}
showVisibleNacosNamespacesDialog.value = false;
}
async function loadVisibleDatabaseNames(connectionId: string, config: ConnectionConfig): Promise<string[]> {
if (connectionUsesVisibleSchemaFilter(config)) {
return api.listSchemas(connectionId, config.database || "");
}
if (config.db_type === "redis") {
return (await api.redisListDatabases(connectionId)).map((database) => String(database.db));
}
if (config.db_type === "mongodb") {
return api.mongoListDatabases(connectionId);
}
return (await api.listDatabases(connectionId)).map((database) => database.name);
}
function normalizeProductionDatabaseSelection(selectedNames: Iterable<string>, databaseNames: string[]): string[] {
if (form.value.db_type === "nacos") {
const available = new Map(databaseNames.map((name) => [nacosNamespaceIdentity(name), name]));
const selected = new Set<string>();
for (const name of selectedNames) {
const canonicalName = available.get(nacosNamespaceIdentity(name));
if (canonicalName !== undefined) selected.add(canonicalName);
}
return [...selected];
}
const available = new Map(databaseNames.map((name) => [name.toLowerCase(), name]));
const selected = new Set<string>();
for (const name of selectedNames) {
const canonicalName = available.get(name.toLowerCase());
if (canonicalName) selected.add(canonicalName);
}
return [...selected];
}
function initialProductionDatabaseSelection(databaseNames: string[]): string[] {
const configured = form.value.production_databases || [];
// A new database-level safeguard starts broad; users can explicitly narrow it in the picker.
return configured.length ? normalizeProductionDatabaseSelection(configured, databaseNames) : databaseNames;
}
async function loadProductionDatabaseNames(connectionId: string, config: ConnectionConfig): Promise<string[]> {
if (config.db_type === "nacos") {
return (await loadReadableNacosNamespaces(connectionId, api)).map((namespace) => namespace.namespace);
}
if (config.db_type === "redis") {
return (await api.redisListDatabases(connectionId)).map((database) => String(database.db));
}
if (config.db_type === "mongodb") {
return api.mongoListDatabases(connectionId);
}
return (await api.listDatabases(connectionId)).map((database) => database.name);
}
async function openProductionDatabasesPicker() {
if (!ensureConnectionHostResolvedFromUrl() || !productionProtectionEnabled.value || form.value.is_production || isLoadingProductionDatabases.value) return;
showProductionDatabasesDialog.value = true;
await reloadProductionDatabases();
}
async function reloadProductionDatabases() {
if (isLoadingProductionDatabases.value) return;
isLoadingProductionDatabases.value = true;
productionDatabaseError.value = "";
productionDatabaseSearchText.value = "";
const draftId = `__production_database_draft_${uuid()}`;
try {
const draftConfig = {
...connectionConfigForSubmit(draftId),
id: draftId,
one_time: true,
};
await api.connectDb(draftConfig);
productionDatabaseNames.value = await loadProductionDatabaseNames(draftId, draftConfig);
productionDatabaseSelection.value = new Set(initialProductionDatabaseSelection(productionDatabaseNames.value));
} catch (e: any) {
productionDatabaseNames.value = [];
productionDatabaseSelection.value = new Set();
productionDatabaseError.value = mongodbAuthFailureHint(errorMessage(e));
} finally {
await api.disconnectDb(draftId).catch(() => undefined);
isLoadingProductionDatabases.value = false;
}
}
function toggleProductionDatabase(database: string) {
const next = new Set(productionDatabaseSelection.value);
if (next.has(database)) next.delete(database);
else next.add(database);
productionDatabaseSelection.value = next;
}
function selectAllProductionDatabases() {
productionDatabaseSelection.value = new Set(productionDatabaseNames.value);
}
function clearProductionDatabaseSelection() {
productionDatabaseSelection.value = new Set();
}
function saveProductionDatabaseSelection() {
if (!productionDatabaseCanSave.value) return;
// A database selection is always narrower than a connection-wide marker.
productionProtectionEnabled.value = true;
form.value.is_production = false;
form.value.production_databases = normalizeProductionDatabaseSelection(productionDatabaseSelection.value, productionDatabaseNames.value);
showProductionDatabasesDialog.value = false;
}
function toggleVisibleDatabase(database: string) {
const next = new Set(visibleDatabaseSelection.value);
if (next.has(database)) next.delete(database);
else next.add(database);
visibleDatabaseSelection.value = next;
}
function selectAllVisibleDatabases() {
visibleDatabaseSelection.value = new Set(listedVisibleDatabaseNames.value);
}
function clearVisibleDatabaseSelection() {
visibleDatabaseSelection.value = new Set();
}
function showAllVisibleDatabases() {
if (visibleFilterUsesSchemas.value) {
handleDraftSchemasShowAll();
form.value.visible_databases = undefined;
} else {
form.value.visible_databases = undefined;
}
visibleDatabaseSelection.value = new Set();
visibleDatabaseNames.value = [];
showVisibleDatabasesDialog.value = false;
}
function saveVisibleDatabaseSelection() {
if (!visibleDatabaseCanSave.value) return;
if (visibleFilterUsesSchemas.value) {
const key = visibleSchemasDatabaseKey.value;
form.value.visible_databases = undefined;
form.value.visible_schemas = {
...form.value.visible_schemas,
[key]: normalizeVisibleSchemaSelection([...visibleDatabaseSelection.value], visibleDatabaseNames.value),
};
} else {
form.value.visible_databases = normalizeVisibleDatabaseSelection([...visibleDatabaseSelection.value], visibleDatabaseNames.value);
}
showVisibleDatabasesDialog.value = false;
}
function resetVisibleSchemasState() {
showVisibleSchemasDialog.value = false;
isLoadingVisibleSchemas.value = false;
visibleSchemaNames.value = [];
visibleSchemaInitialSelection.value = [];
visibleSchemaError.value = "";
}
async function openVisibleSchemasPicker() {
if (!ensureConnectionHostResolvedFromUrl()) return;
if (!canChooseVisibleSchemas.value || isLoadingVisibleSchemas.value) return;
isLoadingVisibleSchemas.value = true;
visibleSchemaError.value = "";
const draftId = buildDraftVisibleSchemasConnectionId(uuid());
try {
const draftConfig: ConnectionConfig = {
...connectionConfigForSubmit(draftId),
id: draftId,
};
await store.addEphemeralConnection(draftConfig);
await store.ensureConnected(draftId);
const names = await api.listSchemas(draftId, visibleSchemasDatabaseKey.value);
visibleSchemaNames.value = names;
const key = visibleSchemasDatabaseKey.value;
const configured = form.value.visible_schemas?.[key];
visibleSchemaInitialSelection.value = Array.isArray(configured) ? configured : [];
showVisibleSchemasDialog.value = true;
} catch (e: any) {
visibleSchemaNames.value = [];
visibleSchemaInitialSelection.value = [];
visibleSchemaError.value = String(e?.message || e);
} finally {
isLoadingVisibleSchemas.value = false;
store.removeConnection(draftId).catch(() => {});
}
}
function handleDraftSchemasSave(selectedNames: string[]) {
const key = visibleSchemasDatabaseKey.value;
form.value.visible_schemas = { ...form.value.visible_schemas, [key]: selectedNames };
}
function handleDraftSchemasShowAll() {
const key = visibleSchemasDatabaseKey.value;
if (form.value.visible_schemas) {
const next = { ...form.value.visible_schemas };
delete next[key];
form.value.visible_schemas = Object.keys(next).length > 0 ? next : undefined;
}
}
function applyConnectionUrl() {
if (applyConnectionUrlToForm(connectionUrlInput.value)) {
toast(t("connection.parseConnectionUrlApplied"), 2000);
}
}
async function copyTestResult() {
if (!testResultMessage.value) return;
try {
await copyToClipboard(testResultMessage.value);
testResultCopied.value = true;
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
async function copyDatabaseInfo() {
const info = visibleDatabaseInfo.value;
if (!info) return;
try {
await copyToClipboard(databaseInfoCopyText(info, databaseInfoFieldLabel, databaseIdentifierCaseLabel));
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
async function copyAgentInstallError() {
if (!agentInstallError.value) return;
try {
await copyToClipboard(agentInstallError.value);
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
async function copyConnectionErrorDetail() {
if (!connectionErrorDetail.value) return;
try {
await copyToClipboard(connectionErrorDetail.value);
connectionErrorCopied.value = true;
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
function openJdbcDriverManager() {
emit("openDriverStore", isJdbcProductConnection.value ? agentDriverFocus.value : { target: "tab", tab: "jdbc" });
}
function openJdbcDriverManagerFromError() {
showConnectionErrorDialog.value = false;
openJdbcDriverManager();
}
function resetForm(options: { preservePickerState?: boolean } = {}) {
editingId.value = null;
selectedConnectionGroupId.value = initialConnectionGroupId();
form.value = defaultForm();
redisKeyTemplatesText.value = "";
resetConnectionNoteVisibilityDraft(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes);
editGlobalConnectTimeoutSecs.value = settingsStore.editorSettings.globalConnectTimeoutSecs;
editGlobalQueryTimeoutSecs.value = settingsStore.editorSettings.globalQueryTimeoutSecs;
selectedTransportLayerId.value = null;
draggedTransportLayerId.value = null;
selectedType.value = "mysql";
pluginFormValues.value = {};
customDriverName.value = "";
mongoUseUrl.value = false;
resetMqFields();
oceanbaseSubMode.value = "mysql";
dremioConnectionMode.value = "legacy";
resetDremioConnectionUrls();
resetJdbcProductConnectionFields(undefined);
jdbcDriverPathsInput.value = "";
selectedJdbcDriverPath.value = "";
connectionUrlInput.value = "";
appliedConnectionUrlInput.value = "";
resetMeilisearchHostInput();
oracleTnsAdminPath.value = "";
resetSalesforceOAuthFields(undefined, undefined);
if (!options.preservePickerState) {
dialogStep.value = "select";
dbSearchQuery.value = "";
selectedDbCategory.value = "sql";
configTab.value = "connection";
}
resetVisibleDatabaseDraftState();
resetVisibleNacosNamespaceDraftState();
resetProductionDatabaseDraftState();
resetVisibleSchemasState();
resetTestState();
}
const submittedOneTimePrefillKey = ref<string | null>(null);
function oneTimePrefillKey(draft: ConnectionDeepLinkDraft) {
return JSON.stringify([
draft.name,
draft.dbType,
draft.driverProfile,
draft.driverLabel,
draft.host,
draft.port,
draft.portExplicit,
draft.username,
draft.password,
draft.database,
draft.urlParams,
draft.ssl,
draft.connectionString,
draft.oracleConnectionType,
draft.useMongoUrl,
draft.serviceConfig,
]);
}
function submitOneTimePrefill(draft: ConnectionDeepLinkDraft) {
if (!draft.oneTime) return;
const key = oneTimePrefillKey(draft);
if (submittedOneTimePrefillKey.value === key) return;
submittedOneTimePrefillKey.value = key;
void nextTick(() => save());
}
function applyConnectionDraftToConfig(config: Omit<ConnectionConfig, "id">, draft: ConnectionDeepLinkDraft): Omit<ConnectionConfig, "id"> {
const next = {
...config,
db_type: draft.dbType,
driver_profile: draft.driverProfile,
driver_label: draft.driverLabel,
host: draft.host ?? config.host,
port: draft.port ?? config.port,
username: draft.username ?? config.username,
password: draft.password ?? config.password,
database: draft.database ?? config.database,
url_params: draft.urlParams ?? config.url_params,
ssl: draft.ssl ?? config.ssl,
external_config: draft.dbType === "meilisearch" && draft.basePath !== undefined ? applyMeilisearchBasePathToExternalConfig(config.external_config, draft.basePath) : config.external_config,
connection_string: draft.connectionString ?? config.connection_string,
oracle_connection_type: draft.oracleConnectionType ?? config.oracle_connection_type,
one_time: draft.oneTime || undefined,
};
setSqlServerPortExplicit(next, draft.portExplicit === true);
return next;
}
function applyConnectionDraftToForm(draft: ConnectionDeepLinkDraft) {
applyProfile(draft.driverProfile);
form.value = applyConnectionDraftToConfig(form.value, draft);
if (draft.serviceConfig?.kind === "consul") {
hydrateConsulFields(connectionDeepLinkServiceHydrationValue(draft.serviceConfig));
} else if (draft.serviceConfig?.kind === "nacos") {
hydrateNacosFields(connectionDeepLinkServiceHydrationValue(draft.serviceConfig));
}
oracleTnsAdminPath.value = parseOracleTnsConnectionString(form.value.connection_string)?.tnsAdmin || "";
selectedType.value = draft.driverProfile;
if (form.value.db_type === "h2") {
h2ConnectionMode.value = h2ConnectionModeForConfig(form.value);
}
resetJdbcProductConnectionFields(jdbcProductProfileForConfig(form.value), form.value);
if (draft.driverProfile === "oceanbase-oracle") {
oceanbaseSubMode.value = "oracle";
selectedType.value = "oceanbase";
}
if (draft.driverProfile === "gbase8a" || draft.driverProfile === "gbase8s") {
selectedType.value = "gbase";
}
customDriverName.value = isCustomCompatibleProfile() ? draft.driverLabel : "";
mongoUseUrl.value = !!draft.useMongoUrl;
if (draft.name?.trim()) {
form.value.name = draft.name.trim();
} else if (!form.value.name.trim()) {
form.value.name = draft.database || draft.host || draft.driverLabel;
}
dialogStep.value = "config";
configTab.value = "connection";
resetTestState();
}
function applyConnectionPrefill(draft: ConnectionDeepLinkDraft) {
resetForm();
applyConnectionDraftToForm(draft);
submitOneTimePrefill(draft);
}
watch(
open,
(value) => {
if (!value) {
const draftId = editingId.value ? null : draftTestConnectionId.value;
submittedOneTimePrefillKey.value = null;
resetForm();
if (draftId) {
void api.disconnectDb(draftId).catch(() => undefined);
draftTestConnectionId.value = uuid();
}
return;
}
if (!props.editConfig) {
resetForm();
if (props.prefillConfig) applyConnectionPrefill(props.prefillConfig);
}
void loadInstalledPlugins().then(() => {
if (!open.value) return;
if (props.editConfig?.db_type === "plugin" && props.editConfig.plugin_id && props.editConfig.plugin_connection_provider) {
const entry = pluginProviderEntry(props.editConfig.plugin_id, props.editConfig.plugin_connection_provider);
if (entry) {
selectedType.value = pluginConnectionProviderOptionValue(props.editConfig.plugin_id, props.editConfig.plugin_connection_provider);
selectedDbCategory.value = "plugins";
pluginFormValues.value = pluginConnectionFormValues(entry.contribution, props.editConfig);
}
} else if (!props.prefillConfig) {
applyRequestedPluginProvider(props.pluginProvider);
}
});
if (!props.prefillConfig?.oneTime) {
void loadJdbcDrivers();
void loadAgentDrivers();
void loadSshConfigHosts();
}
void loadInstalledPlugins().then(() => {
if (!open.value) return;
if (props.editConfig?.db_type === "plugin" && props.editConfig.plugin_id && props.editConfig.plugin_connection_provider) {
const entry = pluginProviderEntry(props.editConfig.plugin_id, props.editConfig.plugin_connection_provider);
if (entry) {
selectedType.value = pluginConnectionProviderOptionValue(props.editConfig.plugin_id, props.editConfig.plugin_connection_provider);
selectedDbCategory.value = "plugins";
pluginFormValues.value = pluginConnectionFormValues(entry.contribution, props.editConfig);
}
} else if (!props.prefillConfig) {
applyRequestedPluginProvider(props.pluginProvider);
}
});
// Preload database names so the summary count is accurate right away.
void nextTick(() => {
// An external update may change the endpoint while retaining its saved
// password. Do not send credentials until the user tests or saves it.
if (!props.updatePrefill && canChooseVisibleDatabases.value && hasVisibleDatabaseFilter.value) {
void preloadVisibleDatabaseNames();
}
});
},
{ immediate: true },
);
watch(connectionGroupOptions, (groups) => {
if (selectedConnectionGroupId.value && !groups.some((group) => group.id === selectedConnectionGroupId.value)) selectedConnectionGroupId.value = null;
});
watch(
() => props.prefillConfig,
(draft) => {
if (open.value && draft && !props.editConfig) applyConnectionPrefill(draft);
},
);
watch(
() => props.pluginProvider,
(target) => {
if (!open.value || props.editConfig || props.prefillConfig || !target) return;
if (!applyRequestedPluginProvider(target)) {
void loadInstalledPlugins().then(() => applyRequestedPluginProvider(target));
}
},
{ deep: true },
);
watch([() => form.value.db_type, () => form.value.username], () => {
if (isOracleSysUser(form.value)) form.value.sysdba = true;
});
watch(
() => connectionConfigSnapshotForVisibleDatabases(),
(current, previous) => {
if (applyingConnectionUpdate || !previous || !visibleObjectFiltersNeedReset(previous, current)) return;
form.value.visible_databases = undefined;
form.value.visible_schemas = undefined;
resetVisibleDatabaseDraftState();
resetVisibleSchemasState();
},
);
watch(
() => currentNacosScopeFingerprint(),
(current) => {
if (form.value.db_type !== "nacos") {
nacosScopeFingerprintBaseline = current;
return;
}
if (!nacosScopeFingerprintBaseline || current === nacosScopeFingerprintBaseline) {
nacosScopeFingerprintBaseline = current;
return;
}
nacosScopeFingerprintBaseline = current;
nacosManagedNamespacesText.value = "";
nacosDynamicAllNamespaces.value = false;
form.value.visible_databases = undefined;
resetVisibleNacosNamespaceDraftState();
resetTestState();
},
);
watch(visibleDatabaseShowSystem, (show) => {
if (show) return;
const connection = connectionConfigSnapshotForVisibleDatabases();
const visible = new Set(visibleFilterUsesSchemas.value ? filterSchemaNamesForVisiblePicker(visibleDatabaseNames.value, connection) : filterDatabaseNamesForVisiblePicker(visibleDatabaseNames.value, connection));
visibleDatabaseSelection.value = new Set([...visibleDatabaseSelection.value].filter((name) => visible.has(name)));
});
watch(canUseTransportLayers, (value) => {
if (!value && configTab.value === "transport") {
configTab.value = "connection";
}
});
watch(sqliteRemoteTransportRestricted, (restricted) => {
if (!restricted) return;
const layers = form.value.transport_layers || [];
const next = layers.filter((layer) => isSqliteRemoteTransportLayerType(layer.type));
if (next.length === layers.length) return;
form.value.transport_layers = next;
selectedTransportLayerId.value = next[0]?.id || null;
});
watch(supportsTlsToggle, (value) => {
if (!value && configTab.value === "tls") {
configTab.value = "connection";
}
});
function ensureSelectedTransportLayer() {
if (!selectedTransportLayerId.value || !transportLayers.value.some((layer) => layer.id === selectedTransportLayerId.value)) {
selectedTransportLayerId.value = transportLayers.value[0]?.id || null;
}
}
function addSshTunnel() {
const next: TransportLayerConfig = { type: "ssh", ...defaultSshTunnel() };
next.name = t("connection.sshHopDefaultName", { index: transportLayers.value.length + 1 });
form.value.transport_layers = [...transportLayers.value, next];
selectedTransportLayerId.value = next.id;
resetTestState();
}
function addProxyTunnel() {
const next: TransportLayerConfig = { type: "proxy", ...defaultProxyTunnel() };
next.name = `Proxy ${transportLayers.value.length + 1}`;
form.value.transport_layers = sqliteRemoteTransportRestricted.value ? insertSqliteRemoteTransportLayer(transportLayers.value, next) : [...transportLayers.value, next];
selectedTransportLayerId.value = next.id;
resetTestState();
}
function addHttpTunnel() {
if (sqliteRemoteTransportRestricted.value) return;
const next: TransportLayerConfig = { type: "http_tunnel", ...defaultHttpTunnel() };
next.name = t("connection.httpTunnelDefaultName", { index: 1 });
form.value.transport_layers = [next, ...transportLayers.value];
selectedTransportLayerId.value = next.id;
resetTestState();
}
function duplicateTransportLayer(layer: TransportLayerConfig) {
if (sqliteRemoteTransportRestricted.value && !isSqliteRemoteTransportLayerType(layer.type)) return;
const next = normalizeTransportLayer({ ...layer, id: uuid(), name: layer.name ? `${layer.name} copy` : "" });
form.value.transport_layers = sqliteRemoteTransportRestricted.value ? insertSqliteRemoteTransportLayer(transportLayers.value, next) : [...transportLayers.value, next];
selectedTransportLayerId.value = next.id;
resetTestState();
}
function removeTransportLayer(id: string) {
form.value.transport_layers = transportLayers.value.filter((layer) => layer.id !== id);
ensureSelectedTransportLayer();
resetTestState();
}
function moveTransportLayer(id: string, direction: -1 | 1) {
const layers = [...transportLayers.value];
const index = layers.findIndex((layer) => layer.id === id);
const target = index + direction;
if (index < 0 || target < 0 || target >= layers.length) return;
[layers[index], layers[target]] = [layers[target], layers[index]];
form.value.transport_layers = layers;
resetTestState();
}
function dropTransportLayer(targetId: string) {
const sourceId = draggedTransportLayerId.value;
draggedTransportLayerId.value = null;
if (!sourceId || sourceId === targetId) return;
const layers = [...transportLayers.value];
const sourceIndex = layers.findIndex((layer) => layer.id === sourceId);
const targetIndex = layers.findIndex((layer) => layer.id === targetId);
if (sourceIndex < 0 || targetIndex < 0) return;
const [source] = layers.splice(sourceIndex, 1);
layers.splice(targetIndex, 0, source);
form.value.transport_layers = layers;
resetTestState();
}
function changeSelectedTransportLayerType(type: "ssh" | "proxy" | "http_tunnel") {
const selected = selectedTransportLayer.value;
if (!selected || selected.type === type) return;
if (sqliteRemoteTransportRestricted.value && !isSqliteRemoteTransportLayerType(type)) return;
const replacement: TransportLayerConfig =
type === "proxy" ? { type: "proxy", ...defaultProxyTunnel(), id: selected.id, name: selected.name } : type === "http_tunnel" ? { type: "http_tunnel", ...defaultHttpTunnel(), id: selected.id, name: selected.name } : { type: "ssh", ...defaultSshTunnel(), id: selected.id, name: selected.name };
form.value.transport_layers = transportLayers.value.map((layer) => (layer.id === selected.id ? replacement : layer));
resetTestState();
}
function updateSelectedProxyType(value: unknown) {
const layer = selectedProxyLayer.value;
if (!layer) return;
layer.proxy_type = value === "http" ? "http" : "socks5";
resetTestState();
}
function updateSelectedSshAuthMethod(value: unknown) {
const layer = selectedSshLayer.value;
if (!layer) return;
applySshAuthMethod(layer, value);
resetTestState();
}
function validateTransportLayers(config: LegacyConnectionConfig) {
const layers = config.transport_layers || [];
if (config.db_type === "sqlite" && sqliteRemoteTransportError(layers)) {
throw new Error(t("connection.sqliteTransportSshOnly"));
}
layers.forEach((layer, index) => {
if (layer.enabled === false) return;
// Profile-referencing layers are stubs: the shared profile supplies the
// whole configuration at connect time, so there is nothing to validate.
if (layer.profile_id) return;
const label = layer.name?.trim() || transportLayerDefaultName(layer, index);
if (layer.type === "http_tunnel") {
if (index !== 0) throw new Error(t("connection.httpTunnelInvalidOrder", { hop: label }));
if (!layer.url?.trim()) throw new Error(t("connection.httpTunnelInvalidUrl", { hop: label }));
const timeout = Number(layer.connect_timeout_secs);
if (!Number.isFinite(timeout) || timeout < 1 || timeout > 300) {
throw new Error(t("connection.httpTunnelInvalidTimeout", { hop: label }));
}
return;
}
if (!layer.host?.trim()) throw new Error(t("connection.sshHopInvalidHost", { hop: label }));
const port = Number(layer.port);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
throw new Error(t("connection.sshHopInvalidPort", { hop: label }));
}
if (layer.type === "ssh") {
layer.user = layer.user?.trim() || DEFAULT_SSH_USER;
// Auth credentials are optional: the backend probes "none" authentication
// first, so hops that require no credential (e.g. passwordless SSH proxies)
// are valid with password, key, and agent all left empty.
const timeout = Number(layer.connect_timeout_secs);
if (!Number.isFinite(timeout) || timeout < 1 || timeout > 300) {
throw new Error(t("connection.sshHopInvalidTimeout", { hop: label }));
}
}
});
}
function clampQueryTimeoutInput(event: Event, target: "global" | "connection") {
const input = event.target as HTMLInputElement;
if (input.value === "") return;
const value = Number(input.value);
if (!Number.isFinite(value) || value <= MAX_QUERY_TIMEOUT_SECS) return;
input.value = String(MAX_QUERY_TIMEOUT_SECS);
if (target === "global") editGlobalQueryTimeoutSecs.value = MAX_QUERY_TIMEOUT_SECS;
else form.value.query_timeout_secs = MAX_QUERY_TIMEOUT_SECS;
}
function clampConnectTimeoutInput(event: Event, target: "global" | "connection") {
const input = event.target as HTMLInputElement;
if (input.value === "") return;
const value = Number(input.value);
if (!Number.isFinite(value) || value <= MAX_CONNECT_TIMEOUT_SECS) return;
input.value = String(MAX_CONNECT_TIMEOUT_SECS);
if (target === "global") editGlobalConnectTimeoutSecs.value = MAX_CONNECT_TIMEOUT_SECS;
else form.value.connect_timeout_secs = MAX_CONNECT_TIMEOUT_SECS;
}
async function persistGlobalTimeoutDrafts() {
const nextConnect = normalizeGlobalConnectTimeoutSecs(editGlobalConnectTimeoutSecs.value);
const nextQuery = normalizeGlobalQueryTimeoutSecs(editGlobalQueryTimeoutSecs.value);
editGlobalConnectTimeoutSecs.value = nextConnect;
editGlobalQueryTimeoutSecs.value = nextQuery;
const connectChanged = nextConnect !== settingsStore.editorSettings.globalConnectTimeoutSecs;
const queryChanged = nextQuery !== settingsStore.editorSettings.globalQueryTimeoutSecs;
if (!connectChanged && !queryChanged) return;
settingsStore.updateEditorSettings({
globalConnectTimeoutSecs: nextConnect,
globalQueryTimeoutSecs: nextQuery,
});
await settingsStore.persistEditorSettings();
await store.applyGlobalTimeouts({
connectTimeoutSecs: connectChanged ? nextConnect : undefined,
queryTimeoutSecs: queryChanged ? nextQuery : undefined,
});
}
async function persistConnectionNoteVisibilityDraft() {
await persistConnectionNoteVisibilityDraftState(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes, (value) => settingsStore.updateEditorSettingsAndPersist({ sidebarShowConnectionNotes: value }));
}
function startSavedConnection(config: ConnectionConfig) {
emit("connectStarted", config.name);
void store
.connect(config)
.then(() => {
emit("connectSucceeded", config.name);
})
.catch((e: any) => {
const message = String(e?.message || e);
if (message.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return;
// Keep failed one-time connections available for retry and error inspection.
// They are still removed by connectionStore.disconnect after a successful session.
emit("connectFailed", appendConnectionErrorHints(config, mongodbAuthFailureHint(message), t));
});
}
function validateConnectionUpdateTarget() {
const update = props.updatePrefill ?? appliedConnectionUpdate;
if (!update) return;
const target = store.getConfig(update.connectionId);
resolveConnectionDeepLinkUpdate(update, target ? [target] : [], false);
if (editingId.value !== update.connectionId) throw new Error("The connection specified by the update link is no longer being edited.");
}
async function save(options: SaveConnectionOptions = {}): Promise<boolean> {
if (!ensureConnectionHostResolvedFromUrl()) return false;
if (isSaving.value) return false;
if (!hasNacosNamespaceScopeForSave()) {
testResult.value = null;
await openVisibleNacosNamespacesPicker();
return false;
}
const wasEditing = !!editingId.value;
const connectAfterSave = options.connectAfterSave ?? (!wasEditing && !isJdbcConnection.value);
const closeOnSuccess = options.closeOnSuccess ?? true;
const databaseInfoForSave = visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value;
isSaving.value = true;
let connectionSaved = false;
try {
let savedConfig: ConnectionConfig;
validateConnectionUpdateTarget();
if (editingId.value) {
const updated = withSavedDatabaseInfo(connectionConfigForSubmit(editingId.value), databaseInfoForSave);
await ensureRequiredAgentDriverInstalled(updated);
await ensureRequiredGaussdbMJdbcRuntime(updated);
validateConnectionUpdateTarget();
await persistGlobalTimeoutDrafts();
await store.updateConnection(updated);
savedConfig = updated;
connectionSaved = true;
await persistConnectionNoteVisibilityDraft();
} else {
const config = withSavedDatabaseInfo(connectionConfigForSubmit(draftTestConnectionId.value), databaseInfoForSave);
await ensureRequiredAgentDriverInstalled(config);
await ensureRequiredGaussdbMJdbcRuntime(config);
await persistGlobalTimeoutDrafts();
await store.addConnection(config, selectedConnectionGroupId.value);
savedConfig = config;
connectionSaved = true;
await persistConnectionNoteVisibilityDraft();
draftTestConnectionId.value = uuid();
}
if (closeOnSuccess) {
if (wasEditing) store.stopEditing();
open.value = false;
await nextTick();
} else if (!wasEditing) {
editingId.value = savedConfig.id;
store.startEditing(savedConfig.id);
}
if (connectAfterSave && savedConfig.db_type !== "jdbc") startSavedConnection(savedConfig);
return true;
} catch (e: any) {
const cause = mongodbAuthFailureHint(String(e?.message || e));
const message = connectionSaved ? t("connection.savedSettingsFailed", { message: cause }) : cause;
testResult.value = { ok: false, message };
showConnectionError(message);
return false;
} finally {
isSaving.value = false;
}
}
const dialogTitle = ref("");
watch([() => editingId.value, () => open.value], () => {
dialogTitle.value = editingId.value ? t("connection.editTitle") : t("connection.title");
});
const sshConfigHostAliases = computed(() => sshConfigHosts.value.map((entry) => entry.alias));
function applySshConfigHostAliasPrefill(target: SshTunnelConfig) {
prefillSshConfigHostAlias(target, sshConfigHosts.value);
}
async function browseSshKeyPath(target?: SshTunnelConfig | null) {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: "Select SSH Private Key",
multiple: false,
});
if (selected && typeof selected === "string") {
if (target) {
target.key_path = selected;
}
}
}
}
async function browseCaCertPath() {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: "Select CA Certificate",
multiple: false,
filters: [{ name: "Certificate", extensions: ["crt", "cer", "pem"] }],
});
if (selected && typeof selected === "string") {
form.value.ca_cert_path = selected;
}
}
}
async function browseCassandraStore(target: "truststore" | "keystore") {
if (!isTauriRuntime()) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: target === "truststore" ? t("connection.cassandraTruststoreBrowse") : t("connection.cassandraKeystoreBrowse"),
multiple: false,
filters: [
{ name: "Java KeyStore / PKCS#12", extensions: ["jks", "p12", "pfx", "truststore", "keystore"] },
{ name: "All Files", extensions: ["*"] },
],
});
if (typeof selected === "string") {
if (target === "truststore") cassandraTls.truststore_path = selected;
else cassandraTls.keystore_path = selected;
}
}
async function browseDamengSslFilesPath() {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: t("connection.damengSslFilesPathBrowse"),
directory: true,
multiple: false,
});
if (selected && typeof selected === "string") {
damengSslFilesPath.value = selected;
}
}
}
async function browseMysqlTlsFile(target: "cert" | "key") {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: target === "cert" ? t("connection.mysqlClientCertBrowse") : t("connection.mysqlClientKeyBrowse"),
multiple: false,
filters: [
{ name: "PEM", extensions: ["pem", "crt", "cer", "key"] },
{ name: "All Files", extensions: ["*"] },
],
});
if (selected && typeof selected === "string") {
if (target === "cert") {
mysqlClientCertPath.value = selected;
} else {
mysqlClientKeyPath.value = selected;
}
}
}
}
async function browsePostgresTlsFile(target: "root" | "cert" | "key") {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: target === "root" ? t("connection.postgresRootCertBrowse") : target === "cert" ? t("connection.postgresClientCertBrowse") : t("connection.postgresClientKeyBrowse"),
multiple: false,
filters: [
{ name: "PEM", extensions: ["pem", "crt", "cer", "key"] },
{ name: "All Files", extensions: ["*"] },
],
});
if (selected && typeof selected === "string") {
if (target === "root") {
postgresRootCertPath.value = selected;
} else if (target === "cert") {
postgresClientCertPath.value = selected;
} else {
postgresClientKeyPath.value = selected;
}
}
}
}
async function browseEtcdTlsFile(target: "ca" | "cert" | "key") {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: target === "ca" ? t("connection.etcdCaCertBrowse") : target === "cert" ? t("connection.etcdClientCertBrowse") : t("connection.etcdClientKeyBrowse"),
multiple: false,
filters: [
{ name: "PEM", extensions: ["pem", "crt", "cer", "key"] },
{ name: "All Files", extensions: ["*"] },
],
});
if (selected && typeof selected === "string") {
if (target === "ca") {
form.value.ca_cert_path = selected;
} else if (target === "cert") {
form.value.client_cert_path = selected;
} else {
form.value.client_key_path = selected;
}
}
}
}
async function browseHiveKerberosFile(target: "krb5" | "jaas") {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: target === "krb5" ? t("connection.hiveKrb5ConfBrowse") : t("connection.hiveJaasConfigBrowse"),
multiple: false,
filters: [{ name: "Config", extensions: ["conf", "ini", "properties", "*"] }],
});
if (selected && typeof selected === "string") {
if (target === "krb5") {
hiveKrb5ConfPath.value = selected;
} else {
hiveJaasConfigPath.value = selected;
}
}
}
}
async function browseOracleTnsNamesFile() {
if (!isTauriRuntime()) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: t("connection.oracleTnsAdminBrowse"),
multiple: false,
filters: [{ name: "Oracle TNS names", extensions: ["ora"] }],
});
if (typeof selected === "string") {
oracleTnsAdminPath.value = normalizeOracleTnsAdminPath(selected);
resetTestState();
}
}
async function browseKafkaKerberosFile(target: "keytab" | "krb5") {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: target === "keytab" ? t("connection.kafkaKerberosKeytabBrowse") : t("connection.kafkaKerberosKrb5ConfBrowse"),
multiple: false,
filters: [{ name: "Kerberos", extensions: target === "keytab" ? ["keytab", "kt", "*"] : ["conf", "ini", "*"] }],
});
if (selected && typeof selected === "string") {
if (target === "keytab") {
mqKafkaKerberosKeytabPath.value = selected;
} else {
mqKafkaKrb5ConfPath.value = selected;
}
}
}
}
async function browseDbFilePath() {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const filters = form.value.db_type === "duckdb" ? [{ name: "DuckDB", extensions: ["duckdb", "db"] }] : form.value.db_type === "access" ? [{ name: "Microsoft Access", extensions: ["accdb", "mdb"] }] : form.value.db_type === "h2" ? [{ name: "H2", extensions: ["db"] }] : undefined;
const selected = await open({
title: "Select Database File",
multiple: false,
...(filters ? { filters } : {}),
});
if (selected && typeof selected === "string") {
form.value.host = selected;
}
}
}
async function browseSqliteExtensionPath() {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: t("connection.sqliteExtensionBrowse"),
multiple: true,
filters: [
{ name: "SQLite Extension", extensions: ["dylib", "so", "dll"] },
{ name: "All Files", extensions: ["*"] },
],
});
const selectedPaths = Array.isArray(selected) ? selected : selected && typeof selected === "string" ? [selected] : [];
if (selectedPaths.length) {
const existing = sqliteExtensionPaths.value
.split(/\r?\n/)
.map((path) => path.trim())
.filter(Boolean);
sqliteExtensionPaths.value = [...existing, ...selectedPaths].join("\n");
}
}
}
function ensureDuckDbFileExtension(path: string): string {
return /\.(duckdb|db)$/i.test(path) ? path : `${path}.duckdb`;
}
async function createDuckDbFilePath() {
if (!isTauriRuntime()) return;
const { save } = await import("@tauri-apps/plugin-dialog");
const selected = await save({
title: t("connection.createDuckDbFile"),
defaultPath: "database.duckdb",
filters: [{ name: "DuckDB", extensions: ["duckdb", "db"] }],
});
if (!selected) return;
const path = ensureDuckDbFileExtension(selected);
form.value.host = path;
try {
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
await writeTextFile(path, "");
} catch (e) {
console.error("Failed to create DuckDB file:", e);
}
}
function ensureSqliteFileExtension(path: string): string {
const extensionPattern = new RegExp(`\\.(${SQLITE_DATABASE_FILE_EXTENSIONS.join("|")})$`, "i");
return extensionPattern.test(path) ? path : `${path}.db`;
}
async function createSqliteFilePath() {
if (!isTauriRuntime()) return;
const { save } = await import("@tauri-apps/plugin-dialog");
const selected = await save({
title: t("connection.createSqliteFile"),
defaultPath: "database.db",
filters: [{ name: "SQLite", extensions: SQLITE_DATABASE_FILE_EXTENSIONS }],
});
if (!selected) return;
const path = ensureSqliteFileExtension(selected);
form.value.host = path;
try {
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
await writeTextFile(path, "");
} catch (e) {
console.error("Failed to create SQLite file:", e);
}
}
async function browseJdbcDriverPaths() {
if (!isTauriRuntime()) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: t("connection.jdbcDriverBrowse"),
multiple: true,
filters: [{ name: "JDBC Driver", extensions: ["jar"] }],
});
if (!selected) return;
const paths = Array.isArray(selected) ? selected : [selected];
const existing = jdbcDriverPathsInput.value
.split(/\r?\n/)
.map((path) => path.trim())
.filter(Boolean);
const merged = Array.from(new Set([...existing, ...paths.filter((path): path is string => typeof path === "string")]));
jdbcDriverPathsInput.value = merged.join("\n");
}
async function loadJdbcDrivers() {
try {
const [drivers, bundles, localBundles] = await Promise.all([api.listJdbcDrivers(), api.listJdbcMavenBundles(), api.listJdbcLocalBundles()]);
jdbcDrivers.value = drivers;
jdbcMavenBundles.value = bundles;
jdbcLocalBundles.value = localBundles;
applyPrestoSqlBuiltinDriverPathsIfAvailable();
} catch {
jdbcDrivers.value = [];
jdbcMavenBundles.value = [];
jdbcLocalBundles.value = [];
}
}
async function loadSshConfigHosts() {
try {
sshConfigHosts.value = await api.listSshConfigHosts();
} catch {
sshConfigHosts.value = [];
}
}
async function loadAgentDrivers() {
try {
agentDrivers.value = await api.listInstalledAgentsLocal();
api
.listInstalledAgents()
.then((drivers) => {
agentDrivers.value = drivers;
})
.catch(() => {
/* keep local state */
});
} catch {
agentDrivers.value = [];
}
}
function addJdbcDriverPaths(paths: string[]) {
const existing = jdbcDriverPathsInput.value
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean);
jdbcDriverPathsInput.value = Array.from(new Set([...existing, ...paths])).join("\n");
}
function applyPrestoSqlBuiltinDriverPathsIfAvailable() {
if (form.value.db_type !== "prestosql" || jdbcManualClasspathCount.value > 0) return;
const paths = prestoSqlBuiltinDriverPaths(jdbcMavenBundles.value);
if (paths.length === 0) return;
addJdbcDriverPaths(paths);
selectedJdbcDriverPath.value = jdbcDriverSelectItems.value.find((item) => paths.every((path) => item.paths.includes(path)))?.id ?? "";
jdbcManualClasspathOpen.value = false;
}
function onJdbcDriverSelect(id: any) {
if (typeof id !== "string" || !id) return;
const item = jdbcDriverSelectItemById.value.get(id);
if (!item) return;
selectedJdbcDriverPath.value = id;
if (item.managedProductRuntime && activeJdbcProductProfile.value) {
const existingPaths = jdbcDriverPathsInput.value
.split(/\r?\n/)
.map((path) => path.trim())
.filter((path) => path && !isJdbcProductManagedMavenPath(activeJdbcProductProfile.value!, path));
jdbcDriverPathsInput.value = Array.from(new Set([...existingPaths, ...item.paths])).join("\n");
} else if (isJdbcxConnection.value && item.jdbcxRuntime) {
const installedJdbcxRuntimePaths = new Set(jdbcDriverSelectItems.value.filter((candidate) => candidate.jdbcxRuntime).flatMap((candidate) => candidate.paths));
const existingPaths = jdbcDriverPathsInput.value
.split(/\r?\n/)
.map((path) => path.trim())
.filter((path) => path && !installedJdbcxRuntimePaths.has(path));
jdbcDriverPathsInput.value = Array.from(new Set([...existingPaths, ...item.paths])).join("\n");
} else {
addJdbcDriverPaths(item.paths);
}
jdbcManualClasspathOpen.value = false;
}
onMounted(async () => {
void tunnelProfileStore.init();
unlistenAgentInstallProgress = await api.listenAgentInstallProgress(handleAgentInstallProgress);
});
onUnmounted(() => {
unlistenAgentInstallProgress?.();
unlistenAgentInstallProgress = null;
// Stop any in-flight Salesforce device-code poll / expiry countdown so
// closing the dialog does not leave orphan timers running.
salesforceDeviceStopPolling();
});
function openExternalUrl(url: string) {
if (isTauriRuntime()) {
import("@tauri-apps/plugin-shell").then(({ open }) => open(url));
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent :style="dialogContentStyle" class="connection-dialog-content" :class="connectionDialogContentClass" :data-wide="shouldUseWideConnectionDialog ? 'true' : undefined" @interact-outside.prevent @escape-key-down="handleDialogEscape" @keydown="preventDialogDocumentSelectAll">
<DialogHeader class="cursor-move select-none" @pointerdown="onDialogHeaderPointerDown" @pointermove="onDialogHeaderPointerMove" @pointerup="onDialogHeaderPointerEnd" @pointercancel="onDialogHeaderPointerEnd">
<DialogTitle>{{ editingId ? t("connection.editTitle") : t("connection.title") }}</DialogTitle>
</DialogHeader>
<template v-if="dialogStep === 'select'">
<div class="flex min-h-0 flex-1 flex-col gap-4">
<div class="connection-db-picker-toolbar flex flex-col gap-3 p-0.5 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<div class="flex shrink-0 rounded-lg border bg-muted/40 p-0.5">
<Button
type="button"
size="icon-sm"
variant="ghost"
:class="dbPickerView === 'icon' ? 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground' : undefined"
:title="t('connection.iconView')"
:aria-label="t('connection.iconView')"
:aria-pressed="dbPickerView === 'icon'"
@click="selectDbPickerView('icon')"
>
<Grid3X3 class="h-3.5 w-3.5" />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
:class="dbPickerView === 'list' ? 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground' : undefined"
:title="t('connection.listView')"
:aria-label="t('connection.listView')"
:aria-pressed="dbPickerView === 'list'"
@click="selectDbPickerView('list')"
>
<List class="h-3.5 w-3.5" />
</Button>
</div>
<div class="connection-db-picker-search relative w-full sm:w-64">
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input data-connection-db-search v-model="dbSearchQuery" v-connection-dialog-auto-focus class="h-9 pl-8" :placeholder="t('connection.searchDatabasePlaceholder')" />
</div>
</div>
<Button data-jdbc-connection-entry type="button" variant="outline" class="h-9 shrink-0 gap-2" @click="goToConnectionStep('jdbc')">
<DatabaseIcon db-type="jdbc" class="h-4 w-4" />
{{ t("connection.jdbcConnection") }}
</Button>
</div>
<div class="connection-db-picker-body min-h-0 flex flex-1 flex-col gap-3 overflow-hidden sm:flex-row sm:gap-4">
<nav data-connection-category-nav class="flex shrink-0 gap-1 overflow-x-auto border-b px-0.5 pt-0.5 pb-2.5 sm:w-40 sm:flex-col sm:overflow-y-auto sm:border-b-0 sm:border-r sm:py-0.5 sm:pr-3.5" :aria-label="t('connection.databaseCategories')">
<button
v-for="category in dbCategories"
:key="category.key"
type="button"
class="connection-db-category-option shrink-0 whitespace-nowrap rounded-[4px] px-3 py-2 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-full"
:class="!isDbSearchActive && selectedDbCategory === category.key ? 'connection-db-category-option--selected bg-primary/10 font-medium text-primary hover:bg-primary/10' : 'text-muted-foreground hover:bg-muted/70'"
:aria-current="!isDbSearchActive && selectedDbCategory === category.key ? 'page' : undefined"
@click="selectDbCategory(category.key)"
>
{{ category.title }}
</button>
</nav>
<div class="connection-db-picker-results min-w-0 flex-1 space-y-5 overflow-y-auto p-0.5 pr-2">
<div v-if="isDbSearchActive" class="text-sm font-medium">{{ t("connection.searchResults") }}</div>
<section v-for="category in visibleDbCategories" :key="category.key" class="space-y-2">
<h3 v-if="isDbSearchActive" class="text-sm font-medium">{{ category.title }}</h3>
<div v-if="dbPickerView === 'icon'" class="connection-db-picker-grid grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-5">
<button
v-for="opt in category.options"
:key="opt.value"
type="button"
:title="opt.label"
class="connection-db-picker-option group flex min-h-24 flex-col items-center justify-center gap-2 rounded-[4px] border bg-background/70 p-3 text-center transition hover:border-primary/40 hover:bg-muted/40 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="isPickerOptionSelected(opt.value) ? 'dbx-tile-selected shadow-sm' : 'border-border'"
:aria-pressed="isPickerOptionSelected(opt.value)"
@click="onDbTypeChange(opt.value)"
@dblclick="goToConnectionStep(opt.value)"
>
<span class="flex h-10 w-10 items-center justify-center rounded-xl bg-muted/60 transition group-hover:bg-background">
<PluginIcon v-if="opt.plugin" :plugin-id="opt.pluginId || ''" :icon="opt.pluginIcon" class="h-6 w-6" />
<DatabaseIcon v-else :db-type="iconTypeMap[opt.value] || opt.value" class="h-6 w-6" />
</span>
<span class="flex min-h-8 max-w-full items-center justify-center">
<span class="line-clamp-2 text-sm leading-4 font-medium">{{ opt.label }}</span>
</span>
</button>
</div>
<div v-else class="grid gap-2">
<button
v-for="opt in category.options"
:key="opt.value"
type="button"
class="connection-db-picker-option flex items-center gap-3 rounded-[4px] border bg-background px-3 py-2 text-left transition hover:border-primary/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="isPickerOptionSelected(opt.value) ? 'dbx-tile-selected' : 'border-border'"
:aria-pressed="isPickerOptionSelected(opt.value)"
@click="onDbTypeChange(opt.value)"
@dblclick="goToConnectionStep(opt.value)"
>
<PluginIcon v-if="opt.plugin" :plugin-id="opt.pluginId || ''" :icon="opt.pluginIcon" class="h-5 w-5 shrink-0" />
<DatabaseIcon v-else :db-type="iconTypeMap[opt.value] || opt.value" class="h-5 w-5 shrink-0" />
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ opt.label }}</span>
<span v-if="isDbSearchActive" class="text-xs text-muted-foreground">{{ category.title }}</span>
</button>
</div>
</section>
<div v-if="!hasDbPickerResults" class="rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
{{ t("connection.noDatabaseMatches") }}
</div>
</div>
</div>
</div>
<DialogFooter class="flex shrink-0 items-center gap-2">
<div class="mr-auto flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
<PluginIcon v-if="isPluginConnection" :plugin-id="selectedPluginProvider?.plugin.manifest.id || ''" :icon="selectedPluginIcon" class="h-4 w-4" />
<DatabaseIcon v-else :db-type="selectedDbIcon" class="h-4 w-4 shrink-0" />
<span class="truncate">{{ t("connection.selectedDatabase") }}: {{ selectedProfile().label }}</span>
</div>
<Button :disabled="!hasDbPickerResults || !selectedDbOptionIsVisible" @click="goToConnectionStep()">
{{ t("connection.next") }}
<ChevronRight class="h-4 w-4" />
</Button>
</DialogFooter>
</template>
<template v-else>
<div class="connection-config-step flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
<Tabs v-model="configTab" class="flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b pb-2">
<TabsList>
<TabsTrigger value="connection">{{ t("connection.basicTab") }}</TabsTrigger>
<TabsTrigger v-if="supportsTlsToggle" value="tls">{{ t("connection.tlsTab") }}</TabsTrigger>
<TabsTrigger v-if="canUseTransportLayers" value="transport">{{ t("connection.sshTunnel") }}</TabsTrigger>
<TabsTrigger value="advanced">{{ t("connection.advancedTab") }}</TabsTrigger>
</TabsList>
</div>
<TabsContent value="connection" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto pt-4 pr-2 pb-6" :class="{ 'connection-form-body--nacos': form.db_type === 'nacos' }">
<div v-if="!isPluginConnection && !isJdbcConnection && form.db_type !== 'nacos' && form.db_type !== 'consul' && form.db_type !== 'mq' && form.db_type !== 'salesforce'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.connectionUrlOptional") }}</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="connectionUrlInput" class="flex-1" :placeholder="connectionUrlPlaceholder" @keydown.enter.prevent="applyConnectionUrl" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="!connectionUrlInput.trim()" :aria-label="t('connection.parseConnectionUrl')" @click="applyConnectionUrl">
<Link2 class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.parseConnectionUrl") }}</TooltipContent>
</Tooltip>
</div>
</div>
<div v-if="form.db_type === 'zookeeper'" class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 m-0 text-xs leading-5 text-muted-foreground">{{ t("connection.zookeeperClusterInputHint") }}</p>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.name") }}</Label>
<Input v-model="form.name" v-connection-dialog-auto-focus class="col-span-3" :placeholder="t('connection.namePlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.type") }}</Label>
<button type="button" class="col-span-3 flex items-center gap-2 rounded-md border bg-muted/20 px-3 py-2 hover:bg-muted/40 cursor-pointer transition" @click="backToDatabasePicker()">
<PluginIcon v-if="isPluginConnection && selectedPluginProvider" :plugin-id="selectedPluginProvider.plugin.manifest.id" :icon="selectedPluginIcon" class="h-4 w-4 shrink-0" />
<DatabaseIcon v-else :db-type="selectedDbIcon" class="h-4 w-4 shrink-0" />
<span class="min-w-0 flex-1 truncate text-sm text-left">{{ selectedProfile().label }}</span>
<Pencil class="h-3 w-3 text-muted-foreground" />
</button>
</div>
<!-- OceanBase mode toggle -->
<div v-if="selectedType === 'oceanbase'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.mode") }}</Label>
<div class="col-span-3 flex gap-2">
<Button size="sm" :variant="oceanbaseSubMode === 'mysql' ? 'default' : 'outline'" @click="switchOceanbaseMode('mysql')">
{{ t("connection.oceanbaseMySQLMode") }}
</Button>
<Button size="sm" :variant="oceanbaseSubMode === 'oracle' ? 'default' : 'outline'" @click="switchOceanbaseMode('oracle')">
{{ t("connection.oceanbaseOracleMode") }}
</Button>
</div>
</div>
<div v-if="selectedType === 'gbase'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.version") }}</Label>
<div class="col-span-3 flex gap-2">
<Button size="sm" :variant="form.driver_profile === 'gbase8s' ? 'outline' : 'default'" @click="switchGbaseProfile('gbase8a')"> GBase 8a </Button>
<Button size="sm" :variant="form.driver_profile === 'gbase8s' ? 'default' : 'outline'" @click="switchGbaseProfile('gbase8s')"> GBase 8s </Button>
</div>
</div>
<div v-if="isCustomCompatibleProfile()" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.driverName") }}</Label>
<Input v-model="customDriverName" class="col-span-3" :placeholder="t('connection.driverNamePlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.color") }}</Label>
<div class="col-span-3 flex min-w-0 items-center">
<div class="flex shrink-0 items-center gap-1.5">
<button
v-for="color in colorOptions"
:key="color.value || 'none'"
type="button"
class="h-6 w-6 rounded-full border ring-offset-background transition hover:scale-105"
:class="[color.class, form.color === color.value ? 'ring-2 ring-ring ring-offset-2' : 'border-border']"
:title="t(color.labelKey)"
@click="handlePresetClick(color.value)"
/>
<Popover v-model:open="customColorOpen">
<PopoverTrigger as-child>
<button
type="button"
class="h-6 w-6 rounded-full border flex items-center justify-center hover:scale-105 transition"
:class="[!isPresetColor(form.color) && form.color ? 'border-border ring-2 ring-ring ring-offset-2' : 'border-dashed border-border']"
:style="!isPresetColor(form.color) && form.color ? { backgroundColor: form.color } : {}"
:title="t('connection.colorCustom')"
>
<Pipette class="h-3.5 w-3.5" :class="!isPresetColor(form.color) && form.color ? 'text-white' : 'text-muted-foreground'" />
</button>
</PopoverTrigger>
<PopoverContent class="w-auto p-2">
<div class="flex items-center gap-2">
<input type="color" :value="form.color" @input="handleCustomColorPicked(($event.target as HTMLInputElement).value)" class="h-6 w-6 cursor-pointer rounded border-0 p-0" />
<Input type="text" :value="customColorInput || form.color" @input="handleCustomColorInput(($event.target as HTMLInputElement).value)" class="w-28 h-7 text-xs font-mono" :placeholder="t('connection.customColorPlaceholder')" />
</div>
</PopoverContent>
</Popover>
</div>
<div v-if="!editingId && connectionGroupOptions.length > 0" class="ml-3 flex min-w-0 flex-1 items-center gap-2 border-l pl-3">
<Label class="shrink-0 text-xs text-muted-foreground">{{ t("connectionGroup.group") }}</Label>
<Select v-model="connectionGroupSelectValue">
<SelectTrigger class="h-8 min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="UNGROUPED_CONNECTION_GROUP">{{ t("connectionGroup.ungroupedLabel") }}</SelectItem>
<SelectItem v-for="group in connectionGroupOptions" :key="group.id" :value="group.id">{{ group.label }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<template v-if="isPluginConnection">
<PluginConnectionFields
v-if="selectedPluginProvider"
:model-value="pluginFormValues"
:contribution="selectedPluginProvider.contribution"
:hidden-bindings="['name']"
layout="connection-dialog"
:plugin-id="selectedPluginProvider.plugin.manifest.id"
@update:model-value="updatePluginFormValues"
/>
<div v-else class="col-span-full rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
{{ pluginLoadError || t("connection.pluginProviderUnavailable") }}
</div>
</template>
<template v-else>
<div v-if="form.db_type === 'h2'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.mode") }}</Label>
<div class="col-span-3 flex gap-2">
<Button size="sm" :variant="h2ConnectionMode === 'file' ? 'default' : 'outline'" @click="switchH2ConnectionMode('file')">
{{ t("connection.h2FileMode") }}
</Button>
<Button size="sm" :variant="h2ConnectionMode === 'tcp' ? 'default' : 'outline'" @click="switchH2ConnectionMode('tcp')">
{{ t("connection.h2TcpMode") }}
</Button>
</div>
</div>
<div v-if="form.db_type === 'h2'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">Driver</Label>
<div class="col-span-3 flex flex-wrap gap-2">
<Button size="sm" :variant="!form.driver_profile || form.driver_profile === 'h2' || form.driver_profile === 'h2-auto' ? 'default' : 'outline'" @click="switchH2DriverProfile('h2')">Auto</Button>
<Button size="sm" :variant="form.driver_profile === 'h2-v1' ? 'default' : 'outline'" @click="switchH2DriverProfile('h2-v1')">H2 1.x</Button>
<Button size="sm" :variant="form.driver_profile === 'h2-v2' || form.driver_profile === 'h2-legacy' ? 'default' : 'outline'" @click="switchH2DriverProfile('h2-v2')">H2 2.0–2.1</Button>
<Button size="sm" :variant="form.driver_profile === 'h2-v3' ? 'default' : 'outline'" @click="switchH2DriverProfile('h2-v3')">H2 2.2+</Button>
<Button size="sm" :variant="form.driver_profile === 'h2-custom' ? 'default' : 'outline'" @click="switchH2DriverProfile('h2-custom')">Custom JAR</Button>
</div>
</div>
<template v-if="isH2CustomDriver">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.jdbcDriverPaths") }}</Label>
<div class="col-span-3 space-y-2">
<Select v-if="jdbcDriverSelectItems.length > 0" :model-value="selectedJdbcDriverPath" @update:model-value="onJdbcDriverSelect">
<SelectTrigger>
<SelectValue :placeholder="t('connection.jdbcDriverSelectPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="driver in jdbcDriverSelectItems" :key="driver.id" :value="driver.id">
{{ driver.label }}
</SelectItem>
</SelectContent>
</Select>
<div class="flex items-start gap-1">
<textarea
v-model="jdbcDriverPathsInput"
class="flex min-h-12 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t('connection.jdbcDriverPathsPlaceholder')"
/>
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button type="button" variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseJdbcDriverPaths">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.jdbcDriverBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.jdbcDriverClass") }}</Label>
<Input v-model="form.jdbc_driver_class" class="col-span-3" placeholder="org.h2.Driver" />
</div>
</template>
<div v-if="h2DriverMissing" class="grid grid-cols-4 items-center gap-4">
<span />
<p class="col-span-3 text-xs text-muted-foreground">
{{ t("connection.driverInstallHintPrefix") }}<a class="underline cursor-pointer text-primary hover:text-primary/80" @click="emit('openDriverStore', agentDriverFocus)">{{ t("toolbar.driverManager") }}</a
>{{ t("connection.driverInstallHintSuffix") }}
</p>
</div>
<!-- JDBC: optional external plugin -->
<template v-if="isJdbcConnection">
<div v-if="form.driver_profile === 'dremio'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mode") }}</Label>
<div class="col-span-3 flex gap-2">
<Button size="sm" :variant="dremioConnectionMode === 'arrow-flight-sql' ? 'default' : 'outline'" @click="applyDremioConnectionMode('arrow-flight-sql')">
{{ t("connection.dremioArrowFlightSqlMode") }}
</Button>
<Button size="sm" :variant="dremioConnectionMode === 'legacy' ? 'default' : 'outline'" @click="applyDremioConnectionMode('legacy')">
{{ t("connection.dremioLegacyJdbcMode") }}
</Button>
</div>
</div>
<div v-if="activeJdbcProductProfile && activeJdbcProductProfile.modes.length > 1" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mode") }}</Label>
<div class="col-span-3 flex gap-2">
<Button v-for="mode in activeJdbcProductProfile.modes" :key="mode.id" type="button" size="sm" :variant="jdbcProductConnectionMode === mode.id ? 'default' : 'outline'" @click="applyJdbcProductConnectionMode(mode.id)">
{{ t(mode.labelKey) }}
</Button>
</div>
</div>
<div v-if="activeJdbcProductProfile && activeJdbcProductMode" class="grid grid-cols-4 items-start gap-4">
<span />
<div class="col-span-3 space-y-1 text-xs text-muted-foreground">
<p>{{ t(activeJdbcProductMode.hintKey) }}</p>
<p>
{{ t(activeJdbcProductProfile.driverManagerHintPrefixKey) }}<a class="underline cursor-pointer text-primary hover:text-primary/80" @click="emit('openDriverStore', agentDriverFocus)">{{ t("toolbar.driverManager") }}</a
>{{ t(activeJdbcProductProfile.driverManagerHintSuffixKey) }}
</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.jdbcUrl") }}</Label>
<Input v-model="form.connection_string" class="col-span-3" :placeholder="t('connection.jdbcUrlPlaceholder')" @blur="syncJdbcProfileModeFromUrl" />
</div>
<div v-if="isJdbcxConnection" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.jdbcxExtensions") }}</Label>
<div class="col-span-3 flex items-start justify-between gap-4 rounded-md border px-3 py-2" :class="jdbcxHighPrivilegeExtensionsAllowed ? 'border-amber-500/60 bg-amber-500/10' : 'bg-muted/20'">
<div class="space-y-1">
<div class="text-sm font-medium">{{ t("connection.jdbcxHighPrivilegeExtensions") }}</div>
<p class="text-xs text-muted-foreground">{{ t("connection.jdbcxHighPrivilegeExtensionsWarning") }}</p>
</div>
<Switch v-model="jdbcxHighPrivilegeExtensionsAllowed" class="mt-0.5 shrink-0" />
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" :placeholder="jdbcUsernamePlaceholder" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.password") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-1.5 text-sm">
<label class="flex items-center gap-2">
<input v-model="form.save_password" type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :aria-label="t('connection.savePassword')" />
<span class="whitespace-nowrap">{{ t("connection.savePassword") }}</span>
</label>
<HelpTooltip :label="t('connection.savePassword')">
{{ form.save_password ? t("connection.savePasswordHint") : t("connection.savePasswordSessionHint") }}
</HelpTooltip>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.jdbcDriverPaths") }}</Label>
<div class="col-span-3 space-y-2">
<Select v-if="jdbcDriverSelectItems.length > 0" :model-value="selectedJdbcDriverPath" @update:model-value="onJdbcDriverSelect">
<SelectTrigger>
<SelectValue :placeholder="t('connection.jdbcDriverSelectPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="driver in jdbcDriverSelectItems" :key="driver.id" :value="driver.id">
{{ driver.label }}
</SelectItem>
</SelectContent>
</Select>
<div class="flex items-center justify-between gap-3 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex min-w-0 items-center gap-2">
<div class="truncate text-xs font-medium">{{ t("connection.jdbcManualClasspath") }}</div>
<Badge variant="outline" class="h-5 shrink-0 rounded-full px-2 text-[10px] font-medium">
{{ t("connection.jdbcManualClasspathCount", { count: jdbcManualClasspathCount }) }}
</Badge>
</div>
<Switch v-model="jdbcManualClasspathOpen" />
</div>
<div v-if="jdbcManualClasspathOpen" class="flex items-start gap-1">
<textarea
v-model="jdbcDriverPathsInput"
class="flex min-h-12 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t('connection.jdbcDriverPathsPlaceholder')"
/>
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button type="button" variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseJdbcDriverPaths">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.jdbcDriverBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.jdbcDriverClass") }}</Label>
<Input v-model="form.jdbc_driver_class" class="col-span-3" :placeholder="t('connection.jdbcDriverClassPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<div class="col-span-3 space-y-2">
<p v-if="!isJdbcProductConnection" class="text-xs text-muted-foreground">
{{ t("connection.jdbcPluginHint") }}
</p>
<div class="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" @click="openJdbcDriverManager">
<FolderOpen class="h-3.5 w-3.5" />
{{ t("toolbar.driverManager") }}
</Button>
<Button type="button" variant="outline" size="sm" @click="openExternalUrl(activeJdbcProductProfile?.docsUrl || 'https://dbxio.com')">
<ExternalLink class="h-3.5 w-3.5" />
{{ activeJdbcProductProfile ? t(activeJdbcProductProfile.docsLabelKey) : t("connection.jdbcDocs") }}
</Button>
</div>
</div>
</div>
</template>
<!-- Local database files: file path only -->
<template v-else-if="usesLocalFilePathInput">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.filePath") }}</Label>
<div class="col-span-3 space-y-1">
<div class="flex items-center gap-1">
<Input v-model="form.host" class="flex-1" :placeholder="sqliteUsesSsh ? t('connection.sqliteRemotePathPlaceholder') : filePathPlaceholder" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="sqliteUsesSsh" @click="browseDbFilePath">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.sshKeyPathBrowse") }}</TooltipContent>
</Tooltip>
<Tooltip v-if="isDesktop && form.db_type === 'duckdb'">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="createDuckDbFilePath">
<FilePlus2 class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.createDuckDbFile") }}</TooltipContent>
</Tooltip>
<Tooltip v-if="isDesktop && form.db_type === 'sqlite'">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="sqliteUsesSsh" @click="createSqliteFilePath">
<FilePlus2 class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.createSqliteFile") }}</TooltipContent>
</Tooltip>
</div>
<p v-if="sqliteUsesSsh" class="text-xs text-muted-foreground">
{{ t("connection.sqliteRemotePathHint") }}
</p>
<p v-if="sqliteUsesSsh" class="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs leading-5 text-amber-700 dark:text-amber-400">
{{ t("connection.sqliteSshCipherUnsupportedHint") }}
</p>
<p v-else-if="supportsMemoryDatabasePath" class="text-xs text-muted-foreground">
{{ t("connection.memoryDatabasePathHint") }}
</p>
</div>
</div>
<div v-if="form.db_type === 'sqlite' && sqliteUsesSsh" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.sqliteWorkerPlacement") }}</Label>
<div class="col-span-3 space-y-2">
<div class="flex min-h-9 flex-wrap items-center gap-x-5 gap-y-2">
<Tooltip v-for="option in sqliteWorkerPlacementOptions" :key="option.value" :delay-duration="0">
<TooltipTrigger as-child>
<label class="flex cursor-pointer items-center gap-1.5 text-sm">
<input type="radio" name="sqlite-worker-placement" class="h-3.5 w-3.5 accent-primary" :value="option.value" v-model="sqliteWorkerPlacement" />
<span>{{ t(option.labelKey) }}</span>
<Badge v-if="option.recommended" class="h-4 rounded-full px-1.5 text-[10px] leading-none">{{ t("connection.sqliteWorkerPlacementDefault") }}</Badge>
</label>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-[18rem] flex-col items-start gap-1 py-2 text-left">
<div class="flex items-center gap-1.5 font-medium">
<span>{{ t(option.labelKey) }}</span>
<Badge v-if="option.recommended" class="h-4 rounded-full px-1.5 text-[10px] leading-none">{{ t("connection.sqliteWorkerPlacementDefault") }}</Badge>
</div>
<p class="text-[11px] leading-relaxed text-background-solid/80">{{ t(option.hintKey) }}</p>
</TooltipContent>
</Tooltip>
</div>
<Input v-if="sqliteWorkerPlacement !== 'session'" v-model="sqliteWorkerPath" :placeholder="sqliteWorkerPlacement === 'persist' ? t('connection.sqliteWorkerPersistPathPlaceholder') : t('connection.sqliteWorkerPreplacedPathPlaceholder')" />
<p v-if="shouldShowSqliteSshWorkerInstallHint" class="text-xs text-muted-foreground">
{{ t("connection.driverInstallHintPrefix") }}<a class="underline cursor-pointer text-primary hover:text-primary/80" @click="emit('openDriverStore', agentDriverFocus)">{{ t("toolbar.driverManager") }}</a
>{{ t("connection.driverInstallHintSuffix") }}
</p>
</div>
</div>
<div v-if="form.db_type === 'sqlite' && !sqliteUsesSsh" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.sqliteCipherKey") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" :placeholder="t('connection.sqliteCipherKeyPlaceholder')" />
</div>
<div v-if="form.db_type === 'sqlite' && !sqliteUsesSsh" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.sqliteExtensions") }}</Label>
<div class="col-span-3 space-y-1">
<div class="flex items-start gap-1">
<textarea
v-model="sqliteExtensionPaths"
class="flex min-h-[76px] flex-1 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t('connection.sqliteExtensionsPlaceholder')"
spellcheck="false"
/>
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseSqliteExtensionPath">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.sqliteExtensionBrowse") }}</TooltipContent>
</Tooltip>
</div>
<p class="text-xs text-muted-foreground">
{{ t("connection.sqliteExtensionsHint") }}
</p>
</div>
</div>
<div v-if="form.db_type === 'duckdb'" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.initScript") }}</Label>
<div class="col-span-3 space-y-1">
<textarea
v-model="form.init_script"
class="flex min-h-[76px] w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t('connection.initScriptPlaceholder')"
spellcheck="false"
/>
<p class="text-xs text-muted-foreground">
{{ t("connection.initScriptHint") }}
</p>
</div>
</div>
<template v-if="form.db_type === 'h2' || form.db_type === 'access'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}{{ form.db_type === "access" ? t("connection.optionalSuffix") : "" }}</Label>
<Input v-model="form.username" class="col-span-3" :placeholder="form.db_type === 'access' ? '' : 'sa'" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.password") }}{{ form.db_type === "access" ? t("connection.optionalSuffix") : "" }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
</template>
</template>
<!-- Message Queue: admin URL and auth -->
<template v-else-if="form.db_type === 'mq'">
<template v-if="mqSystemKind === 'kafka'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqKafkaConnectionSource") }}</Label>
<Select v-model="mqKafkaConnectionSource">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in mqKafkaConnectionSourceOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="mqKafkaConnectionSource === 'bootstrap'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqBootstrapServers") }}</Label>
<Input v-model="mqKafkaBootstrapServers" class="col-span-3" :placeholder="t('connection.mqBootstrapServersPlaceholder')" />
</div>
<div v-else class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqKafkaZooKeeperServers") }}</Label>
<Input v-model="mqKafkaZooKeeperServers" class="col-span-3" :placeholder="t('connection.mqKafkaZooKeeperServersPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqSecurity") }}</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-if="mqSystemKind === 'rocketmq'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.rocketmqNamesrvAddr") }}</Label>
<Input v-model="mqRocketmqNamesrvAddr" class="col-span-3" :placeholder="t('connection.rocketmqNamesrvAddrPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.rocketmqClusterName") }}</Label>
<Input v-model="mqRocketmqClusterName" class="col-span-3" :placeholder="t('connection.rocketmqClusterNamePlaceholder')" />
</div>
</template>
<template v-else-if="mqSystemKind === 'rabbitmq'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqRabbitmqAddresses") }}</Label>
<Input v-model="mqRabbitmqAddresses" class="col-span-3" :placeholder="t('connection.mqRabbitmqAddressesPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqVirtualHost") }}</Label>
<Input v-model="mqRabbitmqVirtualHost" class="col-span-3" :placeholder="t('connection.mqVirtualHostPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqRabbitmqAdminUrl") }}</Label>
<div class="col-span-3 space-y-1">
<Input v-model="mqAdminUrl" :placeholder="t('connection.mqRabbitmqAdminUrlPlaceholder')" />
<p class="text-xs text-muted-foreground">
{{ t("connection.mqRabbitmqAdminUrlHint") }}
</p>
</div>
</div>
</template>
<template v-else>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqAdminUrl") }}</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">{{ t("connection.mqAuth") }}</Label>
<div class="col-span-3 flex flex-wrap gap-2">
<Button size="sm" :variant="mqAuthKind === 'none' ? 'default' : 'outline'" @click="mqAuthKind = 'none'">{{ t("connection.mqAuthNone") }}</Button>
<Button v-if="mqSystemKind === 'pulsar'" size="sm" :variant="mqAuthKind === 'token' ? 'default' : 'outline'" @click="mqAuthKind = 'token'">{{ t("connection.mqAuthToken") }}</Button>
<Button size="sm" :variant="mqAuthKind === 'basic' ? 'default' : 'outline'" @click="mqAuthKind = 'basic'">{{ mqSystemKind === "rocketmq" ? t("connection.rocketmqAclAuth") : t("connection.mqAuthBasic") }}</Button>
<Button v-if="mqSystemKind === 'kafka'" size="sm" :variant="mqAuthKind === 'kerberos' ? 'default' : 'outline'" @click="mqAuthKind = 'kerberos'">{{ t("connection.mqAuthKerberos") }}</Button>
<Button v-if="mqSystemKind === 'pulsar'" size="sm" :variant="mqAuthKind === 'apiKey' ? 'default' : 'outline'" @click="mqAuthKind = 'apiKey'">{{ t("connection.mqAuthApiKey") }}</Button>
<Button v-if="mqSystemKind === 'pulsar'" size="sm" :variant="mqAuthKind === 'oauth2' ? 'default' : 'outline'" @click="mqAuthKind = 'oauth2'">{{ t("connection.mqAuthOauth2") }}</Button>
</div>
</div>
<template v-if="mqAuthKind === 'token'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqToken") }}</Label>
<PasswordInput v-model="mqToken" class="col-span-3" />
</div>
</template>
<template v-else-if="mqAuthKind === 'basic'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ mqSystemKind === "rocketmq" ? t("connection.rocketmqAccessKey") : t("connection.user") }}</Label>
<Input v-model="mqBasicUsername" class="col-span-3" :placeholder="mqSystemKind === 'rabbitmq' ? t('connection.mqRabbitmqUsernamePlaceholder') : ''" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ mqSystemKind === "rocketmq" ? t("connection.rocketmqSecretKey") : t("connection.password") }}</Label>
<PasswordInput v-model="mqBasicPassword" class="col-span-3" :placeholder="mqSystemKind === 'rabbitmq' ? t('connection.mqRabbitmqPasswordPlaceholder') : ''" />
</div>
<div v-if="mqSystemKind === 'kafka'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqSaslMechanism") }}</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="mqSystemKind === 'kafka' && mqAuthKind === 'kerberos'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.kafkaKerberosPrincipal") }}</Label>
<Input v-model="mqKafkaKerberosPrincipal" class="col-span-3" placeholder="user@EXAMPLE.COM" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.kafkaKerberosKeytab") }}</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="mqKafkaKerberosKeytabPath" class="flex-1" :placeholder="t('connection.kafkaKerberosKeytabPlaceholder')" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseKafkaKerberosFile('keytab')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.kafkaKerberosKeytabBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.kafkaKerberosServiceName") }}</Label>
<Input v-model="mqKafkaKerberosServiceName" class="col-span-3" placeholder="kafka" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.kafkaKerberosKrb5Conf") }}</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="mqKafkaKrb5ConfPath" class="flex-1" :placeholder="t('connection.kafkaKerberosKrb5ConfPlaceholder')" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseKafkaKerberosFile('krb5')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.kafkaKerberosKrb5ConfBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<div></div>
<div class="col-span-3 space-y-1 text-xs leading-5 text-muted-foreground">
<p>{{ t("connection.kafkaKerberosPathHint") }}</p>
<p>{{ t("connection.kafkaKerberosAuthHint") }}</p>
</div>
</div>
</template>
<template v-else-if="mqAuthKind === 'apiKey'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqApiKeyHeader") }}</Label>
<Input v-model="mqApiKeyHeader" class="col-span-3" placeholder="Authorization" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqApiKeyValue") }}</Label>
<PasswordInput v-model="mqApiKeyValue" class="col-span-3" />
</div>
</template>
<template v-else-if="mqAuthKind === 'oauth2'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqOauthIssuerUrl") }}</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="connectionLabelClass">{{ t("connection.mqOauthClientId") }}</Label>
<Input v-model="mqOauthClientId" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqOauthClientSecret") }}</Label>
<PasswordInput v-model="mqOauthClientSecret" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqOauthAudience") }}</Label>
<Input v-model="mqOauthAudience" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqOauthScope") }}</Label>
<Input v-model="mqOauthScope" class="col-span-3" />
</div>
</template>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.mqTls") }}</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">{{ t("connection.mqTlsSkipVerify") }}</span>
</label>
</div>
<div v-if="mqSystemKind === 'pulsar'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqPinnedVersion") }}</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 v-if="mqSystemKind === 'pulsar'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqTokenSigning") }}</Label>
<Select v-model="mqTokenSigningMode">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{{ t("connection.mqTokenSigningNone") }}</SelectItem>
<SelectItem value="hs256">HS256 SECRET</SelectItem>
<SelectItem value="rs256">RS256 PRIVATE</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="mqSystemKind !== 'kafka' && mqTokenSigningMode !== 'none'" class="grid grid-cols-4 items-start gap-4">
<Label class="pt-2 text-right">{{ t("connection.mqTokenSigningKey") }}</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' ? t('connection.mqTokenSigningKeyPlaceholderHs256') : t('connection.mqTokenSigningKeyPlaceholderRs256')"
/>
</div>
<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">{{ t("connection.mqTokenSigningHint") }}</p>
</div>
</template>
<!-- Nacos: profile-aware endpoint, namespace and auth -->
<template v-else-if="form.db_type === 'nacos'">
<section data-nacos-profile-selector class="overflow-hidden rounded-lg border bg-muted/10">
<div class="border-b px-4 py-3">
<div class="text-sm font-medium">{{ t("nacos.nacosConnectionPlan") }}</div>
<p class="mt-0.5 text-xs leading-5 text-muted-foreground">{{ t("nacos.nacosConnectionPlanDescription") }}</p>
</div>
<div class="grid grid-cols-3 gap-2 p-3">
<button
v-for="profile in NACOS_CONNECTION_PROFILES"
:key="profile.value"
type="button"
class="min-w-0 rounded-md border px-3 py-2.5 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="nacosConnectionProfile === profile.value ? 'border-primary bg-primary/5 shadow-sm' : 'border-border bg-background'"
:aria-pressed="nacosConnectionProfile === profile.value"
@click="selectNacosConnectionProfile(profile.value)"
>
<span class="block truncate text-sm font-medium">{{ profile.title }}</span>
</button>
</div>
</section>
<section data-nacos-endpoint-section class="rounded-lg border p-4">
<div class="grid gap-4">
<div v-if="nacosImplementation === 'nacos' && nacosVersionMode === 'v3'" class="grid gap-1.5">
<Label>{{ t("nacos.nacosApiPlane") }}</Label>
<div class="grid grid-cols-2 gap-1 rounded-md border bg-muted/20 p-1">
<button
v-for="plane in ['admin', 'console'] as NacosApiPlane[]"
:key="plane"
type="button"
class="min-w-0 rounded px-3 py-2 text-left transition-colors"
:class="nacosApiPlane === plane ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="nacosApiPlane === plane"
@click="nacosApiPlane = plane"
>
<span class="block text-sm font-medium">{{ t(`nacos.nacosApiPlane${plane === "admin" ? "Admin" : "Console"}`) }}</span>
<span class="mt-0.5 block text-xs leading-4">{{ t(`nacos.nacosApiPlane${plane === "admin" ? "Admin" : "Console"}Hint`) }}</span>
</button>
</div>
</div>
<div class="grid gap-1.5">
<Label>{{ t("nacos.nacosServiceAddress") }}</Label>
<Input v-model="nacosServerAddr" :placeholder="nacosPrimaryAddressPlaceholder" />
<p class="text-xs leading-5 text-muted-foreground">
<template>{{ nacosServiceAddressHint }}</template>
</p>
</div>
<p v-if="nacosV3AdminEndpointWarning" class="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs leading-5 text-amber-700 dark:text-amber-400">
{{ nacosV3AdminEndpointWarning }}
</p>
</div>
</section>
<section data-nacos-access-section class="rounded-lg border p-4">
<div class="mb-4">
<div class="text-sm font-medium">{{ t("nacos.nacosAuth") }}</div>
<p class="mt-0.5 text-xs text-muted-foreground">{{ t("nacos.nacosAuthHint") }}</p>
</div>
<div class="grid max-w-md gap-1.5">
<div class="grid gap-1.5">
<div class="flex h-9 items-center gap-1 rounded-md border bg-muted/20 p-0.5">
<Button type="button" size="sm" class="h-8 flex-1" :variant="nacosAuthKind === 'none' ? 'default' : 'ghost'" @click="nacosAuthKind = 'none'">{{ t("connection.nacosAuthNone") }}</Button>
<Button type="button" size="sm" class="h-8 flex-1" :variant="nacosAuthKind === 'usernamePassword' ? 'default' : 'ghost'" @click="nacosAuthKind = 'usernamePassword'">{{ t("nacos.nacosUsernamePassword") }}</Button>
</div>
</div>
</div>
<div v-if="nacosAuthKind === 'usernamePassword'" class="mt-4 grid gap-4 sm:grid-cols-2">
<div class="grid gap-1.5">
<Label>{{ t("connection.user") }}</Label>
<Input v-model="nacosUsername" placeholder="nacos" />
</div>
<div class="grid gap-1.5">
<Label>{{ t("connection.password") }}</Label>
<PasswordInput v-model="nacosPassword" />
</div>
</div>
<p v-if="isNacosV3AdminPlane && nacosAuthKind === 'usernamePassword'" class="mt-4 border-t pt-4 text-xs leading-5 text-muted-foreground">
{{ t("nacos.nacosV3AdminNamespaceScopeHint") }}
</p>
<p v-else-if="nacosImplementation === 'rnacos' && nacosAuthKind === 'usernamePassword'" class="mt-4 border-t pt-4 text-xs leading-5 text-muted-foreground">
{{ t("nacos.rnacosNamespaceAccessScopeHint") }}
</p>
</section>
<section data-nacos-advanced-hint class="flex items-start gap-3 rounded-lg border border-dashed bg-muted/20 px-4 py-3">
<CircleHelp class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div class="min-w-0 flex-1">
<div class="text-sm font-medium">{{ t("nacos.nacosAdvancedHint") }}</div>
<p class="mt-0.5 text-xs leading-5 text-muted-foreground">{{ t("nacos.nacosAdvancedHintDescription") }}</p>
</div>
<Button type="button" variant="outline" size="sm" class="shrink-0" @click="configTab = 'advanced'">{{ t("nacos.nacosGoAdvanced") }}</Button>
</section>
</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">
<Label :class="connectionLabelSmallClass">{{ t("connection.mode") }}</Label>
<div class="col-span-3 flex gap-2">
<Button size="sm" :variant="form.redis_connection_mode === 'standalone' ? 'default' : 'outline'" @click="form.redis_connection_mode = 'standalone'">
{{ t("connection.redisStandaloneMode") }}
</Button>
<Button size="sm" :variant="form.redis_connection_mode === 'sentinel' ? 'default' : 'outline'" @click="form.redis_connection_mode = 'sentinel'">
{{ t("connection.redisSentinelMode") }}
</Button>
<Button size="sm" :variant="form.redis_connection_mode === 'cluster' ? 'default' : 'outline'" @click="form.redis_connection_mode = 'cluster'">
{{ t("connection.redisClusterMode") }}
</Button>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ form.redis_connection_mode === "sentinel" ? t("connection.redisFirstSentinel") : form.redis_connection_mode === "cluster" ? t("connection.redisFirstClusterNode") : t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" />
</div>
<template v-if="form.redis_connection_mode === 'sentinel'">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.redisSentinelNodes") }}</Label>
<textarea
v-model="form.redis_sentinel_nodes"
class="col-span-3 flex min-h-[76px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="sentinel-1:26379&#10;sentinel-2:26379"
spellcheck="false"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.redisSentinelMaster") }}</Label>
<Input v-model="form.redis_sentinel_master" class="col-span-3" placeholder="mymaster" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.redisSentinelUser") }}</Label>
<Input v-model="form.redis_sentinel_username" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.redisSentinelPassword") }}</Label>
<PasswordInput v-model="form.redis_sentinel_password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.redisSentinelTls") }}</Label>
<label class="col-span-3 inline-flex items-center gap-2">
<input type="checkbox" v-model="form.redis_sentinel_tls" class="mr-0" />
<span class="text-xs text-muted-foreground">{{ t("connection.redisSentinelTlsHint") }}</span>
</label>
</div>
</template>
<template v-else-if="form.redis_connection_mode === 'cluster'">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.redisClusterNodes") }}</Label>
<textarea
v-model="form.redis_cluster_nodes"
class="col-span-3 flex min-h-[76px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="redis-1:6379&#10;redis-2:6379"
spellcheck="false"
/>
</div>
</template>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" placeholder="default" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.password") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" :placeholder="t('connection.databasePlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.redisKeySeparator") }}</Label>
<Input v-model="form.redis_key_separator" class="col-span-3 h-8 text-xs" placeholder=":" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.redisKeyTemplates") }}</Label>
<div class="col-span-3 space-y-1">
<textarea
v-model="redisKeyTemplatesText"
class="flex min-h-[76px] w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t('connection.redisKeyTemplatesPlaceholder')"
spellcheck="false"
/>
<p class="text-xs text-muted-foreground">{{ t("connection.redisKeyTemplatesHint") }}</p>
</div>
</div>
</template>
<!-- Consul KV: HTTP endpoint, ACL token and scope -->
<template v-else-if="form.db_type === 'consul'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.consulAddress") }}</Label>
<Input v-model="consulServerAddr" class="col-span-3" placeholder="http://127.0.0.1:8500" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.consulToken") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" :placeholder="t('connection.consulTokenPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.consulDatacenter") }}</Label>
<Input v-model="consulDatacenter" class="col-span-3" placeholder="dc1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.consulNamespace") }}</Label>
<Input v-model="consulNamespace" class="col-span-3" placeholder="default" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.consulPartition") }}</Label>
<Input v-model="consulPartition" class="col-span-3" placeholder="default" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.consulAgentTargetNode") }}</Label>
<Input v-model="consulAgentTargetNode" class="col-span-3" placeholder="consul-client-1" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.consulAgentTargetAddress") }}</Label>
<div class="col-span-3 space-y-1">
<Input v-model="consulAgentTargetAddress" placeholder="127.0.0.1" />
<p class="text-xs text-muted-foreground">{{ t("connection.consulAgentTargetHint") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.consulConsistency") }}</Label>
<Select v-model="consulConsistency">
<SelectTrigger class="col-span-3 h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="default">{{ t("connection.consulConsistencyDefault") }}</SelectItem>
<SelectItem value="stale">{{ t("connection.consulConsistencyStale") }}</SelectItem>
<SelectItem value="consistent">{{ t("connection.consulConsistencyConsistent") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.consulTlsSkipVerify") }}</Label>
<div class="col-span-3 flex items-center gap-2">
<Switch v-model="consulTlsSkipVerify" />
<span class="text-xs text-muted-foreground">{{ t("connection.consulTlsSkipVerifyHint") }}</span>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.consulMeshFeatures") }}</Label>
<div class="col-span-3 flex items-start justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<div class="text-sm font-medium">{{ t("connection.consulMeshVisible") }}</div>
<p class="text-xs text-muted-foreground">{{ t("connection.consulMeshVisibleHint") }}</p>
</div>
<Switch v-model="consulMeshVisible" class="mt-0.5 shrink-0" />
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.consulOperatorWrites") }}</Label>
<div class="col-span-3 grid gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs">
<label class="flex items-center gap-2"><input v-model="consulOperatorVisible" type="checkbox" />{{ t("connection.consulOperatorVisible") }}</label>
<label class="flex items-center gap-2"><input v-model="consulOperatorSnapshotRestoreEnabled" type="checkbox" />{{ t("connection.consulOperatorSnapshotRestore") }}</label>
<label class="flex items-center gap-2"><input v-model="consulOperatorAutopilotWriteEnabled" type="checkbox" />{{ t("connection.consulOperatorAutopilot") }}</label>
<label class="flex items-center gap-2"><input v-model="consulOperatorRaftWriteEnabled" type="checkbox" />{{ t("connection.consulOperatorRaft") }}</label>
<label class="flex items-center gap-2"><input v-model="consulOperatorKeyringWriteEnabled" type="checkbox" />{{ t("connection.consulOperatorKeyring") }}</label>
<label class="flex items-center gap-2"><input v-model="consulOperatorLicenseWriteEnabled" type="checkbox" />{{ t("connection.consulOperatorLicense") }}</label>
</div>
</div>
</template>
<!-- etcd: endpoints, user, password, TLS -->
<template v-else-if="form.db_type === 'etcd'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">API</Label>
<div class="col-span-3 space-y-1.5">
<div class="flex flex-wrap gap-2">
<Button size="sm" :variant="!form.driver_profile || form.driver_profile === 'etcd' ? 'default' : 'outline'" @click="switchEtcdApiVersion('etcd')">v3 (etcd 3.x)</Button>
<Button size="sm" :variant="form.driver_profile === 'etcd-v2' ? 'default' : 'outline'" @click="switchEtcdApiVersion('etcd-v2')">v2 (etcd 2.x)</Button>
</div>
<p v-if="form.driver_profile === 'etcd-v2'" class="text-xs text-muted-foreground">
{{ t("connection.etcdV2ApiHint") }}
</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.etcdEndpoints") }}</Label>
<div class="col-span-3 space-y-1">
<textarea
v-model="etcdEndpointsLines"
class="flex min-h-[76px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="http://127.0.0.1:2379&#10;https://etcd-2:2379"
spellcheck="false"
/>
<p class="text-xs text-muted-foreground">
{{ t("connection.etcdEndpointsHint") }}
</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.password") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
</template>
<!-- ZooKeeper: host, connect string, user, password -->
<template v-else-if="form.db_type === 'zookeeper'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-2" placeholder="127.0.0.1" />
<Input v-model.number="form.port" type="number" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.zookeeperConnectString") }}</Label>
<div class="col-span-3 space-y-1">
<textarea
v-model="zookeeperConnectString"
class="flex min-h-[76px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="127.0.0.1:2181&#10;zk-2:2181"
spellcheck="false"
/>
<p class="text-xs text-muted-foreground">
{{ t("connection.zookeeperConnectStringHint") }}
</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.zookeeperAuthMethod") }}</Label>
<Select v-model="zookeeperAuthScheme">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="digest">{{ t("connection.zookeeperAuthDigest") }}</SelectItem>
<SelectItem value="sasl_digest">{{ t("connection.zookeeperAuthSaslDigest") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.password") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
</template>
<!-- DynamoDB: endpoint, region, AWS credentials -->
<template v-else-if="form.db_type === 'dynamodb'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.dynamodbEndpoint") }}</Label>
<Input v-model="form.host" class="col-span-2" placeholder="dynamodb.us-east-1.amazonaws.com" />
<Input v-model.number="form.port" type="number" class="col-span-1" min="1" max="65535" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex items-center gap-2 text-sm">
<input v-model="form.ssl" type="checkbox" />
<span>{{ t("connection.sslEnable") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.dynamodbRegion") }}</Label>
<Input v-model="form.database" class="col-span-3" placeholder="us-east-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.dynamodbAccessKeyId") }}</Label>
<Input v-model="form.username" class="col-span-3" autocomplete="username" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.dynamodbSecretAccessKey") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.dynamodbSessionToken") }}</Label>
<PasswordInput v-model="form.connection_string" class="col-span-3" :placeholder="t('connection.dynamodbSessionTokenPlaceholder')" />
</div>
</template>
<!-- MongoDB: URL or form -->
<template v-else-if="form.db_type === 'mongodb'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.driverMode") }}</Label>
<div class="col-span-3 flex items-center gap-2">
<Button size="sm" :variant="mongoDriverMode === 'legacy' ? 'outline' : 'default'" @click="mongoDriverMode = 'auto'">{{ t("connection.mongoDriverAuto") }}</Button>
<Button size="sm" :variant="mongoDriverMode === 'legacy' ? 'default' : 'outline'" :disabled="mongoUsesOidc" @click="mongoDriverMode = 'legacy'">{{ t("connection.mongoDriverLegacy") }}</Button>
<Tooltip>
<TooltipTrigger as-child>
<CircleHelp class="h-3.5 w-3.5 cursor-help text-muted-foreground hover:text-foreground" />
</TooltipTrigger>
<TooltipContent side="top" align="center" class="max-w-[320px] text-xs leading-relaxed">
{{ t("connection.mongoLegacyHint") }}
</TooltipContent>
</Tooltip>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.mode") }}</Label>
<div class="col-span-3 flex gap-2">
<Button size="sm" :variant="mongoUseUrl ? 'outline' : 'default'" @click="mongoUseUrl = false">{{ t("connection.modeForm") }}</Button>
<Button size="sm" :variant="mongoUseUrl ? 'default' : 'outline'" @click="mongoUseUrl = true">URL</Button>
</div>
</div>
<template v-if="mongoUseUrl">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">URL</Label>
<textarea
v-model="form.connection_string"
class="col-span-3 flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="mongodb+srv://user:pass@cluster.mongodb.net/mydb"
/>
<p v-if="mongoUsesOidc" class="col-start-2 col-span-3 text-xs text-muted-foreground">
{{ t("connection.oidcBrowserAuthHint") }}
</p>
</div>
</template>
<template v-else>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex items-center gap-2 text-sm">
<input type="checkbox" v-model="form.ssl" class="mr-0" />
<span>{{ t("connection.sslEnable") }}</span>
</label>
</div>
<template v-if="form.ssl">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mongoTlsAllowInvalidCertificates") }}</Label>
<label class="col-span-3 flex items-start gap-2 cursor-pointer">
<input v-model="mongoTlsAllowInvalidCertificates" type="checkbox" class="mr-0 mt-0.5" />
<span class="text-xs leading-5 text-muted-foreground">
{{ t("connection.mongoTlsAllowInvalidCertificatesHint") }}
</span>
</label>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mongoRetryWrites") }}</Label>
<label class="col-span-3 flex items-start gap-2 cursor-pointer">
<input v-model="mongoRetryWrites" type="checkbox" class="mr-0 mt-0.5" />
<span class="text-xs leading-5 text-muted-foreground">
{{ t("connection.mongoRetryWritesHint") }}
</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.caCertPath") }}</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="form.ca_cert_path" class="flex-1" :placeholder="t('connection.caCertPathPlaceholder')" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseCaCertPath">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.caCertPathBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
</template>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" />
</div>
<div v-if="mongoAuthMechanism !== 'MONGODB-OIDC'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.password") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.defaultDatabase") }}</Label>
<Input v-model="form.database" class="col-span-3" :placeholder="t('connection.databasePlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.authDatabase") }}</Label>
<Input v-model="mongoAuthDatabase" class="col-span-3" :disabled="mongoAuthMechanism === 'MONGODB-OIDC'" :placeholder="mongoAuthMechanism === 'MONGODB-OIDC' ? '$external' : t('connection.authDatabasePlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.authMechanism") }}</Label>
<Select v-model="mongoAuthMechanism">
<SelectTrigger class="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{{ t("connection.authMechanismDefault") }}</SelectItem>
<SelectItem value="SCRAM-SHA-1">SCRAM-SHA-1</SelectItem>
<SelectItem value="SCRAM-SHA-256">SCRAM-SHA-256</SelectItem>
<SelectItem value="MONGODB-OIDC">MONGODB-OIDC</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="mongoAuthMechanism === 'MONGODB-OIDC'" class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs text-muted-foreground">
{{ t("connection.oidcBrowserAuthHint") }}
</p>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.urlParams") }}</Label>
<Input v-model="form.url_params" class="col-span-3" placeholder="replicaSet=rs0&authSource=admin" />
</div>
</template>
</template>
<!-- MQTT: broker address, client ID, protocol version, auth, TLS -->
<template v-else-if="form.db_type === 'mqtt'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttBrokerAddress") }}</Label>
<Input v-model="mqttHost" class="col-span-3" :placeholder="t('connection.mqttBrokerAddressPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttBrokerPort") }}</Label>
<Input v-model.number="mqttPort" type="number" class="col-span-3 w-24" min="1" max="65535" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttClientId") }}</Label>
<Input v-model="mqttClientId" class="col-span-3" :placeholder="t('connection.mqttClientIdPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttProtocolVersion") }}</Label>
<Select v-model="mqttProtocolVersion">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="v5">MQTT 5.0</SelectItem>
<SelectItem value="v4">MQTT 3.1.1</SelectItem>
<SelectItem value="v3">MQTT 3.1</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttTransport") }}</Label>
<div class="col-span-3 flex gap-2">
<Button size="sm" :variant="mqttTransportMode === 'tcp' ? 'default' : 'outline'" @click="mqttTransportMode = 'tcp'">{{ t("connection.mqttTransportTcp") }}</Button>
<Button size="sm" :variant="mqttTransportMode === 'websocket' ? 'default' : 'outline'" @click="mqttTransportMode = 'websocket'">{{ t("connection.mqttTransportWebSocket") }}</Button>
</div>
</div>
<div v-if="mqttTransportMode === 'websocket'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttWsPath") }}</Label>
<Input v-model="mqttWsPath" class="col-span-3" :placeholder="t('connection.mqttWsPathPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqAuth") }}</Label>
<div class="col-span-3 flex gap-2">
<Button size="sm" :variant="mqttAuthKind === 'none' ? 'default' : 'outline'" @click="mqttAuthKind = 'none'">{{ t("connection.mqAuthNone") }}</Button>
<Button size="sm" :variant="mqttAuthKind === 'password' ? 'default' : 'outline'" @click="mqttAuthKind = 'password'">{{ t("connection.mqAuthBasic") }}</Button>
<Button size="sm" :variant="mqttAuthKind === 'certificate' ? 'default' : 'outline'" @click="mqttAuthKind = 'certificate'">{{ t("connection.mqttAuthCertificate") }}</Button>
</div>
</div>
<template v-if="mqttAuthKind === 'password'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttUsername") }}</Label>
<Input v-model="mqttUsername" class="col-span-3" :placeholder="t('connection.mqttUsernamePlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttPassword") }}</Label>
<Input v-model="mqttPassword" type="password" class="col-span-3" :placeholder="t('connection.mqttPasswordPlaceholder')" />
</div>
</template>
<template v-else-if="mqttAuthKind === 'certificate'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttCaCertPath") }}</Label>
<Input v-model="mqttCaCertPath" class="col-span-3" placeholder="/path/to/ca.pem" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttClientCertPath") }}</Label>
<Input v-model="mqttClientCertPath" class="col-span-3" placeholder="/path/to/client.crt" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttClientKeyPath") }}</Label>
<Input v-model="mqttClientKeyPath" class="col-span-3" placeholder="/path/to/client.key" />
</div>
</template>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttTls") }}</Label>
<div class="col-span-3 flex items-center gap-2">
<Switch v-model="mqttTls" />
<Label class="text-sm" :class="mqttTls ? '' : 'text-muted-foreground'">TLS</Label>
<template v-if="mqttTls">
<Switch v-model="mqttTlsSkipVerify" class="ml-4" />
<Label class="text-sm" :class="mqttTlsSkipVerify ? '' : 'text-muted-foreground'">{{ t("connection.mqttTlsSkipVerify") }}</Label>
</template>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttKeepAlive") }}</Label>
<Input v-model.number="mqttKeepAliveSecs" type="number" class="col-span-3 w-32" min="1" max="65535" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttConnectTimeout") }}</Label>
<Input v-model.number="mqttConnectTimeoutSecs" type="number" class="col-span-3 w-32" min="1" max="300" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqttMaxPacketSize") }}</Label>
<Input v-model.number="mqttMaxPacketSizeBytes" type="number" class="col-span-3 w-40" min="1024" max="268435455" />
</div>
</template>
<template v-else-if="form.db_type === 'victoriametrics'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex items-center gap-2 text-sm">
<input type="checkbox" v-model="form.ssl" />
<span>{{ t("connection.sslEnable") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" autocomplete="username" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.password") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.victoriametricsApiPath") }}</Label>
<div class="col-span-3 space-y-1.5">
<Input v-model="victoriaMetricsApiPath" placeholder="/prometheus" />
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.victoriametricsApiPathHint") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.victoriametricsLookback") }}</Label>
<div class="col-span-3 space-y-1.5">
<Input v-model="victoriaMetricsLookback" class="w-28" placeholder="1h" />
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.victoriametricsLookbackHint") }}</p>
</div>
</div>
</template>
<!-- InfluxDB: v1 username/password or v2 token/org/bucket -->
<template v-else-if="form.db_type === 'influxdb'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.version") }}</Label>
<Select v-model="influxDbVersion">
<SelectTrigger class="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">InfluxDB 1.x</SelectItem>
<SelectItem value="2">InfluxDB 2.x</SelectItem>
<SelectItem value="3">InfluxDB 3.x</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex items-center gap-2 text-sm">
<input type="checkbox" v-model="form.ssl" class="mr-0" />
<span>{{ t("connection.sslEnable") }}</span>
</label>
</div>
<template v-if="influxDbVersion === '2'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">Organization</Label>
<Input v-model="influxDbOrg" class="col-span-3" placeholder="my-org" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">Bucket</Label>
<Input v-model="form.database" class="col-span-3" placeholder="my-bucket" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">Token</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
</template>
<template v-else-if="influxDbVersion === '3'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.database") }}</Label>
<Input v-model="form.database" class="col-span-3" placeholder="my-database" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">Token</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
</template>
<template v-else>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.password") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.database") }}</Label>
<Input v-model="form.database" class="col-span-3" :placeholder="t('connection.databasePlaceholder')" />
</div>
</template>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.urlParams") }}</Label>
<Input v-model="form.url_params" class="col-span-3" :placeholder="influxDbVersion === '2' ? 'precision=ns' : influxDbVersion === '3' ? '' : 'epoch=ms'" />
</div>
</template>
<!-- Turso: simplified form (URL + Token) -->
<template v-else-if="form.db_type === 'turso'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-3" :placeholder="t('connection.tursoHostPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.tursoHostHint") }}</p>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">Auth Token</Label>
<PasswordInput v-model="form.password" class="col-span-3" placeholder="eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9..." />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.tursoTokenHint") }} <code class="px-1 py-0.5 rounded bg-muted text-xs">turso db tokens create &lt;database-name&gt;</code></p>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.urlParams") }}</Label>
<Input v-model="form.url_params" class="col-span-3" :placeholder="t('connection.tursoUrlParamsPlaceholder')" />
</div>
</template>
<template v-else-if="form.db_type === 'cloudflare-d1'">
<CloudflareD1ConnectionFields v-model:account-id="form.host" v-model:database-id="form.database" v-model:api-token="form.password" />
</template>
<!-- Salesforce: instance URL + access token (SOQL) -->
<template v-else-if="form.db_type === 'salesforce'">
<!-- Instance URL (always visible so the user can confirm the
target org; OAuth/device flows overwrite it after a
successful authorize) -->
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceInstanceUrl") }}</Label>
<div class="col-span-3 space-y-1.5">
<div class="flex gap-2">
<Input v-model="form.host" class="flex-1" :placeholder="t('connection.salesforceInstanceUrlPlaceholder')" />
<Input v-model.number="form.port" type="number" class="w-24 shrink-0" />
</div>
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceInstanceUrlHint") }}</p>
</div>
</div>
<!-- Authentication mode picker -->
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceAuthMode") }}</Label>
<div class="col-span-3 grid h-8 grid-cols-4 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
<button
type="button"
class="h-7 rounded-sm px-2 text-xs transition-colors"
:class="salesforceAuthMode === 'token' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="salesforceAuthMode === 'token'"
:disabled="salesforceOauthRunning"
@click="salesforceSetAuthMode('token')"
>
{{ t("connection.salesforceAuthModeToken") }}
</button>
<button
type="button"
class="h-7 rounded-sm px-2 text-xs transition-colors"
:class="salesforceAuthMode === 'oauth' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50'"
:aria-pressed="salesforceAuthMode === 'oauth'"
:disabled="salesforceOauthRunning || !isDesktop"
:title="!isDesktop ? t('connection.salesforceAuthModeOauthDesktopOnly') : undefined"
@click="salesforceSetAuthMode('oauth')"
>
{{ t("connection.salesforceAuthModeOauth") }}
</button>
<button
type="button"
class="h-7 rounded-sm px-2 text-xs transition-colors"
:class="salesforceAuthMode === 'device' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="salesforceAuthMode === 'device'"
:disabled="salesforceOauthRunning"
@click="salesforceSetAuthMode('device')"
>
{{ t("connection.salesforceAuthModeDevice") }}
</button>
<button
type="button"
class="h-7 rounded-sm px-2 text-xs transition-colors"
:class="salesforceAuthMode === 'password' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="salesforceAuthMode === 'password'"
:disabled="salesforceOauthRunning"
@click="salesforceSetAuthMode('password')"
>
{{ t("connection.salesforceAuthModePassword") }}
</button>
</div>
</div>
<div v-if="!isDesktop && salesforceAuthMode === 'oauth'" class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceAuthModeOauthDesktopOnly") }}</p>
</div>
<!-- Manual access token -->
<template v-if="salesforceAuthMode === 'token'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.salesforceAccessToken") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" :placeholder="t('connection.salesforceAccessTokenPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceAccessTokenHint") }}</p>
</div>
</template>
<!-- Shared OAuth / device-code fields -->
<template v-else>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceEnvironment") }}</Label>
<Select v-model="salesforceEnvironment" :disabled="salesforceOauthRunning">
<SelectTrigger class="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="production">{{ t("connection.salesforceEnvironmentProduction") }}</SelectItem>
<SelectItem value="sandbox">{{ t("connection.salesforceEnvironmentSandbox") }}</SelectItem>
<SelectItem value="custom">{{ t("connection.salesforceEnvironmentCustom") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="salesforceEnvironment === 'custom'" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelClass">{{ t("connection.salesforceLoginUrl") }}</Label>
<div class="col-span-3 space-y-1.5">
<Input v-model="salesforceLoginUrl" :placeholder="t('connection.salesforceLoginUrlPlaceholder')" :disabled="salesforceOauthRunning" />
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceLoginUrlHint") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">
{{ t("connection.salesforceClientId") }}
<span class="ml-1 text-destructive">*</span>
</Label>
<div class="col-span-3 space-y-1.5">
<Input v-model="salesforceClientId" :placeholder="t('connection.salesforceClientIdPlaceholder')" :disabled="salesforceOauthRunning" autocomplete="off" />
<!-- The callback URL only matters for the browser redirect flow; the
other two modes never see one, so keep it out of their way. -->
<p v-if="salesforceAuthMode === 'oauth'" class="text-xs leading-5 text-muted-foreground">
{{ t("connection.salesforceClientIdHint", { callbackUrl: SALESFORCE_OAUTH_CALLBACK_URL }) }}
</p>
<p v-else class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceClientIdHintShared") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.salesforceClientSecret") }}</Label>
<div class="col-span-3 space-y-1.5">
<PasswordInput v-model="salesforceClientSecret" :placeholder="t('connection.salesforceClientSecretPlaceholder')" :disabled="salesforceOauthRunning" autocomplete="new-password" />
</div>
</div>
<!-- Username & password (ROPC) credentials -->
<template v-if="salesforceAuthMode === 'password'">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelClass">{{ t("connection.salesforceUsernameLabel") }}</Label>
<div class="col-span-3 space-y-1.5">
<Input v-model="salesforceUsername" :placeholder="t('connection.salesforceUsernamePlaceholder')" :disabled="salesforceOauthRunning" autocomplete="off" />
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceUsernameHint") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelClass">{{ t("connection.salesforceUserPasswordLabel") }}</Label>
<div class="col-span-3 space-y-1.5">
<PasswordInput v-model="salesforceUserPassword" :placeholder="t('connection.salesforceUserPasswordPlaceholder')" :disabled="salesforceOauthRunning" autocomplete="new-password" />
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceUserPasswordHint") }}</p>
</div>
</div>
</template>
<!-- Previously-authorized banner (edit flow) -->
<div v-if="salesforceOauthPreviouslyAuthorized && !salesforceOauthRunning && salesforceDevicePhase !== 'polling'" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-300">
<ShieldCheck class="h-4 w-4 shrink-0" />
<span>{{ t("connection.salesforceOauthAuthorized") }}</span>
</div>
</div>
<!-- Browser OAuth action -->
<template v-if="salesforceAuthMode === 'oauth'">
<div class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-2">
<Button type="button" :disabled="salesforceOauthRunning || !salesforceClientId.trim() || !isDesktop" @click="salesforceStartBrowserAuthorize">
<Loader2 v-if="salesforceOauthRunning" class="mr-2 h-4 w-4 animate-spin" />
{{ salesforceOauthRunning ? t("connection.salesforceOauthAuthorizing") : salesforceOauthPreviouslyAuthorized ? t("connection.salesforceOauthReauthorize") : t("connection.salesforceOauthAuthorize") }}
</Button>
</div>
</div>
<div v-if="salesforceOauthRunning" class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceOauthAuthorizingHint") }}</p>
</div>
</template>
<!-- Username-password sign in -->
<template v-if="salesforceAuthMode === 'password'">
<div class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-2">
<Button type="button" :disabled="salesforceOauthRunning || !salesforceClientId.trim() || !salesforceUsername.trim() || !salesforceUserPassword" @click="salesforceStartPasswordLogin">
<Loader2 v-if="salesforceOauthRunning" class="mr-2 h-4 w-4 animate-spin" />
{{ salesforceOauthRunning ? t("connection.salesforceOauthAuthorizing") : salesforceOauthPreviouslyAuthorized ? t("connection.salesforcePasswordSignInAgain") : t("connection.salesforcePasswordSignIn") }}
</Button>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforcePasswordRopcNote") }}</p>
</div>
</template>
<!-- Device-code flow -->
<template v-if="salesforceAuthMode === 'device'">
<div v-if="salesforceDevicePhase === 'idle' || salesforceDevicePhase === 'error'" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-2">
<Button type="button" :disabled="salesforceOauthRunning || !salesforceClientId.trim()" @click="salesforceDeviceRequestCode">
<Loader2 v-if="salesforceOauthRunning" class="mr-2 h-4 w-4 animate-spin" />
{{ t("connection.salesforceDeviceGetCode") }}
</Button>
</div>
</div>
<div v-if="salesforceDevicePhase === 'idle' || salesforceDevicePhase === 'error'" class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceDeviceFlowNote") }}</p>
</div>
<template v-if="salesforceDevicePhase === 'polling'">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceDeviceUserCodeLabel") }}</Label>
<div class="col-span-3 space-y-1.5">
<div class="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2">
<span class="font-mono text-lg font-semibold tracking-widest">{{ salesforceDeviceUserCode }}</span>
<Button type="button" variant="ghost" size="icon" class="h-7 w-7" @click="salesforceCopyUserCode">
<Check v-if="salesforceDeviceUserCodeCopied" class="h-4 w-4 text-emerald-600" />
<Copy v-else class="h-4 w-4" />
</Button>
</div>
<div v-if="salesforceDeviceVerificationUri" class="flex items-center gap-2 text-xs">
<a class="text-primary underline underline-offset-2 hover:no-underline" href="#" @click.prevent="salesforceOpenVerificationPage">{{ t("connection.salesforceDeviceOpenVerification") }}</a>
<code class="truncate rounded bg-muted px-1 py-0.5 text-[11px]">{{ salesforceDeviceVerificationUri }}</code>
<Button type="button" variant="ghost" size="icon" class="h-6 w-6" @click="salesforceCopyVerificationUri">
<Check v-if="salesforceDeviceVerificationCopied" class="h-3.5 w-3.5 text-emerald-600" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-3 text-xs text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span>{{ t("connection.salesforceDevicePolling") }}</span>
<span v-if="salesforceDeviceSecondsLeft > 0">· {{ t("connection.salesforceDeviceSecondsLeft", { seconds: salesforceDeviceSecondsLeft }) }}</span>
<Button type="button" variant="outline" size="sm" class="ml-auto" @click="salesforceDeviceCancel">
{{ t("connection.salesforceDeviceCancel") }}
</Button>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceDevicePollingHint") }}</p>
</div>
</template>
<div v-else-if="salesforceDevicePhase === 'expired'" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs">
<span class="text-amber-700 dark:text-amber-300">{{ t("connection.salesforceDeviceExpired") }}</span>
<Button type="button" size="sm" variant="outline" class="self-start" @click="salesforceDeviceRestart">
{{ t("connection.salesforceDeviceRestart") }}
</Button>
</div>
</div>
<div v-else-if="salesforceDevicePhase === 'denied'" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex flex-col gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
<span class="text-destructive">{{ salesforceDeviceError || t("connection.salesforceDeviceDenied") }}</span>
<Button type="button" size="sm" variant="outline" class="self-start" @click="salesforceDeviceRestart">
{{ t("connection.salesforceDeviceRestart") }}
</Button>
</div>
</div>
</template>
</template>
<!-- Shared status / error strip (oauth success + any error) -->
<div v-if="salesforceOauthSuccess && (salesforceAuthMode === 'oauth' || salesforceAuthMode === 'password' || salesforceDevicePhase === 'success')" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-300">
<ShieldCheck class="h-4 w-4 shrink-0" />
<span>{{ salesforceOauthSuccess }}</span>
</div>
</div>
<div v-if="salesforceOauthError && (salesforceAuthMode === 'oauth' || salesforceAuthMode === 'password')" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{{ salesforceOauthError }}
</div>
</div>
<div v-if="salesforceDeviceError && salesforceAuthMode === 'device' && salesforceDevicePhase !== 'polling' && salesforceDevicePhase !== 'denied'" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{{ salesforceDeviceError }}
</div>
</div>
</template>
<!-- MySQL / PostgreSQL: host, port, user, password, database -->
<template v-else>
<div v-if="form.db_type === 'ignite' || form.db_type === 'ignite3'" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.igniteVersion") }}</Label>
<div class="col-span-3 grid grid-cols-2 gap-2">
<button
v-for="profile in IGNITE_CONNECTION_PROFILES"
:key="profile.value"
type="button"
class="min-w-0 rounded-md border px-3 py-2.5 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="form.db_type === profile.value ? 'border-primary bg-primary/5 shadow-sm' : 'border-border bg-background'"
:aria-pressed="form.db_type === profile.value"
@click="selectIgniteConnectionProfile(profile.value)"
>
<span class="block truncate text-sm font-medium">{{ profile.title }}</span>
</button>
</div>
</div>
<div v-if="form.db_type === 'elasticsearch'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.mode") }}</Label>
<div class="col-span-3 grid h-8 grid-cols-2 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
<button
type="button"
class="h-7 rounded-sm px-3 text-sm transition-colors"
:class="elasticsearchConnectionMode === 'direct' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="elasticsearchConnectionMode === 'direct'"
@click="switchElasticsearchConnectionMode('direct')"
>
{{ t("connection.elasticsearchDirectMode") }}
</button>
<button
type="button"
class="h-7 rounded-sm px-3 text-sm transition-colors"
:class="elasticsearchConnectionMode === 'kibana' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="elasticsearchConnectionMode === 'kibana'"
@click="switchElasticsearchConnectionMode('kibana')"
>
{{ t("connection.elasticsearchKibanaProxyMode") }}
</button>
</div>
</div>
<div v-if="form.db_type === 'sqlserver'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.driverMode") }}</Label>
<div class="col-span-3 flex items-center gap-2">
<Button size="sm" :variant="sqlServerDriverMode === 'legacy' ? 'outline' : 'default'" :disabled="agentInstallRunning" @click="setSqlServerDriverMode('auto')">{{ t("connection.mongoDriverAuto") }}</Button>
<Button size="sm" :variant="sqlServerDriverMode === 'legacy' ? 'default' : 'outline'" :disabled="agentInstallRunning" @click="setSqlServerDriverMode('legacy')">{{ t("connection.mongoDriverLegacy") }}</Button>
<Tooltip>
<TooltipTrigger as-child>
<CircleHelp class="h-3.5 w-3.5 cursor-help text-muted-foreground hover:text-foreground" />
</TooltipTrigger>
<TooltipContent side="top" align="center" class="max-w-[320px] whitespace-pre-line text-xs leading-relaxed">
{{ t("connection.sqlServerLegacyCompatibilityModeHint") }}
</TooltipContent>
</Tooltip>
</div>
</div>
<div v-if="form.db_type === 'dameng'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.driverMode") }}</Label>
<div class="col-span-3 flex items-center gap-2">
<Button size="sm" :variant="damengDriverMode === 'builtin' ? 'default' : 'outline'" @click="setDamengDriverMode('builtin')">
{{ t("connection.damengBuiltinDriver") }}
</Button>
<Button size="sm" :variant="damengDriverMode === 'custom' ? 'default' : 'outline'" @click="setDamengDriverMode('custom')">
{{ t("connection.damengCustomDriver") }}
</Button>
<Tooltip>
<TooltipTrigger as-child>
<CircleHelp class="h-3.5 w-3.5 cursor-help text-muted-foreground hover:text-foreground" />
</TooltipTrigger>
<TooltipContent side="top" align="center" class="max-w-[320px] text-xs leading-relaxed">
{{ t("connection.damengDriverModeHint") }}
</TooltipContent>
</Tooltip>
</div>
</div>
<!-- GaussDB: multi-host dynamic list -->
<template v-if="form.db_type === 'gaussdb'">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.host") }}</Label>
<div class="col-span-3 space-y-2">
<div v-for="(entry, idx) in gaussdbHostEntries" :key="idx" class="flex items-start gap-2">
<Input v-model="entry.host" class="flex-1 min-w-0 break-all" placeholder="127.0.0.1" />
<Input v-model.number="entry.port" type="number" class="w-24 shrink-0" />
<Button type="button" variant="outline" size="icon" class="h-8 w-8 shrink-0" :disabled="gaussdbHostEntries.length <= 1" @click="removeGaussdbHostEntry(idx)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
<Button type="button" variant="outline" size="sm" class="mt-1" @click="addGaussdbHostEntry">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("connection.addHost") }}
</Button>
</div>
</div>
</template>
<div v-else-if="form.db_type === 'meilisearch'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.host") }}</Label>
<Input v-model="meilisearchHostInput" class="col-span-3" :placeholder="connectionUrlPlaceholder" @input="resetTestState" />
</div>
<div v-else-if="form.db_type !== 'oracle' || form.oracle_connection_type !== 'tns'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ form.db_type === "elasticsearch" && elasticsearchConnectionMode === "kibana" ? t("connection.elasticsearchKibanaHost") : t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" @input="markSqlServerPortExplicit" />
</div>
<div v-if="isDamengCustomDriver" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.jdbcUrl") }}</Label>
<Input v-model="form.connection_string" class="col-span-3" :placeholder="defaultDamengJdbcUrl(form)" />
</div>
<div v-if="form.db_type === 'elasticsearch' && elasticsearchConnectionMode === 'kibana'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.elasticsearchKibanaBasePath") }}</Label>
<Input v-model="elasticsearchKibanaBasePath" class="col-span-3" placeholder="/kibana/s/default" @input="resetTestState" />
</div>
<div v-if="form.db_type === 'elasticsearch'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.elasticsearchConnectivityCheckPath") }}</Label>
<Input v-model="elasticsearchConnectivityCheckPath" class="col-span-3" :placeholder="t('connection.elasticsearchConnectivityCheckPathPlaceholder')" @input="resetTestState" />
</div>
<div v-if="form.db_type === 'elasticsearch'" class="grid grid-cols-4 items-center gap-4">
<div class="flex items-center gap-1">
<Label :class="connectionLabelSmallClass">{{ t("connection.elasticsearchIndexGroupingPattern") }}</Label>
<Tooltip>
<TooltipTrigger as-child>
<CircleHelp class="h-3.5 w-3.5 cursor-help text-muted-foreground hover:text-foreground" />
</TooltipTrigger>
<TooltipContent side="top" align="center" class="max-w-[280px] text-xs leading-relaxed">
{{ t("connection.elasticsearchIndexGroupingPatternHint") }}
</TooltipContent>
</Tooltip>
</div>
<Input v-model="elasticsearchIndexGroupingPattern" class="col-span-3" :placeholder="t('connection.elasticsearchIndexGroupingPatternPlaceholder')" @input="resetTestState" />
</div>
<div v-if="form.driver_profile === 'gbase8s'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.gbaseServer") }}</Label>
<div class="col-span-3 space-y-1">
<Input v-model="form.gbase_server" placeholder="gbase01" />
<p class="text-xs text-muted-foreground">{{ t("connection.gbaseServerHint") }}</p>
</div>
</div>
<div v-if="form.db_type === 'informix'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.informixServer") }}</Label>
<Input v-model="form.informix_server" class="col-span-3" placeholder="ol_informix1170" />
</div>
<div v-if="form.db_type !== 'meilisearch' && form.db_type !== 'spanner'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" />
</div>
<div v-if="form.db_type !== 'spanner'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ form.db_type === "meilisearch" ? t("connection.mqAuthApiKey") : t("connection.password") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
<div v-if="form.db_type !== 'spanner'" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3 flex items-center gap-1.5 text-sm">
<label class="flex items-center gap-2">
<input v-model="form.save_password" type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :aria-label="t('connection.savePassword')" />
<span class="whitespace-nowrap">{{ t("connection.savePassword") }}</span>
</label>
<HelpTooltip :label="t('connection.savePassword')">
{{ form.save_password ? t("connection.savePasswordHint") : t("connection.savePasswordSessionHint") }}
</HelpTooltip>
</div>
</div>
<div v-if="form.db_type !== 'hbase' && form.db_type !== 'meilisearch' && form.db_type !== 'solr' && form.db_type !== 'spanner'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ databaseLabel }}</Label>
<Input v-model="form.database" class="col-span-3" :placeholder="databasePlaceholder" />
</div>
<!-- Cloud Spanner: project/instance/database resource path instead of user/password/database -->
<template v-if="form.db_type === 'spanner'">
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.spannerHostHint") }}</p>
</div>
<SpannerConnectionFields v-model:database="form.database" @change="resetTestState" />
</template>
<div v-if="form.db_type === 'oracle' && form.oracle_connection_type === 'tns'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">TNS_ADMIN</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="oracleTnsAdminPath" class="flex-1" :placeholder="t('connection.oracleTnsAdminPlaceholder')" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseOracleTnsNamesFile">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.oracleTnsAdminBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
<div v-if="form.db_type === 'oracle' && form.oracle_connection_type === 'tns'" class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.oracleTnsPathHint") }}</p>
</div>
<template v-if="form.db_type === 'hive' || form.db_type === 'kyuubi' || form.db_type === 'impala'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.hiveAuthMode") }}</Label>
<div class="col-span-3 grid h-8 grid-cols-2 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
<button type="button" class="h-7 rounded-sm px-3 text-sm transition-colors" :class="hiveAuthMode === 'none' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" :aria-pressed="hiveAuthMode === 'none'" @click="hiveAuthMode = 'none'">
{{ t("connection.hiveAuthNone") }}
</button>
<button
type="button"
class="h-7 rounded-sm px-3 text-sm transition-colors"
:class="hiveAuthMode === 'kerberos' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="hiveAuthMode === 'kerberos'"
@click="hiveAuthMode = 'kerberos'"
>
Kerberos
</button>
</div>
</div>
<template v-if="hiveAuthMode === 'kerberos'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.hivePrincipal") }}</Label>
<Input v-model="hivePrincipal" class="col-span-3" :placeholder="form.db_type === 'impala' ? 'impala/_HOST@EXAMPLE.COM' : 'hive/_HOST@EXAMPLE.COM'" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">krb5.conf</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="hiveKrb5ConfPath" class="flex-1" placeholder="/etc/krb5.conf" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseHiveKerberosFile('krb5')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.hiveKrb5ConfBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">JAAS</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="hiveJaasConfigPath" class="flex-1" placeholder="/etc/hive-jaas.conf" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseHiveKerberosFile('jaas')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.hiveJaasConfigBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.hiveTicketCache") }}</Label>
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="hiveUseSubjectCredsOnlyFalse" class="mr-0" />
<span class="text-xs text-muted-foreground">{{ t("connection.hiveTicketCacheFallback") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.hiveJvmOptions") }}</Label>
<textarea
v-model="hiveExtraJavaOptions"
class="col-span-3 min-h-16 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t('connection.hiveJvmOptionsPlaceholder')"
/>
</div>
</template>
</template>
<div v-if="form.db_type === 'oracle'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.mode") }}</Label>
<div class="col-span-3 grid h-8 grid-cols-3 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
<button
type="button"
class="h-7 rounded-sm px-3 text-sm transition-colors"
:class="form.oracle_connection_type === 'service_name' || !form.oracle_connection_type ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="form.oracle_connection_type === 'service_name' || !form.oracle_connection_type"
@click="form.oracle_connection_type = 'service_name'"
>
{{ t("connection.serviceNameOnly") }}
</button>
<button
type="button"
class="h-7 rounded-sm px-3 text-sm transition-colors"
:class="form.oracle_connection_type === 'sid' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="form.oracle_connection_type === 'sid'"
@click="form.oracle_connection_type = 'sid'"
>
SID
</button>
<button
type="button"
class="h-7 rounded-sm px-3 text-sm transition-colors"
:class="form.oracle_connection_type === 'tns' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="form.oracle_connection_type === 'tns'"
@click="form.oracle_connection_type = 'tns'"
>
TNS
</button>
</div>
</div>
<div v-if="shouldShowAgentDriverInstallHint" class="grid grid-cols-4 items-center gap-4">
<span />
<p class="col-span-3 text-xs text-muted-foreground">
{{ t("connection.driverInstallHintPrefix") }}<a class="underline cursor-pointer text-primary hover:text-primary/80" @click="emit('openDriverStore', agentDriverFocus)">{{ t("toolbar.driverManager") }}</a
>{{ t("connection.driverInstallHintSuffix") }}
</p>
</div>
<div v-if="form.db_type === 'oracle'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">SYSDBA</Label>
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.sysdba" class="mr-0" :disabled="isOracleSysUser(form)" />
<span class="text-xs text-muted-foreground">as SYSDBA</span>
</label>
</div>
<div v-if="supportsGenericUrlParams" class="connection-url-params-row grid grid-cols-4 items-start gap-4" :class="{ 'connection-url-params-row--compact': !showGenericUrlParamsHint, 'connection-url-params-row--with-hint': showGenericUrlParamsHint }">
<Label :class="[connectionLabelClass, 'connection-url-params-label']">{{ t("connection.urlParams") }}</Label>
<div class="col-span-3 space-y-1.5">
<Input
v-model="form.url_params"
:placeholder="
form.db_type === 'mysql'
? 'charset=utf8mb4'
: form.db_type === 'doris' || form.db_type === 'starrocks'
? 'sessionVariables=query_timeout=60'
: form.db_type === 'saphana'
? 'databaseName=TENANT_DB'
: form.db_type === 'clickhouse'
? 'secure=true'
: form.db_type === 'bigquery'
? 'OAuthType=0;OAuthServiceAcctEmail=svc@project.iam.gserviceaccount.com;OAuthPvtKeyPath=/path/key.json'
: form.db_type === 'spanner'
? 'credentials=/path/key.json;autocommit=true'
: form.db_type === 'informix'
? 'CLIENT_LOCALE=en_US.utf8;DB_LOCALE=en_US.utf8'
: form.db_type === 'spark'
? 'catalog=paimon_catalog'
: form.db_type === 'cassandra'
? 'localdatacenter=dc1'
: 'sslmode=prefer'
"
/>
<p v-if="showGenericUrlParamsHint" class="text-xs leading-5 text-muted-foreground">
{{ t("connection.localInfilePathHint") }}
</p>
<p v-if="form.db_type === 'mysql'" class="text-xs leading-5 text-muted-foreground">
{{ t("connection.sessionVariablesHint") }}
</p>
</div>
</div>
<div v-if="form.db_type === 'dameng'" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.damengJvmOptions") }}</Label>
<div class="col-span-3 space-y-1.5">
<textarea
v-model="damengJvmOptions"
class="min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t('connection.damengJvmOptionsPlaceholder')"
/>
<p class="text-xs leading-5 text-muted-foreground">
{{ t("connection.damengJvmOptionsHint") }}
</p>
</div>
</div>
<template v-if="supportsNativeAgentJdbcDriverConfig">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.jdbcDriverPaths") }}</Label>
<div class="col-span-3 space-y-2">
<Select v-if="jdbcDriverSelectItems.length > 0" :model-value="selectedJdbcDriverPath" @update:model-value="onJdbcDriverSelect">
<SelectTrigger>
<SelectValue :placeholder="t('connection.jdbcDriverSelectPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="driver in jdbcDriverSelectItems" :key="driver.id" :value="driver.id">
{{ driver.label }}
</SelectItem>
</SelectContent>
</Select>
<div class="flex items-center justify-between gap-3 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex min-w-0 items-center gap-2">
<div class="truncate text-xs font-medium">{{ t("connection.jdbcManualClasspath") }}</div>
<Badge variant="outline" class="h-5 shrink-0 rounded-full px-2 text-[10px] font-medium">
{{ t("connection.jdbcManualClasspathCount", { count: jdbcManualClasspathCount }) }}
</Badge>
</div>
<Switch v-model="jdbcManualClasspathOpen" />
</div>
<div v-if="jdbcManualClasspathOpen" class="flex items-start gap-1">
<textarea
v-model="jdbcDriverPathsInput"
class="flex min-h-12 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t('connection.jdbcDriverPathsPlaceholder')"
/>
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button type="button" variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseJdbcDriverPaths">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.jdbcDriverBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.jdbcDriverClass") }}</Label>
<Input v-model="form.jdbc_driver_class" class="col-span-3" :placeholder="t('connection.jdbcDriverClassPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<div class="col-span-3 space-y-2">
<p class="text-xs text-muted-foreground">
{{ form.db_type === "dameng" ? t("connection.damengCustomDriverHint") : t("connection.jdbcPluginHint") }}
</p>
<div class="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" @click="emit('openDriverStore', { target: 'tab', tab: 'jdbc' })">
<FolderOpen class="h-3.5 w-3.5" />
{{ t("toolbar.driverManager") }}
</Button>
<Button v-if="form.db_type !== 'dameng'" type="button" variant="outline" size="sm" @click="openExternalUrl('https://dbxio.com')">
<ExternalLink class="h-3.5 w-3.5" />
{{ t("connection.jdbcDocs") }}
</Button>
</div>
</div>
</div>
</template>
</template>
<div v-if="visibleDatabaseInfo" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.databaseInfo.title") }}</Label>
<Popover>
<PopoverTrigger as-child>
<button
type="button"
class="col-span-3 flex h-9 min-w-0 items-center gap-2 rounded-md border bg-muted/20 px-2.5 text-left text-xs transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:title="databaseInfoCompactLabel"
:aria-label="t('connection.databaseInfo.open', { database: databaseInfoCompactLabel })"
>
<DatabaseLucide class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="rounded-full bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">{{ databaseInfoStatusLabel }}</span>
<span class="min-w-0 flex-1 truncate text-muted-foreground">{{ databaseInfoCompactLabel }}</span>
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" class="w-[360px] max-w-[calc(100vw-24px)] gap-3 p-3" @click.stop @keydown.stop>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
<DatabaseLucide class="h-4 w-4 shrink-0 text-muted-foreground" />
<div class="min-w-0 text-sm font-medium">{{ t("connection.databaseInfo.title") }}</div>
</div>
<p class="mt-1 text-xs text-muted-foreground">{{ databaseInfoDescription }}</p>
</div>
<Button variant="ghost" size="icon-xs" class="h-7 w-7 shrink-0" :title="t('connection.databaseInfo.copy')" :aria-label="t('connection.databaseInfo.copy')" @click="copyDatabaseInfo">
<Copy class="h-3.5 w-3.5" />
</Button>
</div>
<dl class="mt-3 grid min-w-0 grid-cols-[minmax(7.5rem,auto)_minmax(0,1fr)] gap-x-4 gap-y-2 text-xs">
<template v-for="row in databaseInfoDisplayRows" :key="row.key">
<dt class="text-muted-foreground">{{ row.label }}</dt>
<dd class="min-w-0 break-words text-right font-medium">{{ row.displayValue }}</dd>
</template>
</dl>
</PopoverContent>
</Popover>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.note") }}</Label>
<div class="col-span-3 flex min-w-0 items-start gap-3">
<textarea
ref="noteTextareaRef"
v-model="form.note"
rows="1"
class="min-h-8 min-w-0 flex-1 resize-none overflow-y-hidden rounded-md border border-input bg-transparent px-2.5 py-1 text-base leading-5 transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 md:text-sm"
:placeholder="t('connection.notePlaceholder')"
@input="resizeNoteTextarea"
/>
<div class="mt-1.5 flex shrink-0 items-center gap-2">
<div class="flex items-center gap-1">
<Label for="connection-note-sidebar-visibility" class="text-xs font-normal text-muted-foreground">
{{ t("connection.noteShow") }}
</Label>
<Tooltip>
<TooltipTrigger as-child>
<CircleHelp class="h-3.5 w-3.5 cursor-help text-muted-foreground hover:text-foreground" />
</TooltipTrigger>
<TooltipContent side="top" align="center" class="max-w-[280px] text-xs leading-relaxed">
{{ t("connection.noteShowInSidebar") }}
</TooltipContent>
</Tooltip>
</div>
<Switch id="connection-note-sidebar-visibility" v-model="showConnectionNotesInSidebar" :aria-label="t('connection.noteShowInSidebar')" />
</div>
</div>
</div>
</template>
</div>
</TabsContent>
<TabsContent v-if="supportsTlsToggle" value="tls" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2 pb-6">
<div v-if="!supportsPostgresTlsOptions && !supportsMysqlTlsOptions && form.db_type !== 'consul'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">SSL/TLS</Label>
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="tlsEnabled" class="mr-0" />
<span class="text-xs text-muted-foreground">{{ t("connection.sslEnable") }}</span>
</label>
</div>
<template v-if="form.db_type === 'dameng'">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.damengSslFilesPath") }}</Label>
<div class="col-span-3 space-y-1.5">
<div class="flex items-center gap-1">
<Input v-model="damengSslFilesPath" class="flex-1" :placeholder="t('connection.damengSslFilesPathPlaceholder')" :disabled="!tlsEnabled" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="!tlsEnabled" @click="browseDamengSslFilesPath">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.damengSslFilesPathBrowse") }}</TooltipContent>
</Tooltip>
</div>
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("connection.damengSslHint") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.damengSslKeystorePassword") }}</Label>
<PasswordInput v-model="damengSslKeystorePassword" class="col-span-3" :disabled="!tlsEnabled" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.damengSslProtocol") }}</Label>
<Input v-model="damengSslProtocol" class="col-span-3" :placeholder="t('connection.damengSslProtocolPlaceholder')" :disabled="!tlsEnabled" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-[11px] leading-4 text-muted-foreground">{{ t("connection.damengSslVerificationHint") }}</p>
</div>
</template>
<template v-if="form.db_type === 'cassandra'">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">
<span class="inline-flex items-center justify-end gap-1">
<ShieldCheck class="h-3.5 w-3.5" />
{{ t("connection.cassandraTruststore") }}
</span>
</Label>
<div class="col-span-3 grid gap-2">
<div class="flex items-center gap-1">
<Input v-model="cassandraTls.truststore_path" class="flex-1" :placeholder="t('connection.cassandraTruststorePlaceholder')" :disabled="!tlsEnabled" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="!tlsEnabled" @click="browseCassandraStore('truststore')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.cassandraTruststoreBrowse") }}</TooltipContent>
</Tooltip>
</div>
<PasswordInput v-model="cassandraTls.truststore_password" :placeholder="t('connection.cassandraTruststorePassword')" :disabled="!tlsEnabled" />
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("connection.cassandraTruststoreHint") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">
<span class="inline-flex items-center justify-end gap-1">
<KeyRound class="h-3.5 w-3.5" />
{{ t("connection.cassandraKeystore") }}
</span>
</Label>
<div class="col-span-3 grid gap-2">
<div class="flex items-center gap-1">
<Input v-model="cassandraTls.keystore_path" class="flex-1" :placeholder="t('connection.cassandraKeystorePlaceholder')" :disabled="!tlsEnabled" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="!tlsEnabled" @click="browseCassandraStore('keystore')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.cassandraKeystoreBrowse") }}</TooltipContent>
</Tooltip>
</div>
<PasswordInput v-model="cassandraTls.keystore_password" :placeholder="t('connection.cassandraKeystorePassword')" :disabled="!tlsEnabled" />
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("connection.cassandraKeystoreHint") }}</p>
</div>
</div>
</template>
<div v-if="form.db_type === 'redis'" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.redisTlsInsecure") }}</Label>
<label class="col-span-3 flex items-start gap-2 cursor-pointer">
<input type="checkbox" v-model="redisTlsInsecure" class="mr-0 mt-0.5" :disabled="!form.ssl" />
<span class="text-xs leading-5 text-muted-foreground">
{{ t("connection.redisTlsInsecureHint") }}
</span>
</label>
</div>
<template v-if="form.db_type === 'etcd' || form.db_type === 'consul' || form.db_type === 'zookeeper' || form.db_type === 'elasticsearch' || form.db_type === 'easysearch'">
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">
<span class="inline-flex items-center justify-end gap-1">
<ShieldCheck class="h-3.5 w-3.5" />
{{ t("connection.caCertPath") }}
</span>
</Label>
<div class="col-span-3 space-y-2">
<div class="flex items-center gap-1">
<Input v-model="form.ca_cert_path" class="flex-1" :placeholder="t('connection.etcdCaCertPlaceholder')" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseEtcdTlsFile('ca')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.etcdCaCertBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">
<span class="inline-flex items-center justify-end gap-1">
<KeyRound class="h-3.5 w-3.5" />
{{ t("connection.etcdClientAuth") }}
</span>
</Label>
<div class="col-span-3 grid gap-2">
<div class="flex items-center gap-1">
<Input v-model="form.client_cert_path" class="flex-1" :placeholder="t('connection.etcdClientCertPlaceholder')" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseEtcdTlsFile('cert')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.etcdClientCertBrowse") }}</TooltipContent>
</Tooltip>
</div>
<div class="flex items-center gap-1">
<Input v-model="form.client_key_path" class="flex-1" :placeholder="t('connection.etcdClientKeyPlaceholder')" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseEtcdTlsFile('key')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.etcdClientKeyBrowse") }}</TooltipContent>
</Tooltip>
</div>
<p class="text-[11px] leading-4 text-muted-foreground">
{{ t("connection.etcdClientCertHint") }}
</p>
</div>
</div>
</template>
<template v-if="supportsMysqlTlsOptions">
<div v-if="supportsMysqlCleartextPasswordAuth" class="grid grid-cols-4 items-start gap-4">
<Label :class="[connectionLabelSmallPaddedClass, 'min-w-0 break-words']">{{ t("connection.mysqlCleartextPasswordAuth") }}</Label>
<div class="col-span-3 flex min-w-0 items-start justify-between gap-4">
<p class="min-w-0 text-[11px] leading-4 text-muted-foreground break-words">
{{ t("connection.mysqlCleartextPasswordAuthHint") }}
</p>
<Switch v-model="mysqlCleartextPasswordAuth" class="mt-0.5 shrink-0" />
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.mysqlTlsMode") }}</Label>
<Select v-model="mysqlTlsMode">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disabled">{{ t("connection.mysqlTlsModeDisabled") }}</SelectItem>
<SelectItem value="preferred">{{ t("connection.mysqlTlsModePreferred") }}</SelectItem>
<SelectItem value="required">{{ t("connection.mysqlTlsModeRequired") }}</SelectItem>
<SelectItem value="verify_ca">{{ t("connection.mysqlTlsModeVerifyCa") }}</SelectItem>
<SelectItem value="verify_identity">{{ t("connection.mysqlTlsModeVerifyIdentity") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">
<span class="inline-flex items-center justify-end gap-1">
<ShieldCheck class="h-3.5 w-3.5" />
{{ t("connection.caCertPath") }}
</span>
</Label>
<div class="col-span-3 space-y-2">
<div class="flex items-center gap-1">
<Input v-model="form.ca_cert_path" class="flex-1" :placeholder="t('connection.caCertPathPlaceholder')" :disabled="mysqlTlsMode === 'preferred' || mysqlTlsMode === 'disabled'" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="mysqlTlsMode === 'preferred' || mysqlTlsMode === 'disabled'" @click="browseCaCertPath">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.caCertPathBrowse") }}</TooltipContent>
</Tooltip>
</div>
<p class="text-[11px] leading-4 text-muted-foreground">
{{ t("connection.mysqlCaCertHint") }}
</p>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">
<span class="inline-flex items-center justify-end gap-1">
<KeyRound class="h-3.5 w-3.5" />
{{ t("connection.mysqlClientCert") }}
</span>
</Label>
<div class="col-span-3 grid gap-2">
<div class="flex items-center gap-1">
<Input v-model="mysqlClientCertPath" class="flex-1" :placeholder="t('connection.mysqlClientCertPlaceholder')" :disabled="mysqlTlsMode === 'preferred' || mysqlTlsMode === 'disabled'" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="mysqlTlsMode === 'preferred' || mysqlTlsMode === 'disabled'" @click="browseMysqlTlsFile('cert')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.mysqlClientCertBrowse") }}</TooltipContent>
</Tooltip>
</div>
<div class="flex items-center gap-1">
<Input v-model="mysqlClientKeyPath" class="flex-1" :placeholder="t('connection.mysqlClientKeyPlaceholder')" :disabled="mysqlTlsMode === 'preferred' || mysqlTlsMode === 'disabled'" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="mysqlTlsMode === 'preferred' || mysqlTlsMode === 'disabled'" @click="browseMysqlTlsFile('key')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.mysqlClientKeyBrowse") }}</TooltipContent>
</Tooltip>
</div>
<p class="text-[11px] leading-4 text-muted-foreground">
{{ t("connection.mysqlClientCertHint") }}
</p>
</div>
</div>
</template>
<template v-if="supportsPostgresTlsOptions">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.postgresSslMode") }}</Label>
<Select v-model="postgresTlsMode">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disable">{{ t("connection.postgresSslModeDisable") }}</SelectItem>
<SelectItem value="prefer">{{ t("connection.postgresSslModePrefer") }}</SelectItem>
<SelectItem value="require">{{ t("connection.postgresSslModeRequire") }}</SelectItem>
<SelectItem value="verify-ca">{{ t("connection.postgresSslModeVerifyCa") }}</SelectItem>
<SelectItem value="verify-full">{{ t("connection.postgresSslModeVerifyFull") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.postgresLegacyTls") }}</Label>
<div class="col-span-3 flex items-center gap-2">
<Switch v-model="postgresLegacyTls" :disabled="postgresTlsMode === 'disable'" />
<HelpTooltip :label="t('connection.postgresLegacyTlsHint')">
{{ t("connection.postgresLegacyTlsHint") }}
</HelpTooltip>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">
<span class="inline-flex items-center justify-end gap-1">
<ShieldCheck class="h-3.5 w-3.5" />
{{ t("connection.postgresServerCert") }}
</span>
</Label>
<div class="col-span-3 space-y-2">
<div class="flex items-center gap-1">
<Input v-model="postgresRootCertPath" class="flex-1" :placeholder="t('connection.postgresRootCertPlaceholder')" :disabled="postgresTlsMode === 'disable'" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="postgresTlsMode === 'disable'" @click="browsePostgresTlsFile('root')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.postgresRootCertBrowse") }}</TooltipContent>
</Tooltip>
</div>
<p class="text-[11px] leading-4 text-muted-foreground">
{{ t("connection.postgresRootCertHint") }}
</p>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">
<span class="inline-flex items-center justify-end gap-1">
<KeyRound class="h-3.5 w-3.5" />
{{ t("connection.postgresClientCert") }}
</span>
</Label>
<div class="col-span-3 grid gap-2">
<div class="flex items-center gap-1">
<Input v-model="postgresClientCertPath" class="flex-1" :placeholder="t('connection.postgresClientCertPlaceholder')" :disabled="postgresTlsMode === 'disable'" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="postgresTlsMode === 'disable'" @click="browsePostgresTlsFile('cert')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.postgresClientCertBrowse") }}</TooltipContent>
</Tooltip>
</div>
<div class="flex items-center gap-1">
<Input v-model="postgresClientKeyPath" class="flex-1" :placeholder="t('connection.postgresClientKeyPlaceholder')" :disabled="postgresTlsMode === 'disable'" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="postgresTlsMode === 'disable'" @click="browsePostgresTlsFile('key')">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.postgresClientKeyBrowse") }}</TooltipContent>
</Tooltip>
</div>
<p class="text-[11px] leading-4 text-muted-foreground">
{{ t("connection.postgresClientCertHint") }}
</p>
</div>
</div>
</template>
<div v-if="supportsCaCertificatePath" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.caCertPath") }}</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="form.ca_cert_path" class="flex-1" :placeholder="t('connection.caCertPathPlaceholder')" :disabled="!form.ssl" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="!form.ssl" @click="browseCaCertPath">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.caCertPathBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="advanced" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto pt-4 pr-2 pb-6" :class="{ 'connection-form-body--nacos': form.db_type === 'nacos' }">
<div v-if="form.db_type === 'elasticsearch'" class="grid grid-cols-4 items-center gap-4">
<div class="flex items-center gap-1">
<Label :class="connectionLabelSmallClass">{{ t("connection.elasticsearchConnectivityCheckDisabled") }}</Label>
<Tooltip>
<TooltipTrigger as-child>
<CircleHelp class="h-3.5 w-3.5 cursor-help text-muted-foreground hover:text-foreground" />
</TooltipTrigger>
<TooltipContent side="top" align="center" class="max-w-[280px] text-xs leading-relaxed">
{{ t("connection.elasticsearchConnectivityCheckDisabledHint") }}
</TooltipContent>
</Tooltip>
</div>
<div class="col-span-3">
<Switch v-model="elasticsearchConnectivityCheckDisabled" @update:model-value="resetTestState" />
</div>
</div>
<section v-if="form.db_type === 'nacos'" data-nacos-advanced-settings class="overflow-hidden rounded-lg border">
<div class="border-b bg-muted/20 px-4 py-3">
<div class="text-sm font-medium">{{ t("nacos.nacosAdvancedTitle") }}</div>
<p class="mt-0.5 text-xs leading-5 text-muted-foreground">{{ t("nacos.nacosAdvancedDescription") }}</p>
</div>
<div class="grid gap-5 p-4">
<div class="grid gap-1.5">
<div class="flex items-center justify-between gap-3">
<Label>{{ t("connection.nacosContextPath") }}</Label>
<Button v-if="nacosContextPath" type="button" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="nacosContextPath = ''">
{{ t("connection.nacosContextPathRestoreAuto") }}
</Button>
</div>
<Input v-model="nacosContextPath" :placeholder="t('connection.nacosContextPathPlaceholder')" />
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosContextPathHint") }}</p>
</div>
<div class="grid gap-1.5">
<Label>{{ t("connection.nacosPageSize") }}</Label>
<Input v-model.number="nacosPageSize" type="number" min="1" max="500" />
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosPageSizeHint") }}</p>
</div>
<div v-if="isNacosV3AdminPlane" class="grid gap-1.5 border-t pt-4">
<div>
<Label>{{ t("connection.nacosWebConsoleUrl") }}</Label>
<p class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t("connection.nacosWebConsoleUrlHint") }}</p>
</div>
<Input v-model="nacosConsoleUrl" :placeholder="nacosWebConsoleUrlPlaceholder" />
</div>
<div class="grid gap-2 border-t pt-4">
<div class="grid gap-1.5 sm:grid-cols-[minmax(0,1fr)_180px] sm:items-center">
<div>
<Label>{{ t("connection.nacosMetrics") }}</Label>
<p class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosMetricsHint") }}</p>
</div>
<Select v-model="nacosMetricsMode">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{{ t("connection.nacosMetricsAuto") }}</SelectItem>
<SelectItem value="disabled">{{ t("connection.nacosMetricsDisabled") }}</SelectItem>
<SelectItem value="custom">{{ t("connection.nacosMetricsCustom") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="nacosMetricsMode === 'custom'" class="grid gap-1.5">
<Label>{{ t("connection.nacosMetricsUrl") }}</Label>
<Input v-model="nacosMetricsUrl" :aria-invalid="!!nacosMetricsUrlError" :class="{ 'border-destructive focus-visible:ring-destructive': nacosMetricsUrlError }" placeholder="http://127.0.0.1:8848/nacos/actuator/prometheus" />
<p v-if="nacosMetricsUrlError" class="text-xs text-destructive">{{ nacosMetricsUrlError }}</p>
</div>
</div>
<div v-if="nacosImplementation === 'rnacos'" class="grid gap-4 border-t pt-4">
<div class="flex items-start justify-between gap-4">
<div>
<Label>{{ t("nacos.nacosRnacosExtension") }}</Label>
<p class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosRnacosExtensionHint") }}</p>
</div>
<label class="inline-flex shrink-0 items-center gap-2">
<Switch v-model="nacosHistoryEnabled" />
<span class="text-xs text-muted-foreground">{{ t("nacos.nacosEnabled") }}</span>
</label>
</div>
<div class="grid gap-1.5">
<Label>{{ t("connection.nacosRNacosConsoleUrl") }}</Label>
<Input v-model="nacosRNacosConsoleAddr" :placeholder="t('connection.nacosRNacosConsoleUrlPlaceholder')" />
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosRnacosConsoleUrlHint") }}</p>
</div>
<template v-if="nacosRNacosConsoleAddr.trim()">
<div class="grid gap-1.5">
<Label>{{ t("connection.nacosConsoleAuthentication") }}</Label>
<div class="flex items-center gap-1 rounded-md border bg-muted/20 p-0.5">
<Button type="button" size="sm" class="h-8 flex-1" :variant="nacosConsoleAuthKind === 'inherit' ? 'default' : 'ghost'" :disabled="nacosAuthKind === 'none'" @click="nacosConsoleAuthKind = 'inherit'">
{{ t("connection.nacosConsoleAuthInherit") }}
</Button>
<Button type="button" size="sm" class="h-8 flex-1" :variant="nacosConsoleAuthKind === 'usernamePassword' ? 'default' : 'ghost'" @click="nacosConsoleAuthKind = 'usernamePassword'">
{{ t("connection.nacosConsoleAuthSeparate") }}
</Button>
</div>
<p v-if="nacosConsoleAuthKind === 'inherit' && nacosAuthKind === 'none'" class="text-xs text-destructive">{{ t("connection.nacosConsoleAuthPrimaryNone") }}</p>
</div>
<div v-if="nacosConsoleAuthKind === 'usernamePassword'" class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-1.5">
<Label>{{ t("connection.nacosConsoleUser") }}</Label>
<Input v-model="nacosConsoleUsername" />
</div>
<div class="grid gap-1.5">
<Label>{{ t("connection.nacosConsolePassword") }}</Label>
<PasswordInput v-model="nacosConsolePassword" />
</div>
</div>
</template>
</div>
<label class="flex items-start justify-between gap-4 border-t pt-4">
<div>
<div class="text-sm font-medium">{{ t("connection.nacosTls") }}</div>
<p class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosTlsHint") }}</p>
</div>
<span class="inline-flex shrink-0 items-center gap-2">
<input v-model="nacosTlsSkipVerify" type="checkbox" class="mr-0" />
<span class="text-xs text-muted-foreground">{{ t("connection.nacosTlsSkipVerify") }}</span>
</span>
</label>
</div>
</section>
<div v-if="showGaussdbConnectionMode" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.gaussdbConnectionMode") }}</Label>
<div class="col-span-3 grid gap-1">
<Select v-model="gaussdbDriverMode">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="native">{{ t("connection.gaussdbConnectionModeNative") }}</SelectItem>
<SelectItem value="m-jdbc">{{ t("connection.gaussdbConnectionModeMJdbc") }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs leading-5 text-muted-foreground">
{{ isGaussdbMJdbcConnection ? t("connection.gaussdbConnectionModeMJdbcHint") : t("connection.gaussdbConnectionModeNativeHint") }}
</p>
</div>
</div>
<div v-if="isGaussdbMJdbcConnection" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.gaussdbMJdbcDriver") }}</Label>
<div class="col-span-3 space-y-2">
<Select v-if="jdbcDriverSelectItems.length > 0" :model-value="selectedJdbcDriverPath" @update:model-value="onJdbcDriverSelect">
<SelectTrigger class="h-9">
<SelectValue :placeholder="t('connection.jdbcDriverSelectPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="driver in jdbcDriverSelectItems" :key="driver.id" :value="driver.id">
{{ driver.label }}
</SelectItem>
</SelectContent>
</Select>
<div class="flex items-start gap-1">
<textarea
v-model="jdbcDriverPathsInput"
class="flex min-h-12 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t('connection.gaussdbMJdbcDriverPlaceholder')"
/>
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button type="button" variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseJdbcDriverPaths">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.jdbcDriverBrowse") }}</TooltipContent>
</Tooltip>
</div>
<div class="flex items-center justify-between gap-3">
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.gaussdbMJdbcDriverHint") }}</p>
<Button type="button" variant="outline" size="sm" class="shrink-0" @click="openJdbcDriverManager">
<FolderOpen class="h-3.5 w-3.5" />
{{ t("toolbar.driverManager") }}
</Button>
</div>
</div>
</div>
<div v-if="showGaussdbIdentifierQuoteStyle" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.gaussdbIdentifierQuoteStyle") }}</Label>
<div class="col-span-3 grid gap-1">
<Select v-model="gaussdbQuoteStyle">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{{ t("connection.gaussdbIdentifierQuoteAuto") }}</SelectItem>
<SelectItem value="double">{{ t("connection.gaussdbIdentifierQuoteDouble") }}</SelectItem>
<SelectItem value="backtick">{{ t("connection.gaussdbIdentifierQuoteBacktick") }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.gaussdbIdentifierQuoteHint") }}</p>
</div>
</div>
<div v-if="isGaussdbMJdbcConnection" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.gaussdbTargetServerType") }}</Label>
<div class="col-span-3 grid gap-1">
<Select v-model="gaussdbTargetServerTypeComputed">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="master">{{ t("connection.gaussdbTargetServerTypeMaster") }}</SelectItem>
<SelectItem value="slave">{{ t("connection.gaussdbTargetServerTypeSlave") }}</SelectItem>
<SelectItem value="any">{{ t("connection.gaussdbTargetServerTypeAny") }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs leading-5 text-muted-foreground">
{{ t("connection.gaussdbTargetServerTypeHint") }}
</p>
</div>
</div>
<div v-if="showGaussdbConnectionMode" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.gaussdbCountQueryDop") }}</Label>
<div class="col-span-3 grid gap-1">
<Select v-model="gaussdbCountQueryDopComputed">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="1">1 ({{ t("common.disabled") }})</SelectItem>
<SelectItem :value="2">2</SelectItem>
<SelectItem :value="4">4</SelectItem>
<SelectItem :value="8">8</SelectItem>
<SelectItem :value="16">16</SelectItem>
</SelectContent>
</Select>
<p class="text-xs leading-5 text-muted-foreground">
{{ t("connection.gaussdbCountQueryDopHint") }}
</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.connectTimeout") }}</Label>
<div class="col-span-3 grid grid-cols-2 gap-2">
<div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2 gap-y-1 rounded border px-2 py-1.5 sm:flex" :class="form.connect_timeout_inherit === true ? 'border-primary/60 bg-background' : 'border-border bg-muted/30 text-muted-foreground'">
<input id="connect-timeout-global" v-model="form.connect_timeout_inherit" type="radio" name="connect-timeout-scope" :value="true" class="h-3.5 w-3.5 shrink-0 accent-primary" />
<div class="flex min-w-0 flex-1 items-center gap-1">
<label for="connect-timeout-global" class="min-w-0 cursor-pointer truncate text-xs" :title="t('connection.useGlobalQueryTimeout')">{{ t("connection.useGlobalQueryTimeout") }}</label>
<HelpTooltip :label="t('connection.globalConnectTimeoutHint')" content-class="max-w-[280px]">
{{ t("connection.globalConnectTimeoutHint") }}
</HelpTooltip>
</div>
<Input
v-model.number="editGlobalConnectTimeoutSecs"
type="number"
min="1"
:max="MAX_CONNECT_TIMEOUT_SECS"
step="1"
class="col-span-2 h-7 w-full shrink-0 sm:col-span-1 sm:w-20"
:disabled="form.connect_timeout_inherit !== true"
@input="clampConnectTimeoutInput($event, 'global')"
/>
</div>
<div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2 gap-y-1 rounded border px-2 py-1.5 sm:flex" :class="form.connect_timeout_inherit !== true ? 'border-primary/60 bg-background' : 'border-border bg-muted/30 text-muted-foreground'">
<input id="connect-timeout-connection" v-model="form.connect_timeout_inherit" type="radio" name="connect-timeout-scope" :value="false" class="h-3.5 w-3.5 shrink-0 accent-primary" />
<label for="connect-timeout-connection" class="min-w-0 flex-1 cursor-pointer truncate text-xs" :title="t('connection.useConnectionQueryTimeout')">{{ t("connection.useConnectionQueryTimeout") }}</label>
<Input
v-model.number="form.connect_timeout_secs"
type="number"
min="1"
:max="MAX_CONNECT_TIMEOUT_SECS"
step="1"
class="col-span-2 h-7 w-full shrink-0 sm:col-span-1 sm:w-20"
:disabled="form.connect_timeout_inherit === true"
@input="clampConnectTimeoutInput($event, 'connection')"
/>
</div>
</div>
</div>
<div v-if="form.db_type === 'etcd'" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.etcdGrpcMaxInbound") }}</Label>
<div class="col-span-3 space-y-1">
<Input v-model.number="etcdGrpcMaxInboundMessageSizeMiB" type="number" :min="ETCD_GRPC_MAX_INBOUND_MIN_MIB" :max="ETCD_GRPC_MAX_INBOUND_MAX_MIB" step="1" />
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.etcdGrpcMaxInboundHint") }}</p>
</div>
</div>
<!-- query_timeout_secs only feeds the database query pipeline
(dataGrid/queryStore); plugin connections like SSH never
consume it, so the generic radio would only suggest a
budget the provider cannot honor. -->
<div v-if="!isPluginConnection" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.queryTimeout") }}</Label>
<div class="col-span-3 grid grid-cols-2 gap-2">
<div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2 gap-y-1 rounded border px-2 py-1.5 sm:flex" :class="form.query_timeout_inherit === true ? 'border-primary/60 bg-background' : 'border-border bg-muted/30 text-muted-foreground'">
<input id="query-timeout-global" v-model="form.query_timeout_inherit" type="radio" name="query-timeout-scope" :value="true" class="h-3.5 w-3.5 shrink-0 accent-primary" />
<div class="flex min-w-0 flex-1 items-center gap-1">
<label for="query-timeout-global" class="min-w-0 cursor-pointer truncate text-xs" :title="t('connection.useGlobalQueryTimeout')">{{ t("connection.useGlobalQueryTimeout") }}</label>
<HelpTooltip :label="t('connection.globalQueryTimeoutHint')" content-class="max-w-[280px]">
{{ t("connection.globalQueryTimeoutHint") }}
</HelpTooltip>
</div>
<Input v-model.number="editGlobalQueryTimeoutSecs" type="number" min="0" :max="MAX_QUERY_TIMEOUT_SECS" step="1" class="col-span-2 h-7 w-full shrink-0 sm:col-span-1 sm:w-20" :disabled="form.query_timeout_inherit !== true" @input="clampQueryTimeoutInput($event, 'global')" />
</div>
<div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2 gap-y-1 rounded border px-2 py-1.5 sm:flex" :class="form.query_timeout_inherit !== true ? 'border-primary/60 bg-background' : 'border-border bg-muted/30 text-muted-foreground'">
<input id="query-timeout-connection" v-model="form.query_timeout_inherit" type="radio" name="query-timeout-scope" :value="false" class="h-3.5 w-3.5 shrink-0 accent-primary" />
<label for="query-timeout-connection" class="min-w-0 flex-1 cursor-pointer truncate text-xs" :title="t('connection.useConnectionQueryTimeout')">{{ t("connection.useConnectionQueryTimeout") }}</label>
<Input v-model.number="form.query_timeout_secs" type="number" min="0" :max="MAX_QUERY_TIMEOUT_SECS" step="1" class="col-span-2 h-7 w-full shrink-0 sm:col-span-1 sm:w-20" :disabled="form.query_timeout_inherit === true" @input="clampQueryTimeoutInput($event, 'connection')" />
</div>
</div>
</div>
<div v-show="form.db_type === 'mongodb'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.idleTimeout") }}</Label>
<Input v-model.number="form.idle_timeout_secs" type="number" min="0" max="600" step="1" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.keepaliveInterval") }}</Label>
<div class="col-span-3 flex items-center gap-2">
<Switch v-model="keepaliveEnabled" />
<Input v-model.number="form.keepalive_interval_secs" type="number" min="1" max="3600" step="1" class="flex-1" :disabled="!keepaliveEnabled" />
</div>
</div>
<div v-if="supportsDoltSystemTables" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.doltShowSystemTables") }}</Label>
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
<input v-model="showDoltSystemTables" type="checkbox" class="mr-0" />
<span class="text-xs text-muted-foreground">{{ t("connection.doltShowSystemTablesHint") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.readOnly") }}</Label>
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.read_only" class="mr-0" />
<span class="text-xs text-muted-foreground">{{ t("connection.readOnlyHint") }}</span>
</label>
</div>
<div v-if="isSchemaAware(form.db_type)" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.showSystemSchemas") }}</Label>
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.show_system_schemas" class="mr-0" />
<span class="text-xs text-muted-foreground">{{ t("connection.showSystemSchemasHint") }}</span>
</label>
</div>
<div v-if="supportsAutomaticTableLoading" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.tableLoading") }}</Label>
<div class="col-span-3 grid gap-1.5">
<label class="flex cursor-pointer items-center gap-2">
<Switch v-model="form.sidebar_auto_load_all_tables" />
<span class="text-sm font-medium">{{ t("connection.autoLoadAllTables") }}</span>
</label>
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.autoLoadAllTablesHint") }}</p>
</div>
</div>
<!-- Documentation notes are a relational-only feature, so this
follows the same isSchemaAware gate as the row above. -->
<div v-if="isSchemaAware(form.db_type)" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.docsNotesPath") }}</Label>
<div class="col-span-3 space-y-1">
<Input v-model="form.docs_notes_path" :placeholder="t('connection.docsNotesPathPlaceholder')" spellcheck="false" />
<p class="text-xs text-muted-foreground">
{{ t("connection.docsNotesPathHint") }}
</p>
</div>
</div>
<div v-if="!isPluginConnection" class="grid grid-cols-4 items-start gap-4 rounded-[6px] border border-red-500/25 bg-red-500/[0.035] px-3 py-2.5">
<Label :class="[connectionLabelSmallClass, 'pt-0.5 text-red-700 dark:text-red-300']">
<span class="inline-flex items-center justify-end gap-1"><ShieldAlert class="h-3.5 w-3.5" />PROD</span>
</Label>
<div class="col-span-3 grid gap-2">
<div class="flex items-center justify-between gap-3">
<Label class="text-sm font-medium">{{ t("production.enable") }}</Label>
<Switch :model-value="productionProtectionEnabled" @update:model-value="setProductionProtectionEnabled" />
</div>
<p v-if="!productionProtectionEnabled" class="text-xs leading-5 text-muted-foreground">{{ t(productionDisabledDescriptionKey) }}</p>
<template v-else>
<Label class="text-xs font-medium">{{ t("production.scope") }}</Label>
<Tabs v-model="productionScope" class="w-full">
<TabsList class="grid h-8 w-full grid-cols-2">
<TabsTrigger value="connection" class="text-xs">{{ t(productionScopeAllLabelKey) }}</TabsTrigger>
<TabsTrigger value="databases" class="text-xs" :disabled="!canSelectProductionDatabases" :title="canSelectProductionDatabases ? undefined : t('production.singleDatabaseScopeHint')">{{ t(productionScopeSelectedLabelKey) }}</TabsTrigger>
</TabsList>
</Tabs>
<p class="text-xs leading-5 text-muted-foreground">{{ productionScope === "connection" ? t(productionConnectionDescriptionKey) : t(productionScopeDescriptionKey) }}</p>
<div v-if="productionScope === 'databases'" class="grid gap-1.5">
<div class="flex items-center justify-between gap-3">
<Label class="text-xs font-medium">{{ t(productionScopeResourceLabelKey) }}</Label>
<span class="text-xs text-muted-foreground">{{ productionDatabaseSummary }}</span>
</div>
<Button type="button" variant="outline" size="sm" class="justify-start" :disabled="isTesting || isSaving || isLoadingProductionDatabases || !hasRequiredConnectionTarget" @click="openProductionDatabasesPicker">
<Loader2 v-if="isLoadingProductionDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ t(productionScopePickerLabelKey) }}
</Button>
</div>
</template>
</div>
</div>
<div v-show="form.db_type === 'redis'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("settings.redisScanPageSize") }}</Label>
<div class="col-span-3 flex flex-col gap-1">
<Select :model-value="String(form.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT)" @update:model-value="form.redis_scan_page_size = Number($event)">
<SelectTrigger>
<SelectValue :placeholder="t('settings.redisScanPageSize')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="size in REDIS_SCAN_PAGE_SIZE_OPTIONS" :key="size" :value="String(size)">
{{ t("settings.redisScanPageSizeOption", { count: size }) }}
</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">{{ t("settings.redisScanPageSizeDescription") }}</p>
</div>
</div>
</div>
</TabsContent>
<TabsContent v-if="canUseTransportLayers" value="transport" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2 pb-6">
<div class="connection-label-wide-grid grid min-w-0 grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.sshHops") }}</Label>
<div class="col-span-3 grid min-w-0 gap-3">
<div class="flex min-w-0 flex-wrap items-center gap-1 text-[11px] text-muted-foreground">
<template v-for="(segment, index) in transportPathSegments" :key="`${segment}-${index}`">
<span class="inline-block max-w-full truncate rounded border bg-muted/40 px-2 py-1">{{ segment }}</span>
<ChevronRight v-if="index < transportPathSegments.length - 1" class="h-3 w-3" />
</template>
</div>
<div class="grid min-w-0 gap-2">
<button
v-for="(hop, index) in transportLayers"
:key="hop.id"
type="button"
draggable="true"
class="connection-transport-layer-option flex min-h-10 items-center gap-2 rounded-md border px-2 text-left text-xs transition-colors"
:class="hop.id === selectedTransportLayer?.id ? 'connection-transport-layer-option--selected border-primary bg-primary/5' : 'hover:bg-muted/50'"
@click="selectedTransportLayerId = hop.id"
@dragstart="draggedTransportLayerId = hop.id"
@dragover.prevent
@drop="dropTransportLayer(hop.id)"
>
<GripVertical class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="w-5 shrink-0 text-muted-foreground">{{ index + 1 }}</span>
<input v-model="hop.enabled" type="checkbox" class="mr-0" @click.stop />
<span class="min-w-0 flex-1 truncate">
{{ transportLayerDisplayName(hop, index) }}
</span>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="index === 0" @click.stop="moveTransportLayer(hop.id, -1)">
<ArrowUp class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.sshHopMoveUp") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="index === transportLayers.length - 1" @click.stop="moveTransportLayer(hop.id, 1)">
<ArrowDown class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.sshHopMoveDown") }}</TooltipContent>
</Tooltip>
</button>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" @click="addSshTunnel">
<Plus class="mr-1.5 h-3.5 w-3.5" />
{{ t("connection.sshHopAdd") }}
</Button>
<Button type="button" variant="outline" size="sm" @click="addProxyTunnel">
<Plus class="mr-1.5 h-3.5 w-3.5" />
{{ t("connection.proxy") }}
</Button>
<Button v-if="!sqliteRemoteTransportRestricted" type="button" variant="outline" size="sm" @click="addHttpTunnel">
<Plus class="mr-1.5 h-3.5 w-3.5" />
{{ t("connection.httpTunnelAdd") }}
</Button>
<Button v-if="selectedTransportLayer" type="button" variant="outline" size="sm" @click="duplicateTransportLayer(selectedTransportLayer)">
<Copy class="mr-1.5 h-3.5 w-3.5" />
{{ t("connection.sshHopDuplicate") }}
</Button>
<Button v-if="selectedTransportLayer" type="button" variant="outline" size="sm" @click="removeTransportLayer(selectedTransportLayer.id)">
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
{{ t("connection.sshHopDelete") }}
</Button>
</div>
</div>
</div>
<template v-if="selectedTransportLayer">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshHopName") }}</Label>
<Input v-model="selectedTransportLayer.name" class="col-span-3" :placeholder="t('connection.sshHopNamePlaceholder')" />
</div>
<div v-if="tunnelProfiles.length || selectedLayerProfileId" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.tunnelProfile") }}</Label>
<div class="col-span-3 flex min-w-0 items-center gap-2">
<Select :model-value="selectedLayerProfileId || 'custom'" @update:model-value="applyTunnelProfileSelection">
<SelectTrigger class="h-9 min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="custom">{{ t("connection.tunnelProfileCustom") }}</SelectItem>
<SelectItem v-for="profile in tunnelProfiles" :key="profile.id" :value="profile.id">{{ tunnelProfileOptionLabel(profile) }}</SelectItem>
</SelectContent>
</Select>
<Button type="button" variant="outline" size="sm" class="shrink-0" @click="emit('openTunnelProfileSettings')">
{{ t("connection.tunnelProfileManage") }}
</Button>
</div>
</div>
<div v-if="selectedLayerProfileId" class="grid grid-cols-4 items-start gap-4">
<span />
<div class="col-span-3 grid min-w-0 gap-1 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<template v-if="selectedLayerProfile">
<span class="truncate font-medium text-foreground">{{ selectedLayerProfile.name || tunnelProfileSummary(selectedLayerProfile) }}</span>
<span v-if="selectedLayerProfile.name && tunnelProfileSummary(selectedLayerProfile)" class="truncate">{{ tunnelProfileSummary(selectedLayerProfile) }}</span>
<span>{{ t("connection.tunnelProfileManaged") }}</span>
</template>
<span v-else class="text-red-500">{{ t("connection.tunnelProfileMissing") }}</span>
</div>
</div>
<div v-if="!selectedLayerProfileId" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">Type</Label>
<Select :model-value="selectedTransportLayer.type" @update:model-value="(value: any) => changeSelectedTransportLayerType(value)">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ssh">SSH</SelectItem>
<SelectItem value="proxy">Proxy</SelectItem>
<SelectItem v-if="!sqliteRemoteTransportRestricted" value="http_tunnel">{{ t("connection.httpTunnel") }}</SelectItem>
</SelectContent>
</Select>
</div>
<template v-if="selectedSshLayer && !selectedLayerProfileId">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshHost") }}</Label>
<Input v-model="selectedSshLayer.host" class="col-span-2" list="ssh-config-host-aliases" :placeholder="t('connection.sshHostPlaceholder')" :disabled="selectedSshLayer.enabled === false" @change="applySshConfigHostAliasPrefill(selectedSshLayer!)" />
<datalist id="ssh-config-host-aliases">
<option v-for="alias in sshConfigHostAliases" :key="alias" :value="alias" />
</datalist>
<Input v-model.number="selectedSshLayer.port" type="number" min="1" max="65535" class="col-span-1" :disabled="selectedSshLayer.enabled === false" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshUser") }}</Label>
<Input v-model="selectedSshLayer.user" class="col-span-3" placeholder="root" :disabled="selectedSshLayer.enabled === false" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshAuthMethod") }}</Label>
<Select :model-value="selectedSshLayer.auth_method || 'password'" :disabled="selectedSshLayer.enabled === false" @update:model-value="updateSelectedSshAuthMethod">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="password">{{ t("connection.sshAuthMethodPassword") }}</SelectItem>
<SelectItem value="key">{{ t("connection.sshAuthMethodKey") }}</SelectItem>
<SelectItem value="key+password">{{ t("connection.sshAuthMethodKeyPassword") }}</SelectItem>
<SelectItem value="agent">{{ t("connection.sshUseAgent") }}</SelectItem>
<SelectItem value="none">{{ t("connection.sshAuthMethodNone") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="selectedSshLayer.auth_method === 'key' || selectedSshLayer.auth_method === 'key+password'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshKeyPath") }}</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="selectedSshLayer.key_path" class="flex-1" placeholder="~/.ssh/id_rsa" :disabled="selectedSshLayer.enabled === false" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :disabled="selectedSshLayer.enabled === false" @click="browseSshKeyPath(selectedSshLayer)">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.sshKeyPathBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
<div v-if="selectedSshLayer.auth_method === 'key' || selectedSshLayer.auth_method === 'key+password'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshKeyPassphrase") }}</Label>
<PasswordInput v-model="selectedSshLayer.key_passphrase" class="col-span-3" :placeholder="t('connection.sshKeyPassphrasePlaceholder')" :disabled="selectedSshLayer.enabled === false" />
</div>
<div v-if="!selectedSshLayer.auth_method || selectedSshLayer.auth_method === 'password' || selectedSshLayer.auth_method === 'key+password'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshPassword") }}</Label>
<PasswordInput v-model="selectedSshLayer.password" class="col-span-3" :placeholder="t('connection.sshPasswordPlaceholder')" :disabled="selectedSshLayer.enabled === false" />
</div>
<div v-if="selectedSshLayer.auth_method === 'none'" class="grid grid-cols-4 items-center gap-4">
<span />
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.sshAuthMethodNoneHint") }}</p>
</div>
<div v-if="selectedSshLayer.auth_method === 'agent'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshAgentSockPath") }}</Label>
<Input v-model="selectedSshLayer.ssh_agent_sock_path" class="col-span-3" :placeholder="t('connection.sshAgentSockPathPlaceholder')" :disabled="selectedSshLayer.enabled === false" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="selectedSshLayer.expose_lan" class="mr-0" :disabled="selectedSshLayer.enabled === false" />
<span class="text-xs text-muted-foreground">{{ t("connection.sshExposeLan") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<label class="col-span-3 flex items-start gap-2 cursor-pointer">
<input type="checkbox" v-model="selectedSshLayer.allow_exec_channel_proxy" class="mt-0.5 mr-0" :disabled="selectedSshLayer.enabled === false" />
<span class="text-xs text-muted-foreground">{{ t("connection.sshAllowExecChannelProxy") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshConnectTimeout") }}</Label>
<Input v-model.number="selectedSshLayer.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" :disabled="selectedSshLayer.enabled === false" />
</div>
</template>
<template v-else-if="selectedProxyLayer && !selectedLayerProfileId">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.proxyType") }}</Label>
<Select :model-value="selectedProxyLayer.proxy_type || 'socks5'" :disabled="selectedProxyLayer.enabled === false" @update:model-value="updateSelectedProxyType">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="socks5">SOCKS5</SelectItem>
<SelectItem value="http">HTTP CONNECT</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.proxyHost") }}</Label>
<Input v-model="selectedProxyLayer.host" class="col-span-2" placeholder="127.0.0.1" :disabled="selectedProxyLayer.enabled === false" />
<Input v-model.number="selectedProxyLayer.port" type="number" class="col-span-1" :disabled="selectedProxyLayer.enabled === false" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.proxyUsername") }}</Label>
<Input v-model="selectedProxyLayer.username" class="col-span-3" :placeholder="t('connection.proxyUsernamePlaceholder')" :disabled="selectedProxyLayer.enabled === false" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.proxyPassword") }}</Label>
<PasswordInput v-model="selectedProxyLayer.password" class="col-span-3" :placeholder="t('connection.proxyPasswordPlaceholder')" :disabled="selectedProxyLayer.enabled === false" />
</div>
</template>
<template v-else-if="selectedHttpTunnelLayer && !selectedLayerProfileId">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.httpTunnelUrl") }}</Label>
<Input v-model="selectedHttpTunnelLayer.url" class="col-span-3" placeholder="https://dbx.example.com/dbx_tunnel.php" :disabled="selectedHttpTunnelLayer.enabled === false" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.httpTunnelToken") }}</Label>
<PasswordInput v-model="selectedHttpTunnelLayer.token" class="col-span-3" :placeholder="t('connection.httpTunnelTokenPlaceholder')" :disabled="selectedHttpTunnelLayer.enabled === false" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.httpTunnelConnectTimeout") }}</Label>
<Input v-model.number="selectedHttpTunnelLayer.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" :disabled="selectedHttpTunnelLayer.enabled === false" />
</div>
</template>
</template>
<div v-if="hasEnabledSshLayer" class="grid grid-cols-4 items-center gap-4">
<span />
<div class="col-span-3">
<Button type="button" variant="outline" size="sm" :disabled="isTesting || isTestingSshTunnel || isSaving" @click="testSshTunnel">
<Loader2 v-if="isTestingSshTunnel" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
<ShieldCheck v-else class="mr-1.5 h-3.5 w-3.5" />
{{ isTestingSshTunnel ? t("connection.sshTunnelTesting") : t("connection.sshTunnelTest") }}
</Button>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
<DialogFooter class="connection-dialog-footer flex min-w-0 shrink-0 items-center gap-2 sm:flex-nowrap">
<div class="connection-dialog-test-status mr-auto flex min-w-0 flex-1 basis-0 items-center gap-2 overflow-hidden">
<Button v-if="!editingId" variant="outline" class="shrink-0" :disabled="isSaving || isTestingSshTunnel" @click="backToDatabasePicker">
<ArrowLeft class="h-4 w-4" />
{{ t("connection.back") }}
</Button>
<template v-if="pluginActionStatus">
<span class="block min-w-0 flex-1 basis-0 truncate text-xs" :class="pluginActionStatus.ok ? 'text-green-600' : 'text-red-600'" :title="pluginActionStatusMessage" role="status" aria-live="polite">
{{ pluginActionStatusMessage }}
</span>
</template>
<template v-else-if="testResult">
<span class="block min-w-0 flex-1 basis-0 truncate text-xs" :class="testResult.ok ? 'text-green-600' : 'text-red-600'" :title="testResultMessage" role="status" aria-live="polite">
{{ testResultMessage }}
</span>
<Button v-if="!testResult.ok" variant="ghost" size="icon-xs" class="h-5 w-5 shrink-0" :title="testResultCopied ? t('grid.copied') : t('connection.copyTestResult')" :aria-label="testResultCopied ? t('grid.copied') : t('connection.copyTestResult')" @click="copyTestResult">
<Check v-if="testResultCopied" class="h-3 w-3" />
<Copy v-else class="h-3 w-3" />
</Button>
</template>
</div>
<template v-if="isPluginConnection">
<Button v-for="action in pluginFooterActions" :key="action.id" type="button" class="shrink-0" :variant="pluginActionVariant(action)" :disabled="pluginActionDisabled(action)" :title="action.description" @click="runPluginConnectionAction(action)">
<Loader2 v-if="pluginActionIsBusy(action)" class="mr-1.5 h-4 w-4 animate-spin" />
{{ pluginActionLabel(action) }}
</Button>
</template>
<template v-else>
<Button v-if="canChooseVisibleNacosNamespaces" variant="outline" class="shrink-0" :disabled="isTesting || isTestingSshTunnel || isSaving || isLoadingVisibleNacosNamespaces || !hasRequiredConnectionTarget" @click="openVisibleNacosNamespacesPicker">
<Loader2 v-if="isLoadingVisibleNacosNamespaces" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ t(nacosNamespacePickerTitleKey) }}
</Button>
<Button v-else-if="canChooseVisibleDatabases" variant="outline" class="shrink-0" :disabled="isTesting || isTestingSshTunnel || isSaving || isLoadingVisibleDatabases || !hasRequiredConnectionTarget" @click="openVisibleDatabasesPicker">
<Loader2 v-if="isLoadingVisibleDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ hasVisibleObjectFilter ? visibleObjectSummary : visibleFilterUsesSchemas ? t("contextMenu.configureVisibleObjects") : t("contextMenu.selectVisibleDatabases") }}
</Button>
<Button v-if="canChooseVisibleSchemas && !visibleFilterUsesSchemas && hasVisibleSchemaFilter" variant="outline" class="shrink-0" :disabled="isTesting || isTestingSshTunnel || isSaving || isLoadingVisibleSchemas || !hasRequiredConnectionTarget" @click="openVisibleSchemasPicker">
<Loader2 v-if="isLoadingVisibleSchemas" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ visibleSchemaSummary }}
</Button>
<Button variant="outline" class="shrink-0" :disabled="isTesting || isTestingSshTunnel || isSaving" @click="testConnection">
{{ isTesting ? t("connection.testing") : t("connection.test") }}
</Button>
<Button class="shrink-0" @click="save()" :disabled="isSaving || isTestingSshTunnel || !hasRequiredConnectionTarget">
{{ isSaving ? t("common.loading") : editingId || isJdbcConnection ? t("connection.save") : t("connection.saveAndConnect") }}
</Button>
</template>
</DialogFooter>
</template>
</DialogContent>
</Dialog>
<Dialog :open="showAgentInstallDialog" @update:open="setAgentInstallDialogOpen">
<DialogContent class="sm:max-w-[520px]" @interact-outside.prevent @escape-key-down.prevent>
<DialogHeader>
<DialogTitle>{{ agentInstallError ? t("connection.driverInstall.failedTitle") : t("connection.driverInstall.installingTitle") }}</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div class="rounded-lg border bg-muted/20 p-4">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="truncate text-sm font-medium">{{ agentInstallLabel || agentInstallDriverKey }}</div>
<div class="mt-1 text-xs text-muted-foreground tabular-nums">{{ agentInstallProgressLabel }}</div>
</div>
<Loader2 v-if="agentInstallRunning && !agentInstallError" class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
</div>
<div v-if="!agentInstallError" class="mt-4 h-2 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-all" :class="{ 'animate-pulse': agentInstallPercent === null }" :style="{ width: `${agentInstallPercent ?? 35}%` }" />
</div>
</div>
<div v-if="agentInstallError" class="space-y-2">
<div class="text-sm font-medium text-destructive">{{ t("connection.driverInstall.fullError") }}</div>
<pre class="max-h-56 min-w-0 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-all [overflow-wrap:anywhere] rounded-md border bg-muted/30 p-3 text-xs leading-5 text-destructive">{{ agentInstallError }}</pre>
</div>
<div v-else-if="agentInstallCancelError" class="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{{ agentInstallCancelError }}
</div>
</div>
<DialogFooter class="gap-2">
<Button v-if="agentInstallError" variant="outline" @click="copyAgentInstallError">
<Copy class="mr-1.5 h-3.5 w-3.5" />
{{ t("connection.copyError") }}
</Button>
<Button v-if="agentInstallRunning && !agentInstallError" variant="outline" :disabled="agentInstallCancelling" @click="cancelActiveAgentInstall">
{{ t("common.cancel") }}
</Button>
<Button :disabled="!canCloseAgentInstallDialog" @click="showAgentInstallDialog = false">
{{ agentInstallError ? t("common.close") : t("connection.driverInstall.installingButton") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showConnectionErrorDialog">
<DialogContent class="min-w-0 sm:max-w-[680px]">
<DialogHeader>
<DialogTitle>{{ t("connection.connectFailedTitle") }}</DialogTitle>
</DialogHeader>
<div class="min-w-0 space-y-2">
<div class="text-sm text-muted-foreground">{{ t("connection.fullErrorMessage") }}</div>
<pre class="max-h-72 min-w-0 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-all [overflow-wrap:anywhere] rounded-md border bg-muted/30 p-3 text-xs leading-5 text-destructive">{{ connectionErrorDetail }}</pre>
</div>
<DialogFooter class="gap-2">
<Button v-if="showJdbcDependencyDriverManagerAction" variant="outline" @click="openJdbcDriverManagerFromError">
<FolderOpen class="mr-1.5 h-3.5 w-3.5" />
{{ t("toolbar.driverManager") }}
</Button>
<Button variant="outline" @click="copyConnectionErrorDetail">
<Check v-if="connectionErrorCopied" class="mr-1.5 h-3.5 w-3.5" />
<Copy v-else class="mr-1.5 h-3.5 w-3.5" />
{{ connectionErrorCopied ? t("grid.copied") : t("connection.copyError") }}
</Button>
<Button @click="showConnectionErrorDialog = false">{{ t("common.close") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showVisibleNacosNamespacesDialog">
<DialogContent class="sm:max-w-[460px]" @interact-outside.prevent @escape-key-down.prevent>
<DialogHeader>
<DialogTitle>{{ t(nacosNamespacePickerTitleKey) }}</DialogTitle>
<p class="text-sm text-muted-foreground">{{ t(nacosNamespacePickerDescriptionKey, { name: form.name || selectedProfile().label }) }}</p>
</DialogHeader>
<Tabs v-model="visibleNacosNamespaceAccessMode" class="grid gap-3">
<TabsList class="grid h-9 w-full" :class="canDetectNacosNamespaceAccess ? 'grid-cols-2' : 'grid-cols-1'">
<TabsTrigger value="automatic">{{ t("nacos.nacosNamespaceAccessAutomatic") }}</TabsTrigger>
<TabsTrigger v-if="canDetectNacosNamespaceAccess" value="manual">{{ t("nacos.nacosNamespaceAccessManual") }}</TabsTrigger>
</TabsList>
<TabsContent value="automatic" class="m-0 grid gap-3">
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="visibleNacosNamespaceSearchText" :placeholder="t('nacos.nacosSearchNamespaces')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError" />
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("nacos.nacosSelectedNamespaces", { selected: visibleNacosNamespaceSelectedCount, total: visibleNacosNamespaces.length }) }}</span>
<div class="flex items-center gap-2">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="selectAllVisibleNacosNamespaces">{{ t("nacos.nacosSelectAll") }}</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="clearVisibleNacosNamespaceSelection">{{ t("nacos.nacosClearSelection") }}</button>
<button v-if="visibleNacosNamespaceDynamicAllSupported" class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="showAllVisibleNacosNamespaces">{{ t("nacos.nacosShowAll") }}</button>
</div>
</div>
<p v-if="!isLoadingVisibleNacosNamespaces && !visibleNacosNamespaceError && !visibleNacosNamespaceCanSave" class="text-xs text-destructive">{{ t("nacos.nacosNamespaceSelectionRequired") }}</p>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoadingVisibleNacosNamespaces" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="visibleNacosNamespaceListingPermissionDenied" class="grid gap-3 p-3 text-sm text-destructive">
<p>{{ visibleNacosNamespaceError }}</p>
<Button v-if="canDetectNacosNamespaceAccess" variant="outline" size="sm" class="justify-self-start" @click="visibleNacosNamespaceAccessMode = 'manual'">
{{ t("nacos.nacosNamespaceAccessManual") }}
</Button>
</div>
<div v-else-if="visibleNacosNamespaceError" class="p-3 text-sm text-destructive">{{ t("nacos.nacosLoadNamespacesFailed", { message: visibleNacosNamespaceError }) }}</div>
<div v-else-if="!filteredVisibleNacosNamespaces.length" class="p-3 text-sm text-muted-foreground">{{ t("grid.noSearchResults") }}</div>
<template v-else>
<button
v-for="namespace in filteredVisibleNacosNamespaces"
:key="nacosNamespaceValue(namespace) || '__public__'"
type="button"
class="flex min-h-9 w-full min-w-0 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleVisibleNacosNamespace(nacosNamespaceValue(namespace))"
>
<CheckSquare v-if="visibleNacosNamespaceSelection.has(nacosNamespaceValue(namespace))" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate">{{ nacosNamespaceLabel(namespace) }}</span>
<span v-if="namespace.namespace && namespace.namespace !== nacosNamespaceLabel(namespace)" class="shrink-0 truncate text-xs text-muted-foreground">{{ namespace.namespace }}</span>
</button>
</template>
</div>
</TabsContent>
<TabsContent v-if="canDetectNacosNamespaceAccess" value="manual" class="m-0 grid gap-3">
<p class="text-xs leading-5 text-muted-foreground">{{ t("nacos.nacosNamespaceAccessScopeHint") }}</p>
<div v-if="isNacosV3AdminPlane" class="flex gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2.5 text-xs leading-5 text-amber-800 dark:text-amber-300">
<ShieldAlert class="mt-0.5 h-4 w-4 shrink-0" />
<p>{{ t("nacos.nacosV3AdminManagedNamespacesHint") }}</p>
</div>
<div class="grid gap-1.5">
<div class="flex items-center justify-between gap-3">
<Label>{{ t(nacosManualNamespaceLabelKey) }}</Label>
<span class="text-[11px] text-muted-foreground">{{ t("nacos.nacosManagedNamespacesSeparator") }}</span>
</div>
<textarea
v-model="nacosManagedNamespacesText"
data-nacos-managed-namespaces
rows="6"
class="min-h-32 resize-y rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t(nacosManualNamespacePlaceholderKey)"
/>
<p class="text-[11px] leading-4 text-muted-foreground">{{ t(nacosManualNamespaceHintKey) }}</p>
</div>
<p v-if="!visibleNacosNamespaceCanSave" class="text-xs text-destructive">{{ t("nacos.nacosOrdinaryNamespacesRequired") }}</p>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" @click="showVisibleNacosNamespacesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="isResolvingManualNacosNamespaces || !visibleNacosNamespaceCanSave || (visibleNacosNamespaceAccessMode === 'automatic' && (isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError))" @click="saveVisibleNacosNamespaceSelection">{{ t("nacos.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showVisibleDatabasesDialog">
<DialogContent class="sm:max-w-[520px]" @keydown="preventDialogDocumentSelectAll">
<DialogHeader>
<DialogTitle>{{ t(visibleObjectTitleKey) }}</DialogTitle>
<p class="text-sm text-muted-foreground">
{{ t(visibleObjectDescriptionKey, { connection: form.name || selectedProfile().label }) }}
</p>
</DialogHeader>
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="visibleDatabaseSearchText" :placeholder="t(visibleObjectSearchPlaceholderKey)" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingVisibleDatabases || !!visibleDatabaseError" />
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>
{{
t(visibleObjectSelectedCountKey, {
selected: visibleDatabaseSelectedCount,
total: visibleDatabaseTotalCount,
})
}}
</span>
<div class="flex items-center gap-2">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleDatabases" @click="selectAllVisibleDatabases">
{{ t("visibleDatabases.selectAll") }}
</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleDatabases" @click="clearVisibleDatabaseSelection">
{{ t("visibleDatabases.clear") }}
</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleDatabases" @click="showAllVisibleDatabases">
{{ t("visibleDatabases.showAll") }}
</button>
</div>
</div>
<p v-if="!isLoadingVisibleDatabases && !visibleDatabaseError && !visibleDatabaseCanSave" class="text-xs text-destructive">
{{ t(visibleObjectEmptySelectionKey) }}
</p>
<label v-if="visibleDatabaseHasSystemObjects" class="flex h-8 items-center gap-2 rounded-md px-1 text-xs text-muted-foreground">
<input v-model="visibleDatabaseShowSystem" type="checkbox" class="h-3.5 w-3.5 accent-primary" :disabled="isLoadingVisibleDatabases || !!visibleDatabaseError" />
<span>{{ t(visibleSystemObjectsLabelKey) }}</span>
</label>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoadingVisibleDatabases" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<textarea v-else-if="visibleDatabaseError" class="h-full w-full resize-none overflow-auto border-0 bg-transparent p-3 text-sm leading-5 text-destructive outline-none" :value="t(visibleObjectLoadFailedKey, { message: visibleDatabaseError })" readonly />
<div v-else-if="!filteredVisibleDatabaseNames.length" class="p-3 text-sm text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
<template v-else>
<button
v-for="database in filteredVisibleDatabaseNames"
:key="database"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleVisibleDatabase(database)"
>
<CheckSquare v-if="visibleDatabaseSelection.has(database)" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="truncate">{{ database }}</span>
</button>
</template>
</div>
<DialogFooter>
<Button variant="outline" @click="showVisibleDatabasesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="isLoadingVisibleDatabases || !!visibleDatabaseError || !visibleDatabaseCanSave" @click="saveVisibleDatabaseSelection">
{{ t(visibleObjectSaveKey) }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showProductionDatabasesDialog">
<DialogContent class="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t(productionPickerTitleKey) }}</DialogTitle>
<p class="text-sm text-muted-foreground">
{{ t(productionPickerDescriptionKey, { connection: form.name || selectedProfile().label }) }}
</p>
</DialogHeader>
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="productionDatabaseSearchText" :placeholder="t(productionPickerSearchPlaceholderKey)" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" />
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("production.databasesSelectedCount", { selected: productionDatabaseSelectedCount, total: productionDatabaseNames.length }) }}</span>
<div class="flex items-center gap-2">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" @click="selectAllProductionDatabases">
{{ t("visibleDatabases.selectAll") }}
</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" @click="clearProductionDatabaseSelection">
{{ t("visibleDatabases.clear") }}
</button>
</div>
</div>
<p v-if="!isLoadingProductionDatabases && !productionDatabaseError && !productionDatabaseCanSave" class="text-xs text-destructive">
{{ t(productionPickerSelectionRequiredKey) }}
</p>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoadingProductionDatabases" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="productionDatabaseError" class="flex h-full flex-col items-start justify-center gap-3 p-3 text-sm text-destructive">
<p>{{ t(productionPickerLoadFailedKey, { message: productionDatabaseError }) }}</p>
<Button type="button" variant="outline" size="sm" @click="reloadProductionDatabases">
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
{{ t("production.retry") }}
</Button>
</div>
<div v-else-if="!filteredProductionDatabaseNames.length" class="p-3 text-sm text-muted-foreground">
{{ productionDatabaseNames.length ? t("grid.noSearchResults") : t(productionPickerEmptyKey) }}
</div>
<template v-else>
<button
v-for="database in filteredProductionDatabaseNames"
:key="database"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleProductionDatabase(database)"
>
<CheckSquare v-if="productionDatabaseSelection.has(database)" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="truncate">{{ database }}</span>
</button>
</template>
</div>
<DialogFooter>
<Button variant="outline" @click="showProductionDatabasesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="isLoadingProductionDatabases || !!productionDatabaseError || !productionDatabaseCanSave" @click="saveProductionDatabaseSelection">
{{ t("visibleDatabases.save") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<VisibleSchemasDialog
v-model:open="showVisibleSchemasDialog"
draft-mode
:connection-id="''"
:connection-name="form.name || selectedProfile().label"
:database="visibleSchemasDatabaseKey"
:database-type="form.db_type"
:username="form.username"
:draft-schema-names="visibleSchemaNames"
:draft-initial-selection="visibleSchemaInitialSelection"
:draft-loading="isLoadingVisibleSchemas"
:draft-error="visibleSchemaError"
@draft:save="handleDraftSchemasSave"
@draft:show-all="handleDraftSchemasShowAll"
/>
</template>
<style>
.connection-dialog-content {
display: flex;
flex-direction: column;
max-height: calc(var(--dbx-viewport-height) - 2rem);
}
.connection-dialog-content--config {
min-height: 0;
}
.connection-dialog-content--scrollable {
height: min(720px, calc(var(--dbx-viewport-height) - 2rem));
}
.connection-dialog-content--config .connection-form-body {
/* Preserve every form section's natural height; the form viewport owns
* scrolling and must never shrink cards into collapsed grid rows. */
align-content: start;
}
.connection-form-body--nacos {
/* Authentication fields are conditional. Keep every Nacos card at its
* max-content height when they appear, and scroll the form as a whole. */
grid-auto-rows: max-content;
}
@media (max-height: 720px) {
.connection-dialog-content--config {
/* A definite flex height lets tab bodies shrink and scroll above the fixed footer. */
height: calc(var(--dbx-viewport-height) - 2rem);
}
}
/* Legacy responsive layout rules live in public/connection-dialog-legacy.css
* so the production build cannot rewrite their classic media queries. */
html.dbx-legacy-webview .connection-db-category-option--selected {
color: rgb(23, 23, 23) !important;
background-color: rgba(23, 23, 23, 0.08) !important;
}
html.dbx-legacy-webview .connection-db-category-option--selected:hover {
color: rgb(23, 23, 23) !important;
background-color: rgba(23, 23, 23, 0.12) !important;
}
html.dbx-legacy-webview .connection-transport-layer-option--selected {
color: rgb(23, 23, 23) !important;
border-color: rgb(23, 23, 23) !important;
background-color: rgba(23, 23, 23, 0.08) !important;
}
html.dbx-legacy-webview .connection-transport-layer-option--selected:hover {
background-color: rgba(23, 23, 23, 0.12) !important;
}
html.dbx-legacy-webview.dark .connection-db-category-option--selected {
color: rgb(244, 244, 245) !important;
background-color: rgba(255, 255, 255, 0.1) !important;
}
html.dbx-legacy-webview.dark .connection-db-category-option--selected:hover {
color: rgb(244, 244, 245) !important;
background-color: rgba(255, 255, 255, 0.14) !important;
}
html.dbx-legacy-webview.dark .connection-transport-layer-option--selected {
color: rgb(244, 244, 245) !important;
border-color: rgb(244, 244, 245) !important;
background-color: rgba(255, 255, 255, 0.1) !important;
}
html.dbx-legacy-webview.dark .connection-transport-layer-option--selected:hover {
background-color: rgba(255, 255, 255, 0.14) !important;
}
.connection-db-picker-option {
color: var(--foreground);
}
.connection-config-step :is([data-slot="input"], [data-slot="select-trigger"], [data-slot="tabs-list"], [data-slot="tabs-trigger"], textarea) {
border-radius: var(--dbx-radius-fixed-4, 4px);
}
.connection-dialog-content[data-wide="true"] .grid.grid-cols-4 {
grid-template-columns: minmax(5.5rem, 0.7fr) repeat(3, minmax(0, 1fr));
}
.connection-dialog-content[data-wide="true"] .connection-form-body {
width: min(100%, 36rem);
margin-inline: auto;
}
</style>