From 3c8692b133f8d9cc6bfe2e987608c9bb2434d80b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 21 Jul 2026 18:41:58 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(connection/elasticsearch):?= =?UTF-8?q?=20=E4=BF=AE=E5=A4=8D=E7=B4=A2=E5=BC=95=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E4=B8=8E=E6=B5=8B=E8=AF=95=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 CAT Indices 轻量枚举索引并兼容 ES 7.3 参数 - CAT 失败时在统一超时预算内回退 Alias API - 分离连接测试与保存状态并后台加载数据库列表 - 隔离过期测试请求,避免弹窗重开后状态污染 - 补充索引回退、超时预算与按钮状态回归测试 --- .../components/ConnectionModal.i18n.test.tsx | 139 ++++++++++++- frontend/src/components/ConnectionModal.tsx | 186 +++++++++++------- internal/db/driver_agent_revisions_gen.go | 2 +- internal/db/elasticsearch_impl.go | 150 ++++++++++++-- internal/db/elasticsearch_impl_test.go | 164 ++++++++++++--- 5 files changed, 524 insertions(+), 117 deletions(-) diff --git a/frontend/src/components/ConnectionModal.i18n.test.tsx b/frontend/src/components/ConnectionModal.i18n.test.tsx index f9525803..a5faa94c 100644 --- a/frontend/src/components/ConnectionModal.i18n.test.tsx +++ b/frontend/src/components/ConnectionModal.i18n.test.tsx @@ -24,6 +24,7 @@ const notifyStoreSubscribers = () => { }; let mockFormValues: Record = {}; +let mockValidateFields: (() => Promise) | undefined; const antdMessage = vi.hoisted(() => ({ error: vi.fn(), @@ -223,7 +224,7 @@ vi.mock("antd", () => { const Switch = () => ; const formApi = { - validateFields: vi.fn(() => Promise.resolve()), + validateFields: vi.fn(() => mockValidateFields?.() ?? Promise.resolve()), getFieldsValue: vi.fn(() => ({ type: "mysql", timeout: 30, @@ -363,6 +364,7 @@ describe("ConnectionModal i18n", () => { storeState.updateConnection.mockReset(); storeState.setLanguagePreference.mockClear(); mockFormValues = {}; + mockValidateFields = undefined; setCurrentLanguage("zh-CN"); }); @@ -1030,6 +1032,7 @@ describe("ConnectionModal i18n", () => { it("retranslates test failure feedback while preserving raw detail when language changes in-place", async () => { storeState.appearance.uiVersion = "legacy"; setCurrentLanguage("zh-CN"); + backendApp.TestConnection.mockReset(); backendApp.TestConnection.mockResolvedValue({ success: false, message: "backend raw error: /tmp/app.db", @@ -1067,6 +1070,140 @@ describe("ConnectionModal i18n", () => { expect(pageText).toContain("backend raw error: /tmp/app.db"); }); + it("stops connection action loading before optional database discovery finishes", async () => { + storeState.appearance.uiVersion = "legacy"; + setCurrentLanguage("zh-CN"); + backendApp.TestConnection.mockResolvedValue({ + success: true, + message: "连接正常", + }); + let resolveDatabases: ((value: { success: true; data: unknown[] }) => void) | undefined; + backendApp.DBGetDatabases.mockReset(); + backendApp.DBGetDatabases.mockReturnValue( + new Promise((resolve) => { + resolveDatabases = resolve; + }), + ); + const { default: ConnectionModal } = await import("./ConnectionModal"); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + + await act(async () => { + findButton(renderer!, "测试连接").props.onClick(); + await flushConnectionTestTick(); + }); + + expect(textContent(renderer!.toJSON())).toContain("连接成功"); + expect(findButton(renderer!, "测试连接").props.disabled).toBe(false); + expect(findButton(renderer!, "保存").props.disabled).toBe(false); + expect(backendApp.DBGetDatabases).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveDatabases?.({ success: true, data: [] }); + await flushConnectionTestTick(); + }); + }); + + it("does not let a stale validation run restart loading after the modal reopens", async () => { + storeState.appearance.uiVersion = "legacy"; + setCurrentLanguage("zh-CN"); + let resolveValidation: (() => void) | undefined; + mockValidateFields = () => + new Promise((resolve) => { + resolveValidation = resolve; + }); + backendApp.TestConnection.mockReset(); + backendApp.TestConnection.mockRejectedValue(new Error("stale validation failure")); + const { default: ConnectionModal } = await import("./ConnectionModal"); + const connection = initialConnection("elasticsearch", { port: 9200 }); + const onClose = vi.fn(); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await act(async () => { + findButton(renderer!, "测试连接").props.onClick(); + await flushConnectionTestTick(); + }); + await act(async () => { + renderer!.update( + , + ); + }); + await act(async () => { + renderer!.update( + , + ); + }); + await act(async () => { + resolveValidation?.(); + await flushConnectionTestTick(); + }); + + expect(backendApp.TestConnection).not.toHaveBeenCalled(); + expect(textContent(renderer!.toJSON())).not.toContain("stale validation failure"); + expect(findButton(renderer!, "测试连接").props.disabled).toBe(false); + expect(findButton(renderer!, "保存").props.disabled).toBe(false); + }); + + it("ignores a stale connection-test rejection after the modal reopens", async () => { + storeState.appearance.uiVersion = "legacy"; + setCurrentLanguage("zh-CN"); + let rejectConnection: ((reason?: unknown) => void) | undefined; + backendApp.TestConnection.mockReset(); + backendApp.TestConnection.mockReturnValue( + new Promise((_, reject) => { + rejectConnection = reject; + }), + ); + const { default: ConnectionModal } = await import("./ConnectionModal"); + const connection = initialConnection("elasticsearch", { port: 9200 }); + const onClose = vi.fn(); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await act(async () => { + findButton(renderer!, "测试连接").props.onClick(); + await flushConnectionTestTick(); + }); + expect(backendApp.TestConnection).toHaveBeenCalledTimes(1); + + await act(async () => { + renderer!.update( + , + ); + }); + await act(async () => { + renderer!.update( + , + ); + }); + await act(async () => { + rejectConnection?.(new Error("stale connection failure")); + await flushConnectionTestTick(); + }); + + expect(textContent(renderer!.toJSON())).not.toContain("stale connection failure"); + expect(findButton(renderer!, "测试连接").props.disabled).toBe(false); + expect(findButton(renderer!, "保存").props.disabled).toBe(false); + }); + it("renders English data source groups and hints for the remaining step one copy", async () => { storeState.appearance.uiVersion = "legacy"; setCurrentLanguage("en-US"); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 890c0574..da9e8da8 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -328,7 +328,8 @@ const ConnectionModal: React.FC<{ onSaved?: (savedConnection: SavedConnection) => void | Promise; }> = ({ open, onClose, initialValues, onOpenDriverManager, onSaved }) => { const [form] = Form.useForm(); - const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [testingConnection, setTestingConnection] = useState(false); const [useSSL, setUseSSL] = useState(false); const [useSSH, setUseSSH] = useState(false); const [useProxy, setUseProxy] = useState(false); @@ -374,6 +375,7 @@ const ConnectionModal: React.FC<{ const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false); const testInFlightRef = useRef(false); const testTimerRef = useRef(null); + const testRunIdRef = useRef(0); const addConnection = useStore((state) => state.addConnection); const updateConnection = useStore((state) => state.updateConnection); const theme = useStore((state) => state.theme); @@ -1325,8 +1327,10 @@ const ConnectionModal: React.FC<{ }; useEffect(() => { + testRunIdRef.current += 1; if (open) { - setLoading(false); + setSaving(false); + setTestingConnection(false); testInFlightRef.current = false; if (testTimerRef.current !== null) { window.clearTimeout(testTimerRef.current); @@ -1664,6 +1668,7 @@ const ConnectionModal: React.FC<{ useEffect(() => { return () => { + testRunIdRef.current += 1; if (testTimerRef.current !== null) { window.clearTimeout(testTimerRef.current); testTimerRef.current = null; @@ -1687,7 +1692,7 @@ const ConnectionModal: React.FC<{ ); return; } - setLoading(true); + setSaving(true); const config = await buildConnectionConfig({ values, @@ -1745,12 +1750,12 @@ const ConnectionModal: React.FC<{ ), ); } finally { - setLoading(false); + setSaving(false); } }; const requestTest = () => { - if (loading) return; + if (saving || testingConnection) return; if (testTimerRef.current !== null) return; testTimerRef.current = window.setTimeout(() => { testTimerRef.current = null; @@ -1802,13 +1807,17 @@ const ConnectionModal: React.FC<{ const handleTest = async () => { if (testInFlightRef.current) return; testInFlightRef.current = true; + const testRunId = ++testRunIdRef.current; + const isCurrentTestRun = () => testRunIdRef.current === testRunId; try { await form.validateFields(); + if (!isCurrentTestRun()) return; const values = form.getFieldsValue(true); const unavailableReason = await resolveDriverUnavailableReason( values.type, values.driver, ); + if (!isCurrentTestRun()) return; if (unavailableReason) { applyTestFailureFeedback({ kind: "driver_unavailable", @@ -1835,7 +1844,7 @@ const ConnectionModal: React.FC<{ }); return; } - setLoading(true); + setTestingConnection(true); setTestResult(null); const config = await buildConnectionConfig({ values, @@ -1843,6 +1852,7 @@ const ConnectionModal: React.FC<{ initialValues, translate: t, }); + if (!isCurrentTestRun()) return; if (initialValues?.id) { config.id = initialValues.id; } @@ -1868,79 +1878,100 @@ const ConnectionModal: React.FC<{ t("connection.modal.test.timeout", { seconds: timeoutSeconds }), ); + if (!isCurrentTestRun()) return; + if (res.success) { void message.destroy("connection-test-failure"); setTestResult({ type: "success", message: res.message }); - if (isRedisType) { - const dbRes = await withClientTimeout( - RedisGetDatabases(config as any), - rpcTimeoutMs, - t("connection.modal.test.redis_database_list_timeout", { - seconds: timeoutSeconds, - }), - ); - if (dbRes.success) { - const supportedDbs = extractRedisDatabaseList(dbRes.data); - setRedisDbList(supportedDbs); - form.setFieldValue( - "includeRedisDatabases", - normalizeRedisDatabaseSelection( - form.getFieldValue("includeRedisDatabases"), - supportedDbs, - ), - ); - } else { - setRedisDbList( - buildRedisDatabaseList( - config.redisDB, - form.getFieldValue("includeRedisDatabases"), - ), - ); - message.warning( - t("connection.modal.test.redis_database_list_failure", { - detail: normalizeConnectionSecretErrorMessage( - dbRes.message, - t("connection.modal.error.unknown"), - ), - }), - ); - } - } else if (!isJVMType) { - // Other databases: fetch database list - const dbRes = await withClientTimeout( - DBGetDatabases(dbTestConfig as any), - rpcTimeoutMs, - t("connection.modal.test.databaseListTimeout", { - seconds: timeoutSeconds, - }), - ); - if (dbRes.success) { - const dbRows = Array.isArray(dbRes.data) ? dbRes.data : []; - const dbs = dbRows - .map((row: any) => row?.Database || row?.database) - .filter( - (name: any) => typeof name === "string" && name.trim() !== "", + void (async () => { + try { + if (isRedisType) { + const dbRes = await withClientTimeout( + RedisGetDatabases(config as any), + rpcTimeoutMs, + t("connection.modal.test.redis_database_list_timeout", { + seconds: timeoutSeconds, + }), ); - setDbList(dbs); - if (dbs.length === 0) { - message.warning( - values.type === "dameng" - ? t("connection.modal.test.noVisibleSchema") - : t("connection.modal.test.noVisibleDatabaseList"), + if (!isCurrentTestRun()) return; + if (dbRes.success) { + const supportedDbs = extractRedisDatabaseList(dbRes.data); + setRedisDbList(supportedDbs); + form.setFieldValue( + "includeRedisDatabases", + normalizeRedisDatabaseSelection( + form.getFieldValue("includeRedisDatabases"), + supportedDbs, + ), + ); + } else { + setRedisDbList( + buildRedisDatabaseList( + config.redisDB, + form.getFieldValue("includeRedisDatabases"), + ), + ); + message.warning( + t("connection.modal.test.redis_database_list_failure", { + detail: normalizeConnectionSecretErrorMessage( + dbRes.message, + t("connection.modal.error.unknown"), + ), + }), + ); + } + } else if (!isJVMType) { + const dbRes = await withClientTimeout( + DBGetDatabases(dbTestConfig as any), + rpcTimeoutMs, + t("connection.modal.test.databaseListTimeout", { + seconds: timeoutSeconds, + }), ); + if (!isCurrentTestRun()) return; + if (dbRes.success) { + const dbRows = Array.isArray(dbRes.data) ? dbRes.data : []; + const dbs = dbRows + .map((row: any) => row?.Database || row?.database) + .filter( + (name: any) => + typeof name === "string" && name.trim() !== "", + ); + setDbList(dbs); + if (dbs.length === 0) { + message.warning( + values.type === "dameng" + ? t("connection.modal.test.noVisibleSchema") + : t("connection.modal.test.noVisibleDatabaseList"), + ); + } + } else { + setDbList([]); + message.warning( + t("connection.modal.test.databaseListFailure", { + detail: normalizeConnectionSecretErrorMessage( + dbRes.message, + t("connection.modal.error.unknown"), + ), + }), + ); + } } - } else { - setDbList([]); + } catch (error: unknown) { + if (!isCurrentTestRun()) return; + const detail = normalizeConnectionSecretErrorMessage( + error instanceof Error ? error.message : String(error), + t("connection.modal.error.unknown"), + ); message.warning( - t("connection.modal.test.databaseListFailure", { - detail: normalizeConnectionSecretErrorMessage( - dbRes.message, - t("connection.modal.error.unknown"), - ), - }), + isRedisType + ? t("connection.modal.test.redis_database_list_failure", { + detail, + }) + : t("connection.modal.test.databaseListFailure", { detail }), ); } - } + })(); } else { applyTestFailureFeedback({ kind: "runtime", @@ -1949,6 +1980,7 @@ const ConnectionModal: React.FC<{ }); } } catch (e: unknown) { + if (!isCurrentTestRun()) return; if (e && typeof e === "object" && "errorFields" in e) { applyTestFailureFeedback({ kind: "validation", @@ -1968,8 +2000,10 @@ const ConnectionModal: React.FC<{ fallbackKey: "connection.modal.test.fallback.unknownException", }); } finally { - testInFlightRef.current = false; - setLoading(false); + if (isCurrentTestRun()) { + testInFlightRef.current = false; + setTestingConnection(false); + } } }; @@ -2708,8 +2742,8 @@ const ConnectionModal: React.FC<{