feat(desktop): add database info and SQL statement markers
This commit is contained in:
60 files changed
+2403
-134
No files matched your search
@@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
|
||||
import type { ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, HttpTunnelConfig, IdentifierCase, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
|
||||
import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
|
||||
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
|
||||
import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos";
|
||||
@@ -37,6 +37,7 @@ import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedV
|
||||
import { mongodbAuthFailureHint, mongoUrlParam, mongoUrlParamIsTrue, normalizeMongoTlsFormState, setMongoUrlParam, setMongoUrlParamBoolean } from "@/lib/mongo/mongoConnectionOptions";
|
||||
import { mysqlCleartextPasswordAuthEnabled, setMysqlCleartextPasswordAuthEnabled } from "@/lib/database/mysqlConnectionOptions";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { configuredDatabaseProductName, connectionConfigFingerprint, databaseInfoCopyText, databaseInfoRows, normalizeDatabaseConnectionInfo, type DatabaseInfoField } from "@/lib/connection/connectionDatabaseInfo";
|
||||
import { agentDriverInstallKey, appendAgentDriverUpdateHint, hasAgentDriverUpdate, showAgentDriverInstallHint, type AgentDriverInstallState, type DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
|
||||
import { prestoSqlBuiltinDriverPaths } from "@/lib/database/prestoSqlBuiltinDriver";
|
||||
import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDetection";
|
||||
@@ -46,7 +47,35 @@ import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapS
|
||||
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
|
||||
import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
|
||||
import { isSqlServerLegacyCompatibilityMode, requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityMode, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
|
||||
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, RefreshCw, Search, ShieldAlert, ShieldCheck, Square, Trash2 } from "@lucide/vue";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
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, visibleDatabaseSelectionIsStale } from "@/lib/connection/connectionVisibleDatabases";
|
||||
import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
|
||||
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
|
||||
@@ -101,6 +130,8 @@ type LegacyTransportFields = {
|
||||
};
|
||||
type LegacyConnectionConfig = ConnectionConfig & LegacyTransportFields;
|
||||
type ConnectionForm = Omit<ConnectionConfig, "id">;
|
||||
type ConnectionTestState = ConnectionTestResult & { ok: boolean };
|
||||
type SuccessfulConnectionTest = { result: ConnectionTestResult; config: ConnectionConfig };
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
@@ -126,7 +157,13 @@ const store = useConnectionStore();
|
||||
const tunnelProfileStore = useTunnelProfileStore();
|
||||
const isTesting = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const testResult = ref<{ ok: boolean; message: string } | null>(null);
|
||||
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 agentInstallDriverKey = ref("");
|
||||
@@ -1038,7 +1075,7 @@ function isNacosAdminEndpointNotFound(message: string): boolean {
|
||||
return /Nacos admin endpoint was not found/i.test(message);
|
||||
}
|
||||
|
||||
async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalError: string, runId: number): Promise<string | null> {
|
||||
async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalError: string, runId: number): Promise<SuccessfulConnectionTest | null> {
|
||||
if (config.db_type !== "nacos" || !isNacosAdminEndpointNotFound(originalError)) return null;
|
||||
const fallbackUrl = dockerNacosConsoleFallbackUrl(nacosServerAddr.value);
|
||||
if (!fallbackUrl || fallbackUrl === nacosServerAddr.value.trim()) return null;
|
||||
@@ -1046,9 +1083,15 @@ async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalE
|
||||
const previousUrl = nacosServerAddr.value;
|
||||
nacosServerAddr.value = fallbackUrl;
|
||||
try {
|
||||
const fallbackConfig = connectionConfigForSubmit(config.id);
|
||||
const message = await testConnectionWithTimeout(fallbackConfig, runId);
|
||||
return `${message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`;
|
||||
const fallbackConfig = connectionConfigForSubmit(config.id, config.name);
|
||||
const result = await testConnectionWithTimeout(fallbackConfig, runId);
|
||||
return {
|
||||
config: fallbackConfig,
|
||||
result: {
|
||||
...result,
|
||||
message: `${result.message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
nacosServerAddr.value = previousUrl;
|
||||
return null;
|
||||
@@ -1199,15 +1242,63 @@ function isSqlServerTlsHandshakeFailure(message: string): boolean {
|
||||
return text.includes("sql server") && text.includes("tls") && (text.includes("handshake") || text.includes("eof") || text.includes("performing i/o"));
|
||||
}
|
||||
|
||||
async function testConnectionWithTimeout(config: ConnectionConfig, runId: number): Promise<string> {
|
||||
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) {
|
||||
if (!editingId.value || !result.databaseInfo || !savedConnectionConfigFingerprint.value) return;
|
||||
const fingerprint = connectionConfigFingerprint(config, sourceName);
|
||||
if (fingerprint !== savedConnectionConfigFingerprint.value) return;
|
||||
try {
|
||||
await store.updateConnectionDatabaseInfo(editingId.value, result.databaseInfo);
|
||||
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> {
|
||||
const timeoutMs = connectionAttemptTimeoutMs(config);
|
||||
const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs);
|
||||
const promise = api.testConnection(config);
|
||||
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))),
|
||||
@@ -1216,7 +1307,7 @@ async function testConnectionWithTimeout(config: ConnectionConfig, runId: number
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<string>((_, reject) => {
|
||||
new Promise<ConnectionTestResult>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
reject(new Error(timeoutMessage));
|
||||
@@ -1436,9 +1527,11 @@ function switchGbaseProfile(profile: "gbase8a" | "gbase8s") {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.editConfig,
|
||||
(config) => {
|
||||
[() => props.editConfig, open],
|
||||
([config, isOpen]) => {
|
||||
if (!isOpen) return;
|
||||
if (config) {
|
||||
clearSavedDatabaseInfo();
|
||||
const legacyConfig = config as LegacyConnectionConfig;
|
||||
const profile = profileForConfig(config);
|
||||
const oceanbaseMode = profile === "oceanbase" ? oceanbaseSubModeFromConfig(config) : "mysql";
|
||||
@@ -1528,7 +1621,14 @@ watch(
|
||||
customDriverName.value = isCustomCompatibleProfile() ? config.driver_label || "" : "";
|
||||
dialogStep.value = "config";
|
||||
configTab.value = initialConfigTab();
|
||||
// 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) applySavedDatabaseInfo(config);
|
||||
});
|
||||
} else {
|
||||
clearSavedDatabaseInfo();
|
||||
editingId.value = null;
|
||||
form.value = defaultForm();
|
||||
productionProtectionEnabled.value = false;
|
||||
@@ -2050,6 +2150,71 @@ const visibleObjectSelectedCountKey = computed(() => (visibleFilterUsesSchemas.v
|
||||
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 "";
|
||||
return testResult.value.ok ? t("connection.testSuccess") : translateBackendError(t, testResult.value.message);
|
||||
@@ -2147,30 +2312,37 @@ async function testConnection() {
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
let config: ConnectionConfig | null = null;
|
||||
const submittedSourceName = form.value.name;
|
||||
try {
|
||||
config = connectionConfigForSubmit(editingId.value || draftTestConnectionId.value);
|
||||
await ensureRequiredAgentDriverInstalled(config);
|
||||
const msg = await testConnectionWithTimeout(config, runId);
|
||||
const result = await testConnectionWithTimeout(config, runId);
|
||||
if (runId !== testRunId) return;
|
||||
if (config.db_type === "mongodb" && /legacy driver/i.test(msg)) {
|
||||
let successfulConfig = config;
|
||||
if (config.db_type === "mongodb" && /legacy driver/i.test(result.message)) {
|
||||
mongoDriverMode.value = "legacy";
|
||||
successfulConfig = connectionConfigForSubmit(config.id, config.name);
|
||||
}
|
||||
testResult.value = { ok: true, message: msg };
|
||||
applySuccessfulConnectionTest(result, successfulConfig, submittedSourceName);
|
||||
void persistSuccessfulConnectionTest(result, successfulConfig, submittedSourceName);
|
||||
clearEditedConnectionErrorAfterSuccessfulTest();
|
||||
} catch (e: any) {
|
||||
if (runId !== testRunId) return;
|
||||
const rawMessage = mongodbAuthFailureHint(errorMessage(e));
|
||||
const message = config ? connectionErrorWithDriverUpdateHint(config, rawMessage) : rawMessage;
|
||||
const fallbackMessage = config ? await tryNacosDockerConsoleFallback(config, message, runId) : null;
|
||||
const fallback = config ? await tryNacosDockerConsoleFallback(config, message, runId) : null;
|
||||
if (runId !== testRunId) return;
|
||||
const shouldShowSqlServerLegacyMode = !fallbackMessage && config?.db_type === "sqlserver" && !isSqlServerLegacyCompatibilityMode(config.url_params) && isSqlServerTlsHandshakeFailure(message);
|
||||
const shouldShowSqlServerLegacyMode = !fallback && config?.db_type === "sqlserver" && !isSqlServerLegacyCompatibilityMode(config.url_params) && isSqlServerTlsHandshakeFailure(message);
|
||||
if (shouldShowSqlServerLegacyMode) {
|
||||
configTab.value = "advanced";
|
||||
}
|
||||
testResult.value = fallbackMessage ? { ok: true, message: fallbackMessage } : { ok: false, message };
|
||||
if (fallbackMessage) {
|
||||
if (fallback) {
|
||||
applySuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName);
|
||||
void persistSuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName);
|
||||
clearEditedConnectionErrorAfterSuccessfulTest();
|
||||
} else {
|
||||
clearTestedConnectionInfo();
|
||||
testResult.value = { ok: false, message };
|
||||
showConnectionError(message);
|
||||
}
|
||||
} finally {
|
||||
@@ -2319,13 +2491,14 @@ function generateConnectionName(): string {
|
||||
return `${label}_${rand}`;
|
||||
}
|
||||
|
||||
function connectionConfigForSubmit(id: string): ConnectionConfig {
|
||||
function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionConfig {
|
||||
const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig;
|
||||
config.database_info = 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 = generateConnectionName();
|
||||
config.name = generatedName.trim() || generateConnectionName();
|
||||
}
|
||||
if (config.db_type === "kingbase") {
|
||||
config.database = config.database?.trim() || undefined;
|
||||
@@ -2602,6 +2775,13 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
|
||||
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),
|
||||
@@ -2813,6 +2993,7 @@ function resetTestState() {
|
||||
testRunId += 1;
|
||||
isTesting.value = false;
|
||||
testResult.value = null;
|
||||
clearTestedConnectionInfo();
|
||||
showConnectionErrorDialog.value = false;
|
||||
connectionErrorDetail.value = "";
|
||||
}
|
||||
@@ -3098,6 +3279,17 @@ async function copyTestResult() {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -3431,16 +3623,16 @@ function validateTransportLayers(config: LegacyConnectionConfig) {
|
||||
async function save() {
|
||||
if (!ensureConnectionHostResolvedFromUrl()) return;
|
||||
if (isSaving.value) return;
|
||||
const databaseInfoForSave = visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value;
|
||||
isSaving.value = true;
|
||||
resetTestState();
|
||||
try {
|
||||
if (editingId.value) {
|
||||
const updated = connectionConfigForSubmit(editingId.value);
|
||||
const updated = withSavedDatabaseInfo(connectionConfigForSubmit(editingId.value), databaseInfoForSave);
|
||||
await ensureRequiredAgentDriverInstalled(updated);
|
||||
await store.updateConnection(updated);
|
||||
store.stopEditing();
|
||||
} else {
|
||||
const config = connectionConfigForSubmit(draftTestConnectionId.value);
|
||||
const config = withSavedDatabaseInfo(connectionConfigForSubmit(draftTestConnectionId.value), databaseInfoForSave);
|
||||
await ensureRequiredAgentDriverInstalled(config);
|
||||
await store.addConnection(config);
|
||||
draftTestConnectionId.value = uuid();
|
||||
@@ -3817,7 +4009,7 @@ function openExternalUrl(url: string) {
|
||||
</DialogHeader>
|
||||
|
||||
<template v-if="dialogStep === 'select'">
|
||||
<div class="space-y-4">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
|
||||
<div class="flex flex-col gap-3 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">
|
||||
@@ -3839,7 +4031,7 @@ function openExternalUrl(url: string) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[58vh] space-y-5 overflow-y-auto pr-2">
|
||||
<div class="min-h-0 flex-1 space-y-5 overflow-y-auto pr-2">
|
||||
<section v-for="category in filteredDbCategories" :key="category.key" class="space-y-2">
|
||||
<div class="flex items-center">
|
||||
<h3 v-if="category.title" class="text-sm font-medium">{{ category.title }}</h3>
|
||||
@@ -3887,7 +4079,7 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="flex items-center gap-2">
|
||||
<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">
|
||||
<DatabaseIcon :db-type="selectedDbIcon" class="h-4 w-4 shrink-0" />
|
||||
<span class="truncate">{{ t("connection.selectedDatabase") }}: {{ selectedProfile().label }}</span>
|
||||
@@ -3900,8 +4092,8 @@ function openExternalUrl(url: string) {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<Tabs v-model="configTab" class="min-h-0">
|
||||
<div class="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>
|
||||
@@ -3911,8 +4103,8 @@ function openExternalUrl(url: string) {
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="connection" class="m-0">
|
||||
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<TabsContent value="connection" class="m-0 min-h-0 flex-1 overflow-hidden">
|
||||
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto pt-4 pr-2">
|
||||
<div v-if="!isJdbcConnection" 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">
|
||||
@@ -5059,11 +5251,49 @@ function openExternalUrl(url: string) {
|
||||
</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>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="supportsTlsToggle" value="tls" class="m-0">
|
||||
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto overflow-x-hidden">
|
||||
<TabsContent v-if="supportsTlsToggle" value="tls" class="m-0 min-h-0 flex-1 overflow-hidden">
|
||||
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2">
|
||||
<div v-if="!supportsPostgresTlsOptions && !supportsMysqlTlsOptions" 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">
|
||||
@@ -5327,8 +5557,8 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" class="m-0">
|
||||
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<TabsContent value="advanced" class="m-0 min-h-0 flex-1 overflow-hidden">
|
||||
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto pt-4 pr-2">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.connectTimeout") }}</Label>
|
||||
<Input v-model.number="form.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" />
|
||||
@@ -5419,8 +5649,8 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="canUseTransportLayers" value="transport" class="m-0">
|
||||
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto overflow-x-hidden">
|
||||
<TabsContent v-if="canUseTransportLayers" value="transport" class="m-0 min-h-0 flex-1 overflow-hidden">
|
||||
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2">
|
||||
<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">
|
||||
@@ -5663,7 +5893,7 @@ function openExternalUrl(url: string) {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="flex min-w-0 items-center gap-2 sm:flex-nowrap">
|
||||
<DialogFooter class="flex min-w-0 shrink-0 items-center gap-2 sm:flex-nowrap">
|
||||
<div class="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" @click="backToDatabasePicker">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
@@ -5673,7 +5903,7 @@ function openExternalUrl(url: string) {
|
||||
<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 variant="ghost" size="icon-xs" class="h-5 w-5 shrink-0" :title="t('connection.copyTestResult')" :aria-label="t('connection.copyTestResult')" @click="copyTestResult">
|
||||
<Button v-if="!testResult.ok" variant="ghost" size="icon-xs" class="h-5 w-5 shrink-0" :title="t('connection.copyTestResult')" :aria-label="t('connection.copyTestResult')" @click="copyTestResult">
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -5921,6 +6151,12 @@ function openExternalUrl(url: string) {
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.connection-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - 2rem);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.connection-db-picker-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
|
||||
Reference in new issue
Block a user