mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
10682 lines
562 KiB
Vue
10682 lines
562 KiB
Vue
<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 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 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 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 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 <database-name></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>
|