mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 03:52:39 +00:00
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user