diff --git a/apps/desktop/src/components/admin/MySqlDashboard.vue b/apps/desktop/src/components/admin/MySqlDashboard.vue index 8db6b465a6..9f982f907a 100644 --- a/apps/desktop/src/components/admin/MySqlDashboard.vue +++ b/apps/desktop/src/components/admin/MySqlDashboard.vue @@ -14,6 +14,7 @@ import { useVerticalOverlayScrollbar } from "@/composables/useVerticalOverlayScr const props = defineProps<{ connectionId: string; + clientSessionId: string; }>(); const { t } = useI18n(); @@ -115,7 +116,7 @@ function formatClock(at: number): string { async function fetchVariables() { try { - const result = await api.executeQuery(props.connectionId, "", GLOBAL_VARIABLES_SQL, undefined, undefined, { maxRows: 2000 }); + const result = await api.executeQuery(props.connectionId, "", GLOBAL_VARIABLES_SQL, undefined, undefined, { maxRows: 2000, clientSessionId: props.clientSessionId }); variables.value = parseStatusResult(result); } catch { // Non-fatal: cards that depend on variables (max_connections/version) degrade. @@ -129,7 +130,7 @@ async function fetchStatus(options: { silent?: boolean } = {}) { error.value = ""; try { await connectionStore.ensureConnected(props.connectionId); - const result = await api.executeQuery(props.connectionId, "", GLOBAL_STATUS_SQL, undefined, undefined, { maxRows: 2000 }); + const result = await api.executeQuery(props.connectionId, "", GLOBAL_STATUS_SQL, undefined, undefined, { maxRows: 2000, clientSessionId: props.clientSessionId }); const sample: StatusSample = { at: Date.now(), status: parseStatusResult(result) }; const next = [...samples.value, sample]; samples.value = next.length > MAX_SAMPLES ? next.slice(next.length - MAX_SAMPLES) : next; diff --git a/apps/desktop/src/components/admin/__tests__/MySqlDashboard.spec.ts b/apps/desktop/src/components/admin/__tests__/MySqlDashboard.spec.ts new file mode 100644 index 0000000000..9e077c8f6a --- /dev/null +++ b/apps/desktop/src/components/admin/__tests__/MySqlDashboard.spec.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { createApp, nextTick, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GLOBAL_STATUS_SQL, GLOBAL_VARIABLES_SQL } from "@/lib/database/mysqlServerStatus"; + +const mocks = vi.hoisted(() => ({ + ensureConnected: vi.fn(), + executeQuery: vi.fn(), +})); + +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + ensureConnected: mocks.ensureConnected, + getConfig: () => ({ name: "MySQL" }), + }), +})); +vi.mock("@/lib/backend/api", () => ({ executeQuery: mocks.executeQuery })); +vi.mock("@/composables/useVerticalOverlayScrollbar", async () => { + const { ref } = await import("vue"); + return { + useVerticalOverlayScrollbar: () => ({ + hasOverflow: ref(false), + isScrolling: ref(false), + isDragging: ref(false), + thumbStyle: ref({}), + onScroll: vi.fn(), + onTrackPointerDown: vi.fn(), + onThumbPointerDown: vi.fn(), + }), + }; +}); +vi.mock("@lucide/vue", async () => { + const { defineComponent, h } = await import("vue"); + const Icon = defineComponent({ setup: () => () => h("i") }); + return { Activity: Icon, ArrowDownUp: Icon, ChevronRight: Icon, Database: Icon, Gauge: Icon, Loader2: Icon, RefreshCcw: Icon, Search: Icon, Timer: Icon, TriangleAlert: Icon, Users: Icon }; +}); + +async function passthroughComponent() { + const { defineComponent, h } = await import("vue"); + return defineComponent({ + setup: + (_, { slots }) => + () => + h("div", slots.default?.()), + }); +} + +vi.mock("@/components/ui/button", async () => ({ Button: await passthroughComponent() })); +vi.mock("@/components/ui/badge", async () => ({ Badge: await passthroughComponent() })); +vi.mock("@/components/ui/select", async () => { + const component = await passthroughComponent(); + return { Select: component, SelectContent: component, SelectItem: component, SelectTrigger: component, SelectValue: component }; +}); +vi.mock("@/components/common/MetricCard.vue", async () => ({ default: await passthroughComponent() })); +vi.mock("@/components/chart/MetricLineChart.vue", async () => ({ default: await passthroughComponent() })); + +import MySqlDashboard from "@/components/admin/MySqlDashboard.vue"; + +let app: App | null = null; +let root: HTMLDivElement | null = null; + +async function flushMountedPolling() { + for (let i = 0; i < 4; i++) { + await Promise.resolve(); + await nextTick(); + } +} + +beforeEach(() => { + mocks.ensureConnected.mockReset().mockResolvedValue(undefined); + mocks.executeQuery.mockReset().mockImplementation(async (_connectionId: string, _database: string, sql: string) => ({ + columns: ["Variable_name", "Value"], + rows: sql === GLOBAL_STATUS_SQL ? [["Queries", "10"]] : [["version", "8.0.11"]], + affected_rows: 0, + execution_time_ms: 0, + })); + root = document.createElement("div"); + document.body.appendChild(root); +}); + +afterEach(() => { + app?.unmount(); + app = null; + root?.remove(); + root = null; + document.body.innerHTML = ""; +}); + +describe("MySqlDashboard session affinity", () => { + it("uses the dashboard tab session for status and variable polling", async () => { + app = createApp(MySqlDashboard, { connectionId: "mysql-1", clientSessionId: "tab-dashboard" }); + app.mount(root!); + await flushMountedPolling(); + + expect(mocks.ensureConnected).toHaveBeenCalledWith("mysql-1"); + expect(mocks.executeQuery).toHaveBeenNthCalledWith(1, "mysql-1", "", GLOBAL_STATUS_SQL, undefined, undefined, { maxRows: 2000, clientSessionId: "tab-dashboard" }); + expect(mocks.executeQuery).toHaveBeenNthCalledWith(2, "mysql-1", "", GLOBAL_VARIABLES_SQL, undefined, undefined, { maxRows: 2000, clientSessionId: "tab-dashboard" }); + expect(mocks.executeQuery).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 03830b3cc4..e36c7f578d 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -2100,7 +2100,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 4f505462cd..a4be214715 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1793,7 +1793,7 @@ export const useQueryStore = defineStore("query", () => { id, title: conn?.name ? `${conn.name} - ${t("serverDashboard.title")}` : t("serverDashboard.title"), connectionId, - database: conn?.database || "", + database: "", sql: "", isExecuting: false, isCancelling: false, diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 4fc9407b93..afcb1c5e58 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -6103,6 +6103,42 @@ test("closing a data tab releases its tab-scoped client session", async () => { } }); +test("closing a MySQL dashboard releases its tab-scoped client session", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + + connectionStore.addEphemeralConnection({ ...conn("mysql-1"), db_type: "mysql", port: 3306, database: "app" }); + const tabId = store.openMysqlDashboard("mysql-1"); + const closedSessions: any[] = []; + + globalThis.fetch = async (input, init) => { + if (String(input) === "/api/query/close-client-session") { + closedSessions.push(JSON.parse(String(init?.body ?? "{}"))); + return new Response(JSON.stringify(true), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }; + + try { + store.closeTab(tabId, { force: true }); + + await waitFor(() => closedSessions.some((body) => body.clientSessionId === tabId)); + assert.ok( + closedSessions.some((body) => body.connectionId === "mysql-1" && body.database === "" && body.clientSessionId === tabId), + `expected close-client-session for MySQL dashboard, got ${JSON.stringify(closedSessions)}`, + ); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + for (const dbType of ["oracle", "dameng", "gaussdb", "oceanbase-oracle"] as const) { test(`clearing a ${dbType} query schema releases its tab-scoped client session`, async () => { const restoreStorage = installMemoryStorage();