fix(mysql): pin dashboard polling to one client session

Closes #5969
This commit is contained in:
t8y2
2026-08-12 04:28:00 +00:00
parent 5433b61d3e
commit 0e42de5f7f
5 changed files with 143 additions and 4 deletions
@@ -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;
@@ -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<Element> | 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);
});
});
@@ -2100,7 +2100,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<template v-else-if="activeTab.mode === 'mysql-dashboard'">
<div class="min-h-0 flex-1">
<MySqlDashboard :key="activeTab.id" :connection-id="activeTab.connectionId" />
<MySqlDashboard :key="activeTab.id" :connection-id="activeTab.connectionId" :client-session-id="activeTab.id" />
</div>
</template>
+1 -1
View File
@@ -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,
+36
View File
@@ -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();