diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 767a5f560b..b04c7e66b6 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -33,6 +33,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import * as api from "@/lib/backend/api"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnectionUrl } from "@/lib/connection/connectionUrl"; +import { MAX_CONNECT_TIMEOUT_SECS, MAX_QUERY_TIMEOUT_SECS } from "@/lib/connection/timeoutLimits"; import { buildOracleTnsConnectionString, normalizeOracleTnsAdminPath, parseOracleTnsConnectionString } from "@/lib/connection/oracleTnsConnection"; import { connectionDeepLinkServiceHydrationValue, parseConnectionDeepLink, parseServiceConnectionUrl, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connection/connectionPresentation"; @@ -3703,10 +3704,9 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo // service, SID, and descriptor JDBC strings exactly as before. config.connection_string = undefined; } - const connectTimeout = Number(config.connect_timeout_secs); - config.connect_timeout_secs = config.connect_timeout_inherit === true ? normalizeGlobalConnectTimeoutSecs(editGlobalConnectTimeoutSecs.value) : Number.isFinite(connectTimeout) && connectTimeout > 0 ? connectTimeout : 10; + config.connect_timeout_secs = config.connect_timeout_inherit === true ? normalizeGlobalConnectTimeoutSecs(editGlobalConnectTimeoutSecs.value) : normalizeGlobalConnectTimeoutSecs(config.connect_timeout_secs); const queryTimeout = Number(config.query_timeout_secs); - config.query_timeout_secs = config.query_timeout_inherit === true ? normalizeGlobalQueryTimeoutSecs(editGlobalQueryTimeoutSecs.value) : Number.isFinite(queryTimeout) && queryTimeout >= 0 ? queryTimeout : 30; + config.query_timeout_secs = config.query_timeout_inherit === true ? normalizeGlobalQueryTimeoutSecs(editGlobalQueryTimeoutSecs.value) : normalizeGlobalQueryTimeoutSecs(queryTimeout); const idleTimeout = Number(config.idle_timeout_secs); config.idle_timeout_secs = Number.isFinite(idleTimeout) && idleTimeout >= 0 ? idleTimeout : 60; const keepaliveInterval = Number(config.keepalive_interval_secs); @@ -5007,6 +5007,26 @@ function validateTransportLayers(config: LegacyConnectionConfig) { }); } +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); @@ -7728,13 +7748,41 @@ function openExternalUrl(url: string) {
- - +
+ + + + + + + {{ t("connection.globalConnectTimeoutHint") }} + + +
+
- +
@@ -7750,13 +7798,23 @@ function openExternalUrl(url: string) {
- - +
+ + + + + + + {{ t("connection.globalQueryTimeoutHint") }} + + +
+
- +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 85b234e681..3211a5240e 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1012,6 +1012,8 @@ export default { connectTimeout: "Connection Timeout (seconds)", queryTimeout: "Query Timeout (seconds)", useGlobalQueryTimeout: "Global", + globalConnectTimeoutHint: "Range: 1 to 300 seconds", + globalQueryTimeoutHint: "Range: 0 to 3600 seconds. Set to 0 for no timeout.", useConnectionQueryTimeout: "Connection", idleTimeout: "Idle Timeout (seconds)", keepaliveInterval: "Keepalive Interval (seconds)", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index a3f8fa6201..fcc5bc137d 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -795,6 +795,8 @@ export default withEnglishFallback({ connectTimeout: "Tiempo de espera de conexión (segundos)", queryTimeout: "Tiempo de espera de consulta (segundos)", useGlobalQueryTimeout: "Global", + globalConnectTimeoutHint: "Rango: de 1 a 300 segundos", + globalQueryTimeoutHint: "Rango: de 0 a 3600 segundos. Establece 0 para no aplicar límite.", useConnectionQueryTimeout: "Conexión", idleTimeout: "Tiempo de espera inactivo (segundos)", keepaliveInterval: "Intervalo de keepalive (segundos)", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 6fb5d0552a..9393c9ebae 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -793,6 +793,8 @@ export default withEnglishFallback({ connectTimeout: "Timeout Connessione (secondi)", queryTimeout: "Timeout Query (secondi)", useGlobalQueryTimeout: "Globale", + globalConnectTimeoutHint: "Intervallo: da 1 a 300 secondi", + globalQueryTimeoutHint: "Intervallo: da 0 a 3600 secondi. Imposta 0 per nessun limite.", useConnectionQueryTimeout: "Connessione", idleTimeout: "Timeout Inattività (secondi)", keepaliveInterval: "Intervallo keepalive (secondi)", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 821ba39ce6..838291ea03 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -759,6 +759,8 @@ export default withEnglishFallback({ connectTimeout: "接続タイムアウト(秒)", queryTimeout: "クエリタイムアウト(秒)", useGlobalQueryTimeout: "グローバル", + globalConnectTimeoutHint: "1~300 秒の範囲で指定します", + globalQueryTimeoutHint: "0~3600 秒の範囲で指定します。0 はタイムアウトなしです。", useConnectionQueryTimeout: "接続", idleTimeout: "アイドルタイムアウト(秒)", keepaliveInterval: "Keepalive 間隔(秒)", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 759a2c027e..ccae270362 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -909,6 +909,8 @@ export default withEnglishFallback({ connectTimeout: "연결 제한 시간 (초)", queryTimeout: "쿼리 제한 시간 (초)", useGlobalQueryTimeout: "전역", + globalConnectTimeoutHint: "범위: 1~300초", + globalQueryTimeoutHint: "범위: 0~3600초. 0으로 설정하면 제한이 없습니다.", useConnectionQueryTimeout: "연결", idleTimeout: "유휴 제한 시간 (초)", keepaliveInterval: "킵얼라이브 간격 (초)", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 3dcf5692c1..2e029d6be8 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -794,6 +794,8 @@ export default withEnglishFallback({ connectTimeout: "Timeout de Conexão (segundos)", queryTimeout: "Timeout de Consulta (segundos)", useGlobalQueryTimeout: "Global", + globalConnectTimeoutHint: "Intervalo: de 1 a 300 segundos", + globalQueryTimeoutHint: "Intervalo: de 0 a 3600 segundos. Defina 0 para não haver limite.", useConnectionQueryTimeout: "Conexão", idleTimeout: "Timeout de Inatividade (segundos)", keepaliveInterval: "Intervalo de keepalive (segundos)", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index ec0746bba7..63c0a16b73 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -940,6 +940,8 @@ export default withEnglishFallback({ connectTimeout: "连接超时(秒)", queryTimeout: "查询超时(秒)", useGlobalQueryTimeout: "全局", + globalConnectTimeoutHint: "范围为1~300秒", + globalQueryTimeoutHint: "范围为0~3600秒。设为0表示不限制", useConnectionQueryTimeout: "当前连接", idleTimeout: "空闲超时(秒)", keepaliveInterval: "保持连接间隔(秒)", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index d48d29e89a..291289f999 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -793,6 +793,8 @@ export default withEnglishFallback({ connectTimeout: "連線逾時(秒)", queryTimeout: "查詢逾時(秒)", useGlobalQueryTimeout: "全域", + globalConnectTimeoutHint: "範圍為1~300秒", + globalQueryTimeoutHint: "範圍為0~3600秒。設為0表示不限制", useConnectionQueryTimeout: "目前連線", idleTimeout: "閒置逾時(秒)", keepaliveInterval: "保持連線間隔(秒)", diff --git a/apps/desktop/src/lib/__tests__/connection/connectionDialogTimeouts.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionDialogTimeouts.spec.ts new file mode 100644 index 0000000000..0a6bd458c7 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/connectionDialogTimeouts.spec.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_CONNECT_TIMEOUT_SECS, normalizeConnectTimeoutSecs } from "@/lib/connection/timeoutLimits"; + +const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8"); + +describe("ConnectionDialog timeout controls", () => { + it("uses separate maximums for connection and query timeouts", () => { + expect(dialogSource).toMatch(/v-model\.number="editGlobalConnectTimeoutSecs"[\s\S]*?min="1"[\s\S]*?:max="MAX_CONNECT_TIMEOUT_SECS"[\s\S]*?step="1"/); + expect(dialogSource).toMatch(/v-model\.number="form\.connect_timeout_secs"[\s\S]*?min="1"[\s\S]*?:max="MAX_CONNECT_TIMEOUT_SECS"[\s\S]*?step="1"/); + expect(dialogSource).toMatch(/v-model\.number="editGlobalQueryTimeoutSecs"[\s\S]*?min="0"[\s\S]*?:max="MAX_QUERY_TIMEOUT_SECS"[\s\S]*?step="1"/); + expect(dialogSource).toMatch(/v-model\.number="form\.query_timeout_secs"[\s\S]*?min="0"[\s\S]*?:max="MAX_QUERY_TIMEOUT_SECS"[\s\S]*?step="1"/); + expect(dialogSource).not.toContain('v-model.number="editGlobalQueryTimeoutSecs" type="number" min="0" max="300"'); + expect(dialogSource).not.toContain('v-model.number="form.query_timeout_secs" type="number" min="0" max="300"'); + expect(dialogSource).toContain("@input=\"clampQueryTimeoutInput($event, 'global')\""); + expect(dialogSource).toContain("@input=\"clampQueryTimeoutInput($event, 'connection')\""); + expect(dialogSource).toContain("@input=\"clampConnectTimeoutInput($event, 'global')\""); + expect(dialogSource).toContain("@input=\"clampConnectTimeoutInput($event, 'connection')\""); + expect(dialogSource).not.toContain('@blur="editGlobalQueryTimeoutSecs = normalizeGlobalQueryTimeoutSecs'); + }); + + it("restores the default when the connection timeout input is cleared", () => { + expect(normalizeConnectTimeoutSecs("")).toBe(DEFAULT_CONNECT_TIMEOUT_SECS); + expect(dialogSource).toContain("normalizeGlobalConnectTimeoutSecs(config.connect_timeout_secs)"); + expect(dialogSource).not.toContain("Number(config.connect_timeout_secs)"); + }); + + it("shows range help beside both global timeout labels", () => { + expect(dialogSource).toContain('t("connection.globalConnectTimeoutHint")'); + expect(dialogSource).toContain('t("connection.globalQueryTimeoutHint")'); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/connection/timeoutInheritanceBackup.spec.ts b/apps/desktop/src/lib/__tests__/connection/timeoutInheritanceBackup.spec.ts new file mode 100644 index 0000000000..774bb00412 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/timeoutInheritanceBackup.spec.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { loadTimeoutInheritanceBackup, TIMEOUT_INHERITANCE_BACKUP_STORAGE_KEY } from "@/lib/connection/timeoutInheritanceBackup"; + +const storage = new Map(); +Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + clear: () => storage.clear(), + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + }, +}); + +describe("timeout inheritance backup", () => { + beforeEach(() => localStorage.clear()); + + it("preserves query timeouts up to one hour while connection timeouts remain capped at 300 seconds", () => { + localStorage.setItem( + TIMEOUT_INHERITANCE_BACKUP_STORAGE_KEY, + JSON.stringify({ + version: 1, + globalConnectTimeoutSecs: 301, + globalQueryTimeoutSecs: 3600, + connectSnapshots: { connection: 301 }, + querySnapshots: { longQuery: 3600, tooLong: 3601, unlimited: 0 }, + }), + ); + + expect(loadTimeoutInheritanceBackup()).toEqual({ + version: 1, + globalConnectTimeoutSecs: 300, + globalQueryTimeoutSecs: 3600, + connectSnapshots: { connection: 300 }, + querySnapshots: { longQuery: 3600, tooLong: 3600, unlimited: 0 }, + }); + }); +}); diff --git a/apps/desktop/src/lib/connection/timeoutInheritanceBackup.ts b/apps/desktop/src/lib/connection/timeoutInheritanceBackup.ts index ccb9f66444..f11a9db4e9 100644 --- a/apps/desktop/src/lib/connection/timeoutInheritanceBackup.ts +++ b/apps/desktop/src/lib/connection/timeoutInheritanceBackup.ts @@ -1,3 +1,5 @@ +import { normalizeConnectTimeoutSecs, normalizeQueryTimeoutSecs } from "@/lib/connection/timeoutLimits"; + export const TIMEOUT_INHERITANCE_BACKUP_STORAGE_KEY = "dbx-timeout-inheritance-backup-v1"; export interface TimeoutInheritanceBackup { @@ -8,12 +10,12 @@ export interface TimeoutInheritanceBackup { querySnapshots: Record; } -function normalizeSnapshots(value: unknown, min: number): Record { +function normalizeSnapshots(value: unknown, normalize: (timeout: unknown) => number): Record { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const snapshots: Record = {}; for (const [id, timeout] of Object.entries(value)) { if (!id.trim() || typeof timeout !== "number" || !Number.isFinite(timeout)) continue; - snapshots[id] = Math.min(300, Math.max(min, Math.round(timeout))); + snapshots[id] = normalize(timeout); } return snapshots; } @@ -29,10 +31,10 @@ export function loadTimeoutInheritanceBackup(): TimeoutInheritanceBackup | null if (!Number.isFinite(globalConnectTimeoutSecs) || !Number.isFinite(globalQueryTimeoutSecs)) return null; return { version: 1, - globalConnectTimeoutSecs: Math.min(300, Math.max(1, Math.round(globalConnectTimeoutSecs))), - globalQueryTimeoutSecs: Math.min(300, Math.max(0, Math.round(globalQueryTimeoutSecs))), - connectSnapshots: normalizeSnapshots(parsed.connectSnapshots, 1), - querySnapshots: normalizeSnapshots(parsed.querySnapshots, 0), + globalConnectTimeoutSecs: normalizeConnectTimeoutSecs(globalConnectTimeoutSecs), + globalQueryTimeoutSecs: normalizeQueryTimeoutSecs(globalQueryTimeoutSecs), + connectSnapshots: normalizeSnapshots(parsed.connectSnapshots, normalizeConnectTimeoutSecs), + querySnapshots: normalizeSnapshots(parsed.querySnapshots, normalizeQueryTimeoutSecs), }; } catch { return null; diff --git a/apps/desktop/src/lib/connection/timeoutLimits.ts b/apps/desktop/src/lib/connection/timeoutLimits.ts new file mode 100644 index 0000000000..3725d24928 --- /dev/null +++ b/apps/desktop/src/lib/connection/timeoutLimits.ts @@ -0,0 +1,14 @@ +export const DEFAULT_CONNECT_TIMEOUT_SECS = 10; +export const MAX_CONNECT_TIMEOUT_SECS = 300; +export const DEFAULT_QUERY_TIMEOUT_SECS = 30; +export const MAX_QUERY_TIMEOUT_SECS = 3600; + +export function normalizeConnectTimeoutSecs(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_CONNECT_TIMEOUT_SECS; + return Math.min(MAX_CONNECT_TIMEOUT_SECS, Math.max(1, Math.round(value))); +} + +export function normalizeQueryTimeoutSecs(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_QUERY_TIMEOUT_SECS; + return Math.min(MAX_QUERY_TIMEOUT_SECS, Math.max(0, Math.round(value))); +} diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index b4be2964f3..20cabf1664 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -238,7 +238,9 @@ describe("normalizeEditorSettings", () => { expect(normalizeEditorSettings({}).globalQueryTimeoutSecs).toBe(30); expect(normalizeEditorSettings({ queryTimeoutSecs: 45 } as any).globalQueryTimeoutSecs).toBe(45); expect(normalizeEditorSettings({ globalQueryTimeoutSecs: -1 }).globalQueryTimeoutSecs).toBe(0); - expect(normalizeEditorSettings({ globalQueryTimeoutSecs: 301 }).globalQueryTimeoutSecs).toBe(300); + expect(normalizeEditorSettings({ globalQueryTimeoutSecs: 301 }).globalQueryTimeoutSecs).toBe(301); + expect(normalizeEditorSettings({ globalQueryTimeoutSecs: 3600 }).globalQueryTimeoutSecs).toBe(3600); + expect(normalizeEditorSettings({ globalQueryTimeoutSecs: 3601 }).globalQueryTimeoutSecs).toBe(3600); expect(normalizeEditorSettings({ connectTimeoutInheritConnectionIds: ["one", "one", " ", "two"] }).connectTimeoutInheritConnectionIds).toEqual(["one", "two"]); expect(normalizeEditorSettings({ queryTimeoutInheritConnectionIds: ["one", "one", " ", "two"] }).queryTimeoutInheritConnectionIds).toEqual(["one", "two"]); expect(normalizeEditorSettings({}).timeoutInheritanceMigrationVersion).toBe(0); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index dc1b52f8a6..5d36ce6cb8 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -9,6 +9,7 @@ import { type ColumnFormatterConfig, type CustomColumnFormatterConfig, normalize import { type DataGridCopyPreference, type DataGridExtractorOptions, DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridCopyPreference, normalizeDataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor"; import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; import { DEFAULT_QUERY_RESULT_MAX_ROWS, normalizeQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit"; +import { normalizeConnectTimeoutSecs, normalizeQueryTimeoutSecs } from "@/lib/connection/timeoutLimits"; import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry"; import type { SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget"; import type { ConnectionListSortMode } from "@/lib/sidebar/connectionListSort"; @@ -800,13 +801,11 @@ const MIN_UI_SCALE = 0.75; const MAX_UI_SCALE = 2; export function normalizeGlobalQueryTimeoutSecs(value: unknown): number { - if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_EDITOR_SETTINGS.globalQueryTimeoutSecs; - return Math.min(300, Math.max(0, Math.round(value))); + return normalizeQueryTimeoutSecs(value); } export function normalizeGlobalConnectTimeoutSecs(value: unknown): number { - if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_EDITOR_SETTINGS.globalConnectTimeoutSecs; - return Math.min(300, Math.max(1, Math.round(value))); + return normalizeConnectTimeoutSecs(value); } function normalizeUiScale(value: unknown): number {