feat(admin): terminate multiple selected process list sessions

Closes #9778
This commit is contained in:
t8y2
2026-09-21 01:44:23 +08:00
parent 1ded0a7bcd
commit 216afec5e4
13 changed files with 555 additions and 9 deletions
@@ -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}");
}
});
});
+5
View File
@@ -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ı",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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: "アプリケーション",
+5
View File
@@ -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: "복사했습니다",
+5
View File
@@ -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",
+5
View File
@@ -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ı",
+5
View File
@@ -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: "已复制",
+5
View File
@@ -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: "應用程式",