mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(admin): terminate multiple selected process list sessions
Closes #9778
This commit is contained in:
@@ -59,13 +59,16 @@ const terminating = ref(false);
|
||||
const batchSupported = computed(() => driver.value?.supportsBatchCancel === true);
|
||||
// Engines without a terminate statement only expose query cancellation.
|
||||
const terminateSupported = computed(() => typeof driver.value?.buildTerminateSessionSql === "function");
|
||||
// Multi-selection serves batch cancel and/or batch terminate; either enables the checkboxes.
|
||||
const selectionSupported = computed(() => batchSupported.value || terminateSupported.value);
|
||||
const selectedIds = ref(new Set<number>());
|
||||
// Anchor row for Shift+click range selection, in the current display order.
|
||||
const selectionAnchorId = ref<number | null>(null);
|
||||
const batchTargets = ref<ProcessRow[] | null>(null);
|
||||
const batchResult = ref<{ succeeded: number; failures: { id: number; message: string }[] } | null>(null);
|
||||
const batchTerminateTargets = ref<ProcessRow[] | null>(null);
|
||||
const batchResult = ref<{ kind: "cancel" | "terminate"; succeeded: number; failures: { id: number; message: string }[] } | null>(null);
|
||||
const refreshing = ref(false);
|
||||
const actionsLocked = computed(() => canceling.value || cancelTarget.value !== null || terminating.value || terminateTarget.value !== null || batchTargets.value !== null);
|
||||
const actionsLocked = computed(() => canceling.value || cancelTarget.value !== null || terminating.value || terminateTarget.value !== null || batchTargets.value !== null || batchTerminateTargets.value !== null);
|
||||
let connectionGeneration = 0;
|
||||
let disposed = false;
|
||||
const fallbackListSql = ref<string | null>(null);
|
||||
@@ -165,7 +168,7 @@ async function confirmBatchCancel() {
|
||||
sql: statements.map((statement) => statement.sql).join(";\n"),
|
||||
source: t("production.sourceAdmin"),
|
||||
execute: async () => {
|
||||
const summary = { succeeded: 0, failures: [] as { id: number; message: string }[] };
|
||||
const summary = { kind: "cancel" as const, succeeded: 0, failures: [] as { id: number; message: string }[] };
|
||||
for (const statement of statements) {
|
||||
// A closed tab or changed connection must not dispatch remaining work.
|
||||
if (!isCurrent()) break;
|
||||
@@ -194,6 +197,79 @@ async function confirmBatchCancel() {
|
||||
}
|
||||
}
|
||||
|
||||
function requestBatchTerminate() {
|
||||
if (!terminateSupported.value || actionsLocked.value || refreshing.value || ownSessionId.value === null) return;
|
||||
const targets = selectableRows.value.filter((row) => selectedIds.value.has(row.id));
|
||||
if (targets.length) batchTerminateTargets.value = targets.map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the selected sessions one by one. Each statement runs on its own so a
|
||||
* failure on one session (permissions, an id that already exited) is reported in
|
||||
* the summary instead of aborting the remaining terminations, mirroring batch
|
||||
* cancel. The `terminating` mutex is shared with the single-row terminate action.
|
||||
*/
|
||||
async function confirmBatchTerminate() {
|
||||
const targets = batchTerminateTargets.value;
|
||||
const activeDriver = driver.value;
|
||||
const buildTerminateSql = activeDriver?.buildTerminateSessionSql;
|
||||
if (!targets?.length || !activeDriver || !buildTerminateSql || terminating.value) return;
|
||||
const connection = { ...props.connection };
|
||||
const generation = connectionGeneration;
|
||||
const isCurrent = () => !disposed && generation === connectionGeneration;
|
||||
terminating.value = true;
|
||||
let executed = false;
|
||||
try {
|
||||
const statements = targets.map((row) => ({ id: row.id, sql: buildTerminateSql.call(activeDriver, row.id) }));
|
||||
const executeTerminateSql = async (sql: string) => {
|
||||
const results = await api.executeMulti(connection.id, "", sql, undefined, undefined, { maxRows: 1 });
|
||||
const executionError = processListExecutionError(results);
|
||||
if (executionError) throw new Error(executionError);
|
||||
return results;
|
||||
};
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection,
|
||||
database: "",
|
||||
sql: statements.map((statement) => statement.sql).join(";\n"),
|
||||
source: t("production.sourceAdmin"),
|
||||
execute: async () => {
|
||||
const summary = { kind: "terminate" as const, succeeded: 0, failures: [] as { id: number; message: string }[] };
|
||||
for (const statement of statements) {
|
||||
// A closed tab or changed connection must not dispatch remaining work.
|
||||
if (!isCurrent()) break;
|
||||
executed = true;
|
||||
try {
|
||||
let usedFallbackTerminateSql = false;
|
||||
let results;
|
||||
try {
|
||||
results = await executeTerminateSql(statement.sql);
|
||||
} catch (error) {
|
||||
if (!activeDriver.buildFallbackTerminateSessionSql || !activeDriver.shouldUseFallbackTerminateSessionSql?.(error)) throw error;
|
||||
usedFallbackTerminateSql = true;
|
||||
results = await executeTerminateSql(activeDriver.buildFallbackTerminateSessionSql(statement.id));
|
||||
}
|
||||
const terminateResultError = usedFallbackTerminateSql ? activeDriver.fallbackTerminateSessionResultError?.(results) : activeDriver.terminateSessionResultError?.(results);
|
||||
if (terminateResultError) throw new Error(terminateResultError);
|
||||
summary.succeeded++;
|
||||
} catch (error: unknown) {
|
||||
summary.failures.push({ id: statement.id, message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
},
|
||||
});
|
||||
if (result === undefined || !isCurrent()) return;
|
||||
batchResult.value = result;
|
||||
batchTerminateTargets.value = null;
|
||||
clearSelection();
|
||||
} catch (error: unknown) {
|
||||
if (isCurrent()) toast(t("processList.terminateFailed", { message: error instanceof Error ? error.message : String(error) }), 5000);
|
||||
} finally {
|
||||
terminating.value = false;
|
||||
if (!disposed && (executed || !isCurrent())) void load({ silent: true });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSort(key: string) {
|
||||
if (sortKey.value === key) {
|
||||
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
||||
@@ -402,6 +478,7 @@ watch(
|
||||
connectionGeneration++;
|
||||
clearSelection();
|
||||
batchTargets.value = null;
|
||||
batchTerminateTargets.value = null;
|
||||
batchResult.value = null;
|
||||
cancelTarget.value = null;
|
||||
fallbackListSql.value = null;
|
||||
@@ -446,6 +523,19 @@ onBeforeUnmount(() => {
|
||||
<Ban v-else class="h-3.5 w-3.5" />
|
||||
{{ t("processList.batchCancel", { count: selectedIds.size }) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="terminateSupported"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="h-7 gap-1.5 px-2 text-xs"
|
||||
:disabled="actionsLocked || refreshing || selectedIds.size === 0 || ownSessionId === null"
|
||||
:title="ownSessionId === null ? t('processList.batchNeedsSession') : undefined"
|
||||
@click="requestBatchTerminate"
|
||||
>
|
||||
<Loader2 v-if="terminating && batchTerminateTargets" class="h-3.5 w-3.5 animate-spin" />
|
||||
<PlugZap v-else class="h-3.5 w-3.5" />
|
||||
{{ t("processList.batchTerminate", { count: selectedIds.size }) }}
|
||||
</Button>
|
||||
<div class="flex h-7 items-center gap-1.5 rounded-md border bg-background px-2">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input v-model="search" :disabled="actionsLocked" class="h-full w-40 min-w-0 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('processList.filter')" />
|
||||
@@ -466,7 +556,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="batchSupported && ownSessionId === null && !refreshing" class="border-b px-3 py-2 text-xs text-muted-foreground">{{ t("processList.batchNeedsSession") }}</div>
|
||||
<div v-if="selectionSupported && ownSessionId === null && !refreshing" class="border-b px-3 py-2 text-xs text-muted-foreground">{{ t("processList.batchNeedsSession") }}</div>
|
||||
|
||||
<div v-if="loadError" class="border-b bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ loadError }}</div>
|
||||
|
||||
@@ -474,7 +564,7 @@ onBeforeUnmount(() => {
|
||||
<table class="w-full border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-muted/40 backdrop-blur">
|
||||
<tr>
|
||||
<th v-if="batchSupported" class="w-8 border-b px-3 py-2">
|
||||
<th v-if="selectionSupported" class="w-8 border-b px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 accent-primary"
|
||||
@@ -498,7 +588,7 @@ onBeforeUnmount(() => {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in filteredRows" :key="row.id" class="border-b hover:bg-accent/40" :class="{ 'bg-primary/5': isOwnSession(row) }">
|
||||
<td v-if="batchSupported" class="px-3 py-1.5">
|
||||
<td v-if="selectionSupported" class="px-3 py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 accent-primary"
|
||||
@@ -551,7 +641,7 @@ onBeforeUnmount(() => {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && filteredRows.length === 0">
|
||||
<td :colspan="columns.length + 1 + (batchSupported ? 1 : 0)" class="px-3 py-10 text-center text-muted-foreground">
|
||||
<td :colspan="columns.length + 1 + (selectionSupported ? 1 : 0)" class="px-3 py-10 text-center text-muted-foreground">
|
||||
{{ search ? t("grid.noSearchResults") : t("processList.empty") }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -652,6 +742,43 @@ onBeforeUnmount(() => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
:open="batchTerminateTargets !== null"
|
||||
@update:open="
|
||||
(open) => {
|
||||
if (!open && !terminating) batchTerminateTargets = null;
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogContent
|
||||
class="max-w-md"
|
||||
:show-close-button="!terminating"
|
||||
@interact-outside="
|
||||
(event) => {
|
||||
if (terminating) event.preventDefault();
|
||||
}
|
||||
"
|
||||
@escape-key-down="
|
||||
(event) => {
|
||||
if (terminating) event.preventDefault();
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("processList.batchTerminateTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">{{ t("processList.batchTerminateConfirm", { count: batchTerminateTargets?.length ?? 0 }) }}</p>
|
||||
<p class="max-h-40 overflow-auto break-words font-mono text-xs">{{ batchTerminateTargets?.map((row) => row.id).join(", ") }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="terminating" @click="batchTerminateTargets = null">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button variant="destructive" :disabled="terminating" @click="confirmBatchTerminate">
|
||||
<Loader2 v-if="terminating" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
{{ t(terminating ? "processList.batchTerminateRunning" : "processList.terminate") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
:open="batchResult !== null"
|
||||
@update:open="
|
||||
@@ -662,9 +789,9 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<DialogContent class="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("processList.batchTitle") }}</DialogTitle>
|
||||
<DialogTitle>{{ t(batchResult?.kind === "terminate" ? "processList.batchTerminateTitle" : "processList.batchTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p v-if="batchResult" class="text-sm">{{ t("processList.batchSummary", { succeeded: batchResult.succeeded, failed: batchResult.failures.length }) }}</p>
|
||||
<p v-if="batchResult" class="text-sm">{{ t(batchResult.kind === "terminate" ? "processList.batchTerminateSummary" : "processList.batchSummary", { succeeded: batchResult.succeeded, failed: batchResult.failures.length }) }}</p>
|
||||
<ul v-if="batchResult?.failures.length" class="max-h-60 space-y-2 overflow-auto text-xs text-destructive">
|
||||
<li v-for="failure in batchResult.failures" :key="failure.id" class="break-words">{{ failure.id }}: {{ failure.message }}</li>
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App, type Component } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { PG_PROCESS_LIST_SQL } from "@/lib/database/postgresProcessList";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureConnected: vi.fn(),
|
||||
executeQuery: vi.fn(),
|
||||
executeMulti: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
// Flipped by the driver mock to simulate engines without a terminate statement.
|
||||
const driverState = vi.hoisted(() => ({ stripTerminate: false }));
|
||||
|
||||
function passthrough(tag: string): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h(tag, attrs, slots.default?.());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function modelInput(): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs }) {
|
||||
return () =>
|
||||
h("input", {
|
||||
...attrs,
|
||||
value: attrs.modelValue as string,
|
||||
onInput: (event: Event) => (attrs["onUpdate:modelValue"] as ((value: string) => void) | undefined)?.((event.target as HTMLInputElement).value),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function modelDialog(): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => (attrs.open ? h("div", attrs, slots.default?.()) : null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Dialog body mock carrying a marker so dialog-only buttons can be told apart from grid buttons. */
|
||||
function dialogSurface(): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h("div", { ...attrs, "data-dialog": "" }, slots.default?.());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => (values === undefined ? key : `${key}:${Object.values(values).join(",")}`),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@lucide/vue", () => {
|
||||
const Icon = passthrough("span");
|
||||
return { Activity: Icon, AlertTriangle: Icon, ArrowDown: Icon, ArrowUp: Icon, Ban: Icon, Copy: Icon, Loader2: Icon, PlugZap: Icon, RefreshCcw: Icon, Search: Icon };
|
||||
});
|
||||
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
|
||||
vi.mock("@/components/ui/badge", () => ({ Badge: passthrough("span") }));
|
||||
vi.mock("@/components/ui/input", () => ({ Input: modelInput() }));
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: modelDialog(),
|
||||
DialogContent: dialogSurface(),
|
||||
DialogFooter: passthrough("div"),
|
||||
DialogHeader: passthrough("div"),
|
||||
DialogTitle: passthrough("div"),
|
||||
}));
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({ ensureConnected: mocks.ensureConnected }),
|
||||
}));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/lib/backend/api", () => ({ executeQuery: mocks.executeQuery, executeMulti: mocks.executeMulti }));
|
||||
vi.mock("@/lib/database/productionExecutionGuard", () => ({
|
||||
executeWithProductionSqlGuard: (options: { execute: () => Promise<unknown> }) => options.execute(),
|
||||
}));
|
||||
vi.mock("@/lib/tabs/tabUiState", () => ({ useTabUiState: () => ({ initialState: {}, track: () => {} }) }));
|
||||
vi.mock("@/lib/database/processListDrivers", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/database/processListDrivers")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveProcessListDriverForConnection: (connection: Parameters<typeof actual.resolveProcessListDriverForConnection>[0]) => {
|
||||
const resolved = actual.resolveProcessListDriverForConnection(connection);
|
||||
if (!resolved || !driverState.stripTerminate) return resolved;
|
||||
const { buildTerminateSessionSql: _omitted, ...rest } = resolved;
|
||||
return rest;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import ProcessListPanel from "@/components/admin/ProcessListPanel.vue";
|
||||
|
||||
const mysqlConnection: ConnectionConfig = {
|
||||
id: "mysql-1",
|
||||
name: "MySQL",
|
||||
db_type: "mysql",
|
||||
host: "localhost",
|
||||
port: 3306,
|
||||
username: "root",
|
||||
password: "",
|
||||
};
|
||||
|
||||
const postgresConnection: ConnectionConfig = {
|
||||
id: "pg-1",
|
||||
name: "Postgres",
|
||||
db_type: "postgres",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "postgres",
|
||||
password: "",
|
||||
};
|
||||
|
||||
function mysqlListResult() {
|
||||
return {
|
||||
columns: ["Id", "User", "Host", "db", "Command", "Time", "State", "Info"],
|
||||
rows: [
|
||||
[5, "root", "localhost", null, "Query", 0, "executing", "SHOW FULL PROCESSLIST"],
|
||||
[11, "app", "hosta", "shop", "Sleep", 120, null, null],
|
||||
[12, "app", "hostb", "shop", "Sleep", 30, null, null],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function postgresListResult() {
|
||||
return {
|
||||
columns: ["pid", "user", "db", "client", "app", "state", "wait", "time", "query"],
|
||||
rows: [
|
||||
[5, "postgres", "postgres", "local", "", "active", "", 0, PG_PROCESS_LIST_SQL],
|
||||
[21, "app", "shop", "10.0.0.2", "worker", "idle", "", 120, "SELECT 1"],
|
||||
[22, "app", "shop", "10.0.0.3", "worker", "idle", "", 30, "SELECT 2"],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let app: App<Element> | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
driverState.stripTerminate = false;
|
||||
mocks.ensureConnected.mockReset().mockResolvedValue(undefined);
|
||||
mocks.executeQuery.mockReset().mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
|
||||
if (sql === "SELECT CONNECTION_ID()") return { columns: ["CONNECTION_ID()"], rows: [[5]] };
|
||||
if (sql === "SHOW FULL PROCESSLIST") return mysqlListResult();
|
||||
if (sql === "SELECT pg_backend_pid()") return { columns: ["pg_backend_pid"], rows: [[5]] };
|
||||
if (sql === PG_PROCESS_LIST_SQL) return postgresListResult();
|
||||
return { columns: [], rows: [] };
|
||||
});
|
||||
mocks.executeMulti.mockReset().mockResolvedValue([{ columns: [], rows: [] }]);
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
app = null;
|
||||
root?.remove();
|
||||
root = null;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function gridButtons(): HTMLButtonElement[] {
|
||||
return Array.from(root?.querySelectorAll("button") ?? []).filter((button) => !button.closest("[data-dialog]"));
|
||||
}
|
||||
|
||||
function dialogButtons(): HTMLButtonElement[] {
|
||||
return Array.from(root?.querySelectorAll("[data-dialog] button") ?? []);
|
||||
}
|
||||
|
||||
function findGridButton(prefix: string): HTMLButtonElement | undefined {
|
||||
return gridButtons().find((button) => button.textContent?.trim().startsWith(prefix));
|
||||
}
|
||||
|
||||
function findDialogButton(text: string): HTMLButtonElement | undefined {
|
||||
return dialogButtons().find((button) => button.textContent?.trim() === text);
|
||||
}
|
||||
|
||||
function sessionCheckbox(id: number): HTMLInputElement | undefined {
|
||||
return root?.querySelector<HTMLInputElement>(`[aria-label="processList.selectSession:${id}"]`) ?? undefined;
|
||||
}
|
||||
|
||||
async function mountPanel(connection: ConnectionConfig) {
|
||||
app = createApp(ProcessListPanel, { connection });
|
||||
app.mount(root!);
|
||||
await vi.waitFor(() => expect(sessionCheckbox(11) ?? sessionCheckbox(21)).toBeTruthy());
|
||||
await vi.waitFor(() => expect((sessionCheckbox(11) ?? sessionCheckbox(21))?.disabled).toBe(false));
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
async function selectSessions(...ids: number[]) {
|
||||
for (const id of ids) {
|
||||
sessionCheckbox(id)?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
describe("ProcessListPanel batch terminate", () => {
|
||||
it("terminates every selected session one by one after confirmation", async () => {
|
||||
await mountPanel(mysqlConnection);
|
||||
await selectSessions(11, 12);
|
||||
|
||||
const toolbarButton = findGridButton("processList.batchTerminate");
|
||||
expect(toolbarButton?.textContent).toContain("processList.batchTerminate:2");
|
||||
toolbarButton?.click();
|
||||
await nextTick();
|
||||
|
||||
expect(root?.textContent).toContain("processList.batchTerminateTitle");
|
||||
expect(root?.textContent).toContain("processList.batchTerminateConfirm:2");
|
||||
expect(root?.textContent).toContain("11, 12");
|
||||
|
||||
findDialogButton("processList.terminate")?.click();
|
||||
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.executeMulti).toHaveBeenNthCalledWith(1, "mysql-1", "", "KILL 11", undefined, undefined, { maxRows: 1 });
|
||||
expect(mocks.executeMulti).toHaveBeenNthCalledWith(2, "mysql-1", "", "KILL 12", undefined, undefined, { maxRows: 1 });
|
||||
|
||||
await vi.waitFor(() => expect(root?.textContent).toContain("processList.batchTerminateSummary:2,0"));
|
||||
expect(findDialogButton("common.close")).toBeTruthy();
|
||||
// The selection is consumed so the same rows cannot be terminated twice.
|
||||
expect(findGridButton("processList.batchTerminate")?.disabled).toBe(true);
|
||||
expect(sessionCheckbox(11)?.checked).toBe(false);
|
||||
expect(sessionCheckbox(12)?.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps terminating the remaining sessions and summarizes per-row failures", async () => {
|
||||
mocks.executeMulti.mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
|
||||
if (sql === "KILL 11") return [{ columns: [], rows: [] }];
|
||||
throw new Error("Unknown thread id");
|
||||
});
|
||||
await mountPanel(mysqlConnection);
|
||||
await selectSessions(11, 12);
|
||||
|
||||
findGridButton("processList.batchTerminate")?.click();
|
||||
await nextTick();
|
||||
findDialogButton("processList.terminate")?.click();
|
||||
|
||||
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(root?.textContent).toContain("processList.batchTerminateSummary:1,1"));
|
||||
expect(root?.textContent).toContain("12: Unknown thread id");
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("locks the other batch and row actions while a batch termination is pending", async () => {
|
||||
const releaseTerminates: Array<() => void> = [];
|
||||
mocks.executeMulti.mockImplementation(() => new Promise((resolve) => releaseTerminates.push(() => resolve([{ columns: [], rows: [] }]))));
|
||||
await mountPanel(mysqlConnection);
|
||||
await selectSessions(11, 12);
|
||||
|
||||
const batchCancelButton = findGridButton("processList.batchCancel");
|
||||
expect(batchCancelButton?.disabled).toBe(false);
|
||||
findGridButton("processList.batchTerminate")?.click();
|
||||
await nextTick();
|
||||
|
||||
// The open confirmation dialog already locks the toolbar and row actions.
|
||||
expect(batchCancelButton?.disabled).toBe(true);
|
||||
expect(gridButtons().some((button) => button.textContent?.trim() === "processList.kill" && !button.disabled)).toBe(false);
|
||||
expect(gridButtons().some((button) => button.textContent?.trim() === "processList.terminate" && !button.disabled)).toBe(false);
|
||||
|
||||
findDialogButton("processList.terminate")?.click();
|
||||
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(1));
|
||||
expect(root?.textContent).toContain("processList.batchTerminateRunning");
|
||||
expect(batchCancelButton?.disabled).toBe(true);
|
||||
|
||||
releaseTerminates.splice(0).forEach((release) => release());
|
||||
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(2));
|
||||
releaseTerminates.splice(0).forEach((release) => release());
|
||||
await vi.waitFor(() => expect(root?.textContent).toContain("processList.batchTerminateSummary:2,0"));
|
||||
});
|
||||
|
||||
it("does not offer batch or row termination when the driver cannot terminate", async () => {
|
||||
driverState.stripTerminate = true;
|
||||
await mountPanel(mysqlConnection);
|
||||
|
||||
expect(findGridButton("processList.batchTerminate")).toBeUndefined();
|
||||
expect(gridButtons().some((button) => button.textContent?.trim() === "processList.terminate")).toBe(false);
|
||||
// Query cancellation keeps multi-selection and its own batch action.
|
||||
expect(findGridButton("processList.batchCancel")).toBeTruthy();
|
||||
expect(sessionCheckbox(11)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the existing batch cancel flow working", async () => {
|
||||
await mountPanel(mysqlConnection);
|
||||
await selectSessions(11, 12);
|
||||
|
||||
findGridButton("processList.batchCancel")?.click();
|
||||
await nextTick();
|
||||
expect(root?.textContent).toContain("processList.batchTitle");
|
||||
expect(root?.textContent).toContain("processList.batchConfirm:2");
|
||||
|
||||
findDialogButton("processList.kill")?.click();
|
||||
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.executeMulti).toHaveBeenNthCalledWith(1, "mysql-1", "", "KILL QUERY 11", undefined, undefined, { maxRows: 1 });
|
||||
expect(mocks.executeMulti).toHaveBeenNthCalledWith(2, "mysql-1", "", "KILL QUERY 12", undefined, undefined, { maxRows: 1 });
|
||||
await vi.waitFor(() => expect(root?.textContent).toContain("processList.batchSummary:2,0"));
|
||||
});
|
||||
|
||||
it("enables selection and batch termination on engines without batch cancel", async () => {
|
||||
// PostgreSQL requires the server to confirm the termination with a true result cell.
|
||||
mocks.executeMulti.mockResolvedValue([{ columns: ["pg_terminate_backend"], rows: [[true]] }]);
|
||||
await mountPanel(postgresConnection);
|
||||
await selectSessions(21, 22);
|
||||
|
||||
expect(findGridButton("processList.batchCancel")).toBeUndefined();
|
||||
const toolbarButton = findGridButton("processList.batchTerminate");
|
||||
expect(toolbarButton?.textContent).toContain("processList.batchTerminate:2");
|
||||
toolbarButton?.click();
|
||||
await nextTick();
|
||||
|
||||
findDialogButton("processList.terminate")?.click();
|
||||
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.executeMulti).toHaveBeenNthCalledWith(1, "pg-1", "", "SELECT pg_terminate_backend(21)", undefined, undefined, { maxRows: 1 });
|
||||
expect(mocks.executeMulti).toHaveBeenNthCalledWith(2, "pg-1", "", "SELECT pg_terminate_backend(22)", undefined, undefined, { maxRows: 1 });
|
||||
await vi.waitFor(() => expect(root?.textContent).toContain("processList.batchTerminateSummary:2,0"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import az from "../locales/az";
|
||||
import en from "../locales/en";
|
||||
import es from "../locales/es";
|
||||
import it_ from "../locales/it";
|
||||
import ja from "../locales/ja";
|
||||
import ko from "../locales/ko";
|
||||
import ptBR from "../locales/pt-BR";
|
||||
import tr from "../locales/tr";
|
||||
import zhCN from "../locales/zh-CN";
|
||||
import zhTW from "../locales/zh-TW";
|
||||
|
||||
const locales: Array<[string, Record<string, unknown>]> = [
|
||||
["az", az],
|
||||
["en", en],
|
||||
["es", es],
|
||||
["it", it_],
|
||||
["ja", ja],
|
||||
["ko", ko],
|
||||
["pt-BR", ptBR],
|
||||
["tr", tr],
|
||||
["zh-CN", zhCN],
|
||||
["zh-TW", zhTW],
|
||||
];
|
||||
|
||||
const BATCH_TERMINATE_KEYS = ["batchTerminate", "batchTerminateTitle", "batchTerminateConfirm", "batchTerminateRunning", "batchTerminateSummary"];
|
||||
|
||||
describe("process list batch terminate locale parity", () => {
|
||||
it.each(locales)("%s ships every batch terminate key", (_name, locale) => {
|
||||
const processList = (locale as { processList: Record<string, unknown> }).processList;
|
||||
for (const key of BATCH_TERMINATE_KEYS) {
|
||||
expect(typeof processList[key]).toBe("string");
|
||||
expect(String(processList[key]).trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the count and summary placeholders in every locale", () => {
|
||||
for (const [_name, locale] of locales) {
|
||||
const processList = (locale as { processList: Record<string, string> }).processList;
|
||||
expect(processList.batchTerminate).toContain("{count}");
|
||||
expect(processList.batchTerminateConfirm).toContain("{count}");
|
||||
expect(processList.batchTerminateSummary).toContain("{succeeded}");
|
||||
expect(processList.batchTerminateSummary).toContain("{failed}");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3857,6 +3857,11 @@ export default withEnglishFallback({
|
||||
terminateConfirm: "{id} ({user}) sessiyası sonlandırılsın? Əlaqə dərhal bağlanır və təsdiqlənməmiş tranzaksiyalar geri alınır.",
|
||||
terminateSuccess: "{id} sessiyası sonlandırıldı",
|
||||
terminateFailed: "Sessiyanı sonlandırmaq mümkün olmadı: {message}",
|
||||
batchTerminate: "Seçilmiş sessiyaları sonlandır ({count})",
|
||||
batchTerminateTitle: "Seçilmiş sessiyaları sonlandır",
|
||||
batchTerminateConfirm: "Bu {count} sessiya sonlandırılsın? Əlaqələr dərhal bağlanır və təsdiqlənməmiş tranzaksiyalar geri alınır.",
|
||||
batchTerminateRunning: "Sessiyalar sonlandırılır…",
|
||||
batchTerminateSummary: "Sonlandırma sorğuları: {succeeded} uğurlu, {failed} uğursuz.",
|
||||
previewTitle: "İfadə",
|
||||
copy: "Kopyala",
|
||||
copied: "Kopyalandı",
|
||||
|
||||
@@ -4079,6 +4079,11 @@ export default {
|
||||
terminateConfirm: "Terminate session {id} ({user})? The connection is closed immediately and any uncommitted transaction is rolled back.",
|
||||
terminateSuccess: "Terminated session {id}",
|
||||
terminateFailed: "Failed to terminate session: {message}",
|
||||
batchTerminate: "Terminate selected sessions ({count})",
|
||||
batchTerminateTitle: "Terminate selected sessions",
|
||||
batchTerminateConfirm: "Terminate these {count} sessions? The connections are closed immediately and any uncommitted transactions are rolled back.",
|
||||
batchTerminateRunning: "Terminating sessions…",
|
||||
batchTerminateSummary: "Termination requests: {succeeded} succeeded, {failed} failed.",
|
||||
previewTitle: "Statement",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
|
||||
@@ -9569,6 +9569,11 @@ export default withEnglishFallback({
|
||||
terminateConfirm: "¿Terminar la sesión {id} ({user})? La conexión se cierra de inmediato y cualquier transacción sin confirmar se revierte.",
|
||||
terminateSuccess: "Sesión {id} terminada",
|
||||
terminateFailed: "Error al terminar la sesión: {message}",
|
||||
batchTerminate: "Terminar sesiones seleccionadas ({count})",
|
||||
batchTerminateTitle: "Terminar sesiones seleccionadas",
|
||||
batchTerminateConfirm: "¿Terminar estas {count} sesiones? Las conexiones se cierran de inmediato y cualquier transacción sin confirmar se revierte.",
|
||||
batchTerminateRunning: "Terminando sesiones…",
|
||||
batchTerminateSummary: "Solicitudes de terminación: {succeeded} correctas, {failed} fallidas.",
|
||||
colPid: "PID",
|
||||
colClient: "Cliente",
|
||||
colApp: "Aplicación",
|
||||
|
||||
@@ -9569,6 +9569,11 @@ export default withEnglishFallback({
|
||||
terminateConfirm: "Terminare la sessione {id} ({user})? La connessione viene chiusa immediatamente e le transazioni non confermate vengono annullate.",
|
||||
terminateSuccess: "Sessione {id} terminata",
|
||||
terminateFailed: "Impossibile terminare la sessione: {message}",
|
||||
batchTerminate: "Termina le sessioni selezionate ({count})",
|
||||
batchTerminateTitle: "Termina le sessioni selezionate",
|
||||
batchTerminateConfirm: "Terminare queste {count} sessioni? Le connessioni vengono chiuse immediatamente e qualsiasi transazione non confermata viene annullata.",
|
||||
batchTerminateRunning: "Terminazione delle sessioni in corso…",
|
||||
batchTerminateSummary: "Richieste di terminazione: {succeeded} riuscite, {failed} non riuscite.",
|
||||
colPid: "PID",
|
||||
colClient: "Client",
|
||||
colApp: "Applicazione",
|
||||
|
||||
@@ -9619,6 +9619,11 @@ export default withEnglishFallback({
|
||||
terminateConfirm: "セッション {id}({user})を終了しますか?接続は直ちに切断され、未コミットのトランザクションはロールバックされます。",
|
||||
terminateSuccess: "セッション {id} を終了しました。",
|
||||
terminateFailed: "セッションの終了に失敗しました:{message}",
|
||||
batchTerminate: "選択したセッションを終了({count})",
|
||||
batchTerminateTitle: "選択したセッションを終了",
|
||||
batchTerminateConfirm: "これら {count} 個のセッションを終了しますか?接続は直ちに切断され、未コミットのトランザクションはロールバックされます。",
|
||||
batchTerminateRunning: "セッションを終了しています…",
|
||||
batchTerminateSummary: "終了要求:成功 {succeeded} 件、失敗 {failed} 件。",
|
||||
colPid: "PID",
|
||||
colClient: "クライアント",
|
||||
colApp: "アプリケーション",
|
||||
|
||||
@@ -3843,6 +3843,11 @@ export default withEnglishFallback({
|
||||
terminateConfirm: "세션 {id} ({user})을(를) 종료하시겠습니까? 연결이 즉시 끊기고 커밋되지 않은 트랜잭션은 롤백됩니다.",
|
||||
terminateSuccess: "세션 {id}을(를) 종료했습니다",
|
||||
terminateFailed: "세션 종료에 실패했습니다: {message}",
|
||||
batchTerminate: "선택한 세션 종료({count})",
|
||||
batchTerminateTitle: "선택한 세션 종료",
|
||||
batchTerminateConfirm: "이 {count}개 세션을 종료하시겠습니까? 연결이 즉시 끊기고 커밋되지 않은 트랜잭션은 롤백됩니다.",
|
||||
batchTerminateRunning: "세션을 종료하는 중…",
|
||||
batchTerminateSummary: "종료 요청: 성공 {succeeded}개, 실패 {failed}개.",
|
||||
previewTitle: "구문",
|
||||
copy: "복사",
|
||||
copied: "복사했습니다",
|
||||
|
||||
@@ -9570,6 +9570,11 @@ export default withEnglishFallback({
|
||||
terminateConfirm: "Encerrar a sessão {id} ({user})? A conexão é fechada imediatamente e qualquer transação não confirmada é revertida.",
|
||||
terminateSuccess: "Sessão {id} encerrada",
|
||||
terminateFailed: "Falha ao encerrar a sessão: {message}",
|
||||
batchTerminate: "Encerrar sessões selecionadas ({count})",
|
||||
batchTerminateTitle: "Encerrar sessões selecionadas",
|
||||
batchTerminateConfirm: "Encerrar estas {count} sessões? As conexões são fechadas imediatamente e qualquer transação não confirmada é revertida.",
|
||||
batchTerminateRunning: "Encerrando sessões…",
|
||||
batchTerminateSummary: "Solicitações de encerramento: {succeeded} bem-sucedidas, {failed} com falha.",
|
||||
colPid: "PID",
|
||||
colClient: "Cliente",
|
||||
colApp: "Aplicativo",
|
||||
|
||||
@@ -3829,6 +3829,11 @@ export default withEnglishFallback({
|
||||
terminateConfirm: "{id} ({user}) oturumu sonlandırılsın mı? Bağlantı hemen kapatılır ve işlenmemiş işlemler geri alınır.",
|
||||
terminateSuccess: "{id} oturumu sonlandırıldı",
|
||||
terminateFailed: "Oturum sonlandırılamadı: {message}",
|
||||
batchTerminate: "Seçili oturumları sonlandır ({count})",
|
||||
batchTerminateTitle: "Seçili oturumları sonlandır",
|
||||
batchTerminateConfirm: "Bu {count} oturum sonlandırılsın mı? Bağlantılar hemen kapatılır ve işlenmemiş işlemler geri alınır.",
|
||||
batchTerminateRunning: "Oturumlar sonlandırılıyor…",
|
||||
batchTerminateSummary: "Sonlandırma istekleri: {succeeded} başarılı, {failed} başarısız.",
|
||||
previewTitle: "İfade",
|
||||
copy: "Kopyala",
|
||||
copied: "Kopyalandı",
|
||||
|
||||
@@ -3989,6 +3989,11 @@ export default withEnglishFallback({
|
||||
terminateConfirm: "确定终止会话 {id}({user})?该连接会立即断开,未提交的事务将回滚。",
|
||||
terminateSuccess: "已终止会话 {id}",
|
||||
terminateFailed: "终止会话失败:{message}",
|
||||
batchTerminate: "终止选中会话({count})",
|
||||
batchTerminateTitle: "终止选中会话",
|
||||
batchTerminateConfirm: "确定终止这 {count} 个会话?这些连接会立即断开,未提交的事务将回滚。",
|
||||
batchTerminateRunning: "正在终止会话…",
|
||||
batchTerminateSummary: "终止请求执行成功 {succeeded} 个,失败 {failed} 个。",
|
||||
previewTitle: "语句",
|
||||
copy: "复制",
|
||||
copied: "已复制",
|
||||
|
||||
@@ -9568,6 +9568,11 @@ export default withEnglishFallback({
|
||||
terminateConfirm: "確定終止會話 {id}({user})?該連線會立即中斷,未提交的交易將回滾。",
|
||||
terminateSuccess: "已終止會話 {id}",
|
||||
terminateFailed: "終止會話失敗:{message}",
|
||||
batchTerminate: "終止所選會話({count})",
|
||||
batchTerminateTitle: "終止所選會話",
|
||||
batchTerminateConfirm: "確定終止這 {count} 個會話?這些連線會立即中斷,未提交的交易將回滾。",
|
||||
batchTerminateRunning: "正在終止會話…",
|
||||
batchTerminateSummary: "終止請求執行成功 {succeeded} 個,失敗 {failed} 個。",
|
||||
colPid: "PID",
|
||||
colClient: "用戶端",
|
||||
colApp: "應用程式",
|
||||
|
||||
Reference in New Issue
Block a user