diff --git a/apps/desktop/src/components/admin/ProcessListPanel.vue b/apps/desktop/src/components/admin/ProcessListPanel.vue index 1459dc6629..f0319a0e04 100644 --- a/apps/desktop/src/components/admin/ProcessListPanel.vue +++ b/apps/desktop/src/components/admin/ProcessListPanel.vue @@ -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()); // Anchor row for Shift+click range selection, in the current display order. const selectionAnchorId = ref(null); const batchTargets = ref(null); -const batchResult = ref<{ succeeded: number; failures: { id: number; message: string }[] } | null>(null); +const batchTerminateTargets = ref(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(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(() => { {{ t("processList.batchCancel", { count: selectedIds.size }) }} +
@@ -466,7 +556,7 @@ onBeforeUnmount(() => {
-
{{ t("processList.batchNeedsSession") }}
+
{{ t("processList.batchNeedsSession") }}
{{ loadError }}
@@ -474,7 +564,7 @@ onBeforeUnmount(() => { - - - @@ -652,6 +742,43 @@ onBeforeUnmount(() => { + + + + {{ t("processList.batchTerminateTitle") }} + +

{{ t("processList.batchTerminateConfirm", { count: batchTerminateTargets?.length ?? 0 }) }}

+

{{ batchTerminateTargets?.map((row) => row.id).join(", ") }}

+ + + + +
+
+ - {{ t("processList.batchTitle") }} + {{ t(batchResult?.kind === "terminate" ? "processList.batchTerminateTitle" : "processList.batchTitle") }} -

{{ t("processList.batchSummary", { succeeded: batchResult.succeeded, failed: batchResult.failures.length }) }}

+

{{ t(batchResult.kind === "terminate" ? "processList.batchTerminateSummary" : "processList.batchSummary", { succeeded: batchResult.succeeded, failed: batchResult.failures.length }) }}

  • {{ failure.id }}: {{ failure.message }}
diff --git a/apps/desktop/src/components/admin/__tests__/ProcessListPanel.spec.ts b/apps/desktop/src/components/admin/__tests__/ProcessListPanel.spec.ts new file mode 100644 index 0000000000..77f643b811 --- /dev/null +++ b/apps/desktop/src/components/admin/__tests__/ProcessListPanel.spec.ts @@ -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) => (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 }) => options.execute(), +})); +vi.mock("@/lib/tabs/tabUiState", () => ({ useTabUiState: () => ({ initialState: {}, track: () => {} }) })); +vi.mock("@/lib/database/processListDrivers", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveProcessListDriverForConnection: (connection: Parameters[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 | 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(`[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")); + }); +}); diff --git a/apps/desktop/src/i18n/__tests__/processListBatchTerminateMessages.spec.ts b/apps/desktop/src/i18n/__tests__/processListBatchTerminateMessages.spec.ts new file mode 100644 index 0000000000..54df8336bb --- /dev/null +++ b/apps/desktop/src/i18n/__tests__/processListBatchTerminateMessages.spec.ts @@ -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]> = [ + ["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 }).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 }).processList; + expect(processList.batchTerminate).toContain("{count}"); + expect(processList.batchTerminateConfirm).toContain("{count}"); + expect(processList.batchTerminateSummary).toContain("{succeeded}"); + expect(processList.batchTerminateSummary).toContain("{failed}"); + } + }); +}); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index f0f8c718f4..e5b5a985c9 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -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ı", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index b543cf9f8b..3dfa1265f8 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 9f18aa759e..85db7ac09b 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 92a08911d6..1233e242d3 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index ddbc15153b..4e59320bff 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -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: "アプリケーション", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 1ce0374a86..18d442fa94 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -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: "복사했습니다", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index d118dd0cbf..d5ad99d877 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 71ae8c8262..1663f367fa 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -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ı", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 064a94d55d..6d54501fa5 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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: "已复制", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 896dcce098..001db51daa 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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: "應用程式",
+ {
+ {
+ {{ search ? t("grid.noSearchResults") : t("processList.empty") }}