feat(xugu): add active transaction management

This commit is contained in:
Elias
2026-10-01 02:05:50 +08:00
committed by GitHub
parent 4706ffe070
commit 0f7e2b6c6c
10 changed files with 566 additions and 39 deletions
@@ -0,0 +1,116 @@
package main
import (
"context"
"database/sql"
"fmt"
"os"
"regexp"
"testing"
"time"
)
// TestLiveXuguTransactionManagement verifies the SQL used by the desktop
// transaction panel against a real XuguDB instance. The only killed target is
// a transaction opened by this test; no application transaction is touched.
func TestLiveXuguTransactionManagement(t *testing.T) {
if os.Getenv("XUGU_LIVE_TEST") != "1" {
t.Skip("set XUGU_LIVE_TEST=1 with XUGU_LIVE_* connection settings")
}
params := liveXuguParams(t)
params.Database = "SYSTEM"
admin, err := openDB(params)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var ownNode, ownSession int64
if err := admin.QueryRowContext(ctx, "SELECT NODEID, SESSION_ID FROM SYS_SESSIONS WHERE SESSION_ID = USERENV('SID')").Scan(&ownNode, &ownSession); err != nil {
t.Fatalf("own-session SQL: %v", err)
}
if ownNode <= 0 || ownSession < 0 {
t.Fatalf("invalid own-session identity: node=%d session=%d", ownNode, ownSession)
}
const listSQL = `SELECT T.NODEID AS NODE_ID,
CAST(T.TRANID AS VARCHAR(32)) AS TRANSACTION_ID,
S.SESSION_ID AS SESSION_ID,
S.USER_NAME AS USER_NAME,
S.DB_NAME AS DB_NAME,
S.IP AS CLIENT_IP,
T.START_T AS START_TIME
FROM SYS_ALL_TRANS T
JOIN SYS_ALL_SESSIONS S
ON T.NODEID = S.NODEID AND T.TRANID = S.CURR_TID
ORDER BY T.START_T`
rows, err := admin.QueryContext(ctx, listSQL)
if err != nil {
t.Fatalf("transaction-list SQL: %v", err)
}
if err := rows.Close(); err != nil {
t.Fatal(err)
}
// Keep a dedicated second connection in one explicit transaction so the
// administrative session can only terminate this test-owned transaction.
worker, err := openDB(params)
if err != nil {
t.Fatal(err)
}
defer worker.Close()
conn, err := worker.Conn(ctx)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
tx, err := conn.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
var targetNode, targetSession int64
var targetTransaction string
if err := tx.QueryRowContext(ctx, "SELECT NODEID, SESSION_ID, CAST(CURR_TID AS VARCHAR(32)) FROM SYS_SESSIONS WHERE SESSION_ID = USERENV('SID')").Scan(&targetNode, &targetSession, &targetTransaction); err != nil {
t.Fatalf("test-owned transaction identity: %v", err)
}
if targetNode <= 0 || targetSession < 0 || targetTransaction == "" || targetTransaction == "0" || (targetNode == ownNode && targetSession == ownSession) {
t.Fatalf("unsafe test target: node=%d session=%d transaction=%q", targetNode, targetSession, targetTransaction)
}
var listed bool
rows, err = admin.QueryContext(ctx, listSQL)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
var node, session int64
var transaction string
var user, dbName, ip sql.NullString
var start any
if err := rows.Scan(&node, &transaction, &session, &user, &dbName, &ip, &start); err != nil {
rows.Close()
t.Fatal(err)
}
if node == targetNode && session == targetSession && transaction == targetTransaction {
listed = true
}
}
if err := rows.Err(); err != nil {
rows.Close()
t.Fatal(err)
}
rows.Close()
if !listed {
t.Fatalf("test-owned transaction %s was not listed", targetTransaction)
}
if !regexp.MustCompile(`^[1-9][0-9]*$`).MatchString(targetTransaction) {
t.Fatalf("invalid server transaction id %q", targetTransaction)
}
// Exercise the exact literal statement sent by the desktop panel.
if _, err := admin.ExecContext(ctx, fmt.Sprintf("CALL DBMS_DBA.KILL_TRANS(%d, %s)", targetNode, targetTransaction)); err != nil {
t.Fatalf("terminate test-owned transaction: %v", err)
}
}
@@ -13,6 +13,7 @@ import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { clampInterval, createProcessListLoadCoordinator, DEFAULT_REFRESH_SECONDS, processListExecutionError, processListSessionCount } from "@/lib/database/mysqlProcessList";
import { resolveProcessListDriverForConnection, type ProcessRow } from "@/lib/database/processListDrivers";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { processListSelectionAfterClick } from "@/lib/database/processListSelection";
import { useTabUiState } from "@/lib/tabs/tabUiState";
@@ -34,12 +35,16 @@ const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{
// The panel is only opened for supported engines; guard the driver defensively so
// a missing one degrades to an empty table rather than crashing the render.
const driver = computed(() => resolveProcessListDriverForConnection(props.connection));
const xuguAdminRequired = computed(() => effectiveDatabaseTypeForConnection(props.connection) === "xugu" && driver.value === null);
const columns = computed(() => driver.value?.columns ?? []);
const numericKeys = computed(() => new Set(columns.value.filter((column) => column.numeric).map((column) => column.key)));
const rows = ref<ProcessRow[]>([]);
const truncated = ref(false);
const ownSessionId = ref<number | null>(null);
const ownNodeId = ref<number | null>(null);
const transactionMode = computed(() => driver.value?.mode === "transaction");
const transactionIdentityUnavailable = computed(() => transactionMode.value && (ownSessionId.value === null || ownNodeId.value === null));
const loading = ref(false);
const loadCoordinator = createProcessListLoadCoordinator();
const loadError = ref("");
@@ -154,14 +159,14 @@ function requestBatchCancel() {
async function confirmBatchCancel() {
const targets = batchTargets.value;
const activeDriver = driver.value;
if (!targets?.length || !activeDriver?.supportsBatchCancel || ownSessionId.value === null || canceling.value) return;
if (!targets?.length || !activeDriver?.supportsBatchCancel || !activeDriver.buildCancelQuerySql || ownSessionId.value === null || canceling.value) return;
const connection = { ...props.connection };
const generation = connectionGeneration;
const isCurrent = () => !disposed && generation === connectionGeneration;
canceling.value = true;
let executed = false;
try {
const statements = targets.map((row) => ({ id: row.id, sql: activeDriver.buildCancelQuerySql(row.id) }));
const statements = targets.map((row) => ({ id: row.id, sql: activeDriver.buildCancelQuerySql!(row.id) }));
const result = await executeWithProductionSqlGuard({
connection,
database: "",
@@ -296,14 +301,23 @@ async function load(options: { silent?: boolean } = {}) {
try {
let idResult;
try {
idResult = await api.executeQuery(connectionId, "", activeDriver.ownSessionSql, undefined, undefined, { maxRows: 1 });
idResult = await api.executeQuery(connectionId, activeDriver.database ?? "", activeDriver.ownSessionSql, undefined, undefined, { maxRows: 1 });
} catch (error) {
if (!activeDriver.fallbackOwnSessionSql || !activeDriver.shouldUseFallbackOwnSessionSql?.(error)) throw error;
idResult = await api.executeQuery(connectionId, "", activeDriver.fallbackOwnSessionSql, undefined, undefined, { maxRows: 1 });
idResult = await api.executeQuery(connectionId, activeDriver.database ?? "", activeDriver.fallbackOwnSessionSql, undefined, undefined, { maxRows: 1 });
}
const ownRow = idResult?.rows?.[0];
if (activeDriver.mode === "transaction") {
const nodeId = Number(ownRow?.[0]);
const sessionId = Number(ownRow?.[1]);
if (isCurrent() && Number.isSafeInteger(nodeId) && nodeId > 0 && Number.isSafeInteger(sessionId) && sessionId >= 0) {
ownNodeId.value = nodeId;
ownSessionId.value = sessionId;
}
} else {
const parsed = Number(ownRow?.[0]);
if (isCurrent() && Number.isInteger(parsed) && parsed > 0) ownSessionId.value = parsed;
}
const raw = idResult?.rows?.[0]?.[0];
const parsed = Number(raw);
if (isCurrent() && Number.isInteger(parsed) && parsed > 0) ownSessionId.value = parsed;
} catch {
// Non-fatal: without our own id we simply cannot dim the self row.
}
@@ -312,10 +326,10 @@ async function load(options: { silent?: boolean } = {}) {
const listSql = fallbackListSql.value ?? activeDriver.listSql;
let result;
try {
result = await api.executeQuery(connectionId, "", listSql, undefined, undefined, { maxRows: activeDriver.maxRows });
result = await api.executeQuery(connectionId, activeDriver.database ?? "", listSql, undefined, undefined, { maxRows: activeDriver.maxRows });
} catch (error) {
if (fallbackListSql.value || !activeDriver.fallbackListSql || !activeDriver.shouldUseFallbackListSql?.(error)) throw error;
result = await api.executeQuery(connectionId, "", activeDriver.fallbackListSql, undefined, undefined, { maxRows: activeDriver.maxRows });
result = await api.executeQuery(connectionId, activeDriver.database ?? "", activeDriver.fallbackListSql, undefined, undefined, { maxRows: activeDriver.maxRows });
// Cache the compatible query so old servers do not fail once per refresh.
if (isCurrent()) fallbackListSql.value = activeDriver.fallbackListSql;
}
@@ -323,7 +337,17 @@ async function load(options: { silent?: boolean } = {}) {
rows.value = activeDriver.mapRows(result);
truncated.value = result.truncated === true;
} catch (error: any) {
if (isCurrent()) loadError.value = error?.message || String(error);
if (isCurrent()) {
if (activeDriver.mode === "transaction") {
// A failed refresh must not leave stale kill targets on screen. A
// connection failure is not evidence that there are no transactions.
rows.value = [];
truncated.value = false;
ownSessionId.value = null;
ownNodeId.value = null;
}
loadError.value = activeDriver.mode === "transaction" ? t("processList.transactionLoadFailed", { message: error?.message || String(error) }) : error?.message || String(error);
}
} finally {
loading.value = false;
refreshing.value = false;
@@ -333,38 +357,48 @@ async function load(options: { silent?: boolean } = {}) {
}
function isOwnSession(row: ProcessRow): boolean {
if (transactionMode.value) return ownNodeId.value !== null && ownSessionId.value !== null && row.nodeId === ownNodeId.value && row.id === ownSessionId.value;
return ownSessionId.value !== null && row.id === ownSessionId.value;
}
function requestCancel(row: ProcessRow) {
if (isOwnSession(row) || actionsLocked.value || refreshing.value) return;
if (isOwnSession(row) || transactionIdentityUnavailable.value || actionsLocked.value || refreshing.value) return;
cancelTarget.value = row;
}
async function confirmCancel() {
const target = cancelTarget.value;
const activeDriver = driver.value;
if (!target || !activeDriver || canceling.value) return;
if (!target || !activeDriver || canceling.value || isOwnSession(target) || transactionIdentityUnavailable.value || (transactionMode.value ? !activeDriver.buildKillTransactionSql : !activeDriver.buildCancelQuerySql)) return;
const connection = { ...props.connection };
const generation = connectionGeneration;
const isCurrent = () => !disposed && generation === connectionGeneration;
canceling.value = true;
try {
const cancelSql = activeDriver.buildCancelQuerySql(target.id);
const cancelSql = transactionMode.value ? activeDriver.buildKillTransactionSql!(target) : activeDriver.buildCancelQuerySql!(target.id);
let usedFallbackCancelSql = false;
const executeCancelSql = async (sql: string) => {
if (!isCurrent()) return undefined;
const results = await api.executeMulti(connection.id, "", sql, undefined, undefined, { maxRows: 1 });
const results = await api.executeMulti(connection.id, activeDriver.database ?? "", sql, undefined, undefined, { maxRows: 1 });
const executionError = processListExecutionError(results);
if (executionError) throw new Error(executionError);
return results;
};
const result = await executeWithProductionSqlGuard({
connection,
database: "",
database: activeDriver.database ?? "",
sql: cancelSql,
source: t("production.sourceAdmin"),
execute: async () => {
if (activeDriver.mode === "transaction") {
// A confirmation dialog may stay open while the Agent reconnects.
// Refuse to kill until the executing session has been identified again.
const identity = await api.executeQuery(connection.id, activeDriver.database ?? "", activeDriver.ownSessionSql, undefined, undefined, { maxRows: 1 });
const currentNode = Number(identity.rows?.[0]?.[0]);
const currentSession = Number(identity.rows?.[0]?.[1]);
if (!Number.isSafeInteger(currentNode) || currentNode <= 0 || !Number.isSafeInteger(currentSession) || currentSession < 0) throw new Error(t("processList.transactionNeedsSession"));
if (currentNode === target.nodeId && currentSession === target.id) throw new Error(t("processList.transactionCannotKillSelf"));
}
try {
return await executeCancelSql(cancelSql);
} catch (error) {
@@ -377,10 +411,10 @@ async function confirmCancel() {
if (result === undefined || !isCurrent()) return;
const cancelResultError = usedFallbackCancelSql ? activeDriver.fallbackCancelQueryResultError?.(result) : activeDriver.cancelQueryResultError?.(result);
if (cancelResultError) throw new Error(cancelResultError);
toast(t("processList.killSuccess", { id: target.id }), 2500);
toast(t(activeDriver.mode === "transaction" ? "processList.transactionKillSuccess" : "processList.killSuccess", { id: target.transactionId ?? target.id }), 2500);
cancelTarget.value = null;
} catch (error: any) {
if (isCurrent()) toast(t("processList.killFailed", { message: error?.message || String(error) }), 5000);
if (isCurrent()) toast(t(activeDriver.mode === "transaction" ? "processList.transactionKillFailed" : "processList.killFailed", { message: error?.message || String(error) }), 5000);
} finally {
canceling.value = false;
if (cancelTarget.value === null) void load({ silent: true });
@@ -485,6 +519,7 @@ watch(
rows.value = [];
truncated.value = false;
ownSessionId.value = null;
ownNodeId.value = null;
search.value = "";
sortKey.value = driver.value?.defaultSortKey ?? "time";
sortDir.value = "desc";
@@ -501,13 +536,21 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-background">
<div v-if="xuguAdminRequired" class="flex h-full items-center justify-center bg-background p-6">
<div class="flex max-w-md items-start gap-3 rounded-lg border bg-muted/20 p-4 text-sm text-muted-foreground">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" />
<span>{{ t("processList.transactionRequiresSysdba") }}</span>
</div>
</div>
<div v-else class="flex h-full min-h-0 flex-col bg-background">
<div class="flex min-h-11 shrink-0 flex-wrap items-center gap-2 border-b bg-muted/20 px-3 py-1">
<div class="flex min-w-0 items-center gap-2">
<Activity class="h-4 w-4 text-primary" />
<div class="truncate text-sm font-semibold">{{ t("processList.title") }}</div>
<div class="truncate text-sm font-semibold">{{ t(transactionMode ? "processList.transactionTitle" : "processList.title") }}</div>
<Badge variant="outline" class="h-5 rounded-md px-1.5 text-[11px]">{{ connection.name }}</Badge>
<Badge variant="secondary" class="h-5 rounded-md px-1.5 text-[11px]">{{ t("processList.sessionCount", { count: processListSessionCount(truncated ? rows.length : filteredRows.length, truncated) }) }}</Badge>
<Badge v-if="!transactionMode || !loadError" variant="secondary" class="h-5 rounded-md px-1.5 text-[11px]">{{
t(transactionMode ? "processList.transactionCount" : "processList.sessionCount", { count: processListSessionCount(truncated ? rows.length : filteredRows.length, truncated) })
}}</Badge>
</div>
<div class="ml-auto flex flex-wrap items-center gap-2">
<Button
@@ -538,7 +581,7 @@ onBeforeUnmount(() => {
</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')" />
<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(transactionMode ? 'processList.transactionFilter' : 'processList.filter')" />
</div>
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
<input v-model="autoRefresh" type="checkbox" class="h-3.5 w-3.5 accent-primary" />
@@ -556,7 +599,10 @@ onBeforeUnmount(() => {
</div>
</div>
<div v-if="transactionMode" class="border-b bg-muted/10 px-3 py-2 text-xs text-muted-foreground">{{ t("processList.transactionScope") }}</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="transactionIdentityUnavailable && !refreshing && !loadError" class="border-b px-3 py-2 text-xs text-muted-foreground">{{ t("processList.transactionNeedsSession") }}</div>
<div v-if="loadError" class="border-b bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ loadError }}</div>
@@ -587,7 +633,7 @@ onBeforeUnmount(() => {
</tr>
</thead>
<tbody>
<tr v-for="row in filteredRows" :key="row.id" class="border-b hover:bg-accent/40" :class="{ 'bg-primary/5': isOwnSession(row) }">
<tr v-for="row in filteredRows" :key="driver?.rowKey?.(row) ?? row.id" class="border-b hover:bg-accent/40" :class="{ 'bg-primary/5': isOwnSession(row) }">
<td v-if="selectionSupported" class="px-3 py-1.5">
<input
type="checkbox"
@@ -618,12 +664,12 @@ onBeforeUnmount(() => {
variant="ghost"
size="sm"
class="h-6 gap-1 px-1.5 text-[11px] text-destructive hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
:disabled="isOwnSession(row) || actionsLocked || refreshing"
:title="isOwnSession(row) ? t('processList.cannotKillSelf') : t('processList.kill')"
:disabled="isOwnSession(row) || transactionIdentityUnavailable || actionsLocked || refreshing"
:title="isOwnSession(row) ? t(transactionMode ? 'processList.transactionCannotKillSelf' : 'processList.cannotKillSelf') : t(transactionMode ? 'processList.transactionKill' : 'processList.kill')"
@click="requestCancel(row)"
>
<Ban class="h-3.5 w-3.5" />
{{ t("processList.kill") }}
{{ t(transactionMode ? "processList.transactionKill" : "processList.kill") }}
</Button>
<Button
v-if="terminateSupported"
@@ -640,9 +686,9 @@ onBeforeUnmount(() => {
</span>
</td>
</tr>
<tr v-if="!loading && filteredRows.length === 0">
<tr v-if="!loading && filteredRows.length === 0 && (!transactionMode || !loadError)">
<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") }}
{{ search ? t("grid.noSearchResults") : t(transactionMode ? "processList.transactionEmpty" : "processList.empty") }}
</td>
</tr>
</tbody>
@@ -661,17 +707,17 @@ onBeforeUnmount(() => {
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<AlertTriangle class="h-4 w-4 text-destructive" />
{{ t("processList.killTitle") }}
{{ t(transactionMode ? "processList.transactionKillTitle" : "processList.killTitle") }}
</DialogTitle>
</DialogHeader>
<p v-if="cancelTarget" class="text-sm text-muted-foreground">
{{ t("processList.killConfirm", { id: cancelTarget.id, user: cancelTarget.user }) }}
{{ t(transactionMode ? "processList.transactionKillConfirm" : "processList.killConfirm", { id: cancelTarget.transactionId ?? cancelTarget.id, node: cancelTarget.nodeId, user: cancelTarget.user, db: cancelTarget.db }) }}
</p>
<DialogFooter>
<Button variant="outline" :disabled="canceling" @click="cancelTarget = null">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" :disabled="canceling" @click="confirmCancel">
<Loader2 v-if="canceling" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
{{ t("processList.kill") }}
{{ t(transactionMode ? "processList.transactionKill" : "processList.kill") }}
</Button>
</DialogFooter>
</DialogContent>
@@ -4,6 +4,7 @@ import { createApp, defineComponent, h, nextTick, type App, type Component } fro
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig } from "@/types/database";
import { PG_PROCESS_LIST_SQL } from "@/lib/database/postgresProcessList";
import { XUGU_OWN_SESSION_SQL, XUGU_TRANSACTION_LIST_SQL } from "@/lib/database/xuguProcessList";
const mocks = vi.hoisted(() => ({
ensureConnected: vi.fn(),
@@ -120,6 +121,27 @@ const postgresConnection: ConnectionConfig = {
password: "",
};
const xuguConnection: ConnectionConfig = {
id: "xugu-1",
name: "Xugu",
db_type: "xugu",
database: "SHOP_DEMO",
host: "localhost",
port: 5138,
username: "SYSDBA",
password: "",
};
function xuguTransactionResult() {
return {
columns: ["NODE_ID", "TRANSACTION_ID", "SESSION_ID", "USER_NAME", "DB_NAME", "CLIENT_IP", "START_TIME"],
rows: [
[1, "100", 5, "SYSDBA", "SYSTEM", "127.0.0.1", "2026-09-30 10:00:00"],
[2, "9007199254740993", 7, "APP_TEST", "SHOP_DEMO", "10.0.0.7", "2026-09-30 09:00:00"],
],
};
}
function mysqlListResult() {
return {
columns: ["Id", "User", "Host", "db", "Command", "Time", "State", "Info"],
@@ -153,6 +175,8 @@ beforeEach(() => {
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();
if (sql === XUGU_OWN_SESSION_SQL) return { columns: ["NODEID", "SESSION_ID"], rows: [[1, 5]] };
if (sql === XUGU_TRANSACTION_LIST_SQL) return xuguTransactionResult();
return { columns: [], rows: [] };
});
mocks.executeMulti.mockReset().mockResolvedValue([{ columns: [], rows: [] }]);
@@ -203,6 +227,13 @@ async function selectSessions(...ids: number[]) {
}
}
async function mountXuguPanel() {
app = createApp(ProcessListPanel, { connection: xuguConnection });
app.mount(root!);
await vi.waitFor(() => expect(root?.textContent).toContain("9007199254740993"));
await nextTick();
}
describe("ProcessListPanel batch terminate", () => {
it("terminates every selected session one by one after confirmation", async () => {
await mountPanel(mysqlConnection);
@@ -320,4 +351,96 @@ describe("ProcessListPanel batch terminate", () => {
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"));
});
it("uses transaction labels and SYSTEM context without changing session controls", async () => {
await mountXuguPanel();
expect(root?.textContent).toContain("processList.transactionTitle");
expect(root?.textContent).toContain("processList.transactionScope");
expect(root?.textContent).toContain("processList.transactionCount:2");
expect(root?.textContent).not.toContain("processList.terminate");
expect(root?.querySelector("input[type=checkbox][aria-label^='processList.selectSession']")).toBeNull();
expect(mocks.executeQuery).toHaveBeenCalledWith("xugu-1", "SYSTEM", XUGU_OWN_SESSION_SQL, undefined, undefined, { maxRows: 1 });
expect(mocks.executeQuery).toHaveBeenCalledWith("xugu-1", "SYSTEM", XUGU_TRANSACTION_LIST_SQL, undefined, undefined, { maxRows: 5000 });
const killButtons = gridButtons().filter((button) => button.textContent?.trim() === "processList.transactionKill");
expect(killButtons).toHaveLength(2);
expect(killButtons[0]?.disabled).toBe(true);
expect(killButtons[1]?.disabled).toBe(false);
killButtons[1]?.click();
await nextTick();
expect(root?.textContent).toContain("processList.transactionKillConfirm:9007199254740993,2,APP_TEST,SHOP_DEMO");
findDialogButton("processList.transactionKill")?.click();
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledWith("xugu-1", "SYSTEM", "CALL DBMS_DBA.KILL_TRANS(2, 9007199254740993)", undefined, undefined, { maxRows: 1 }));
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("processList.transactionKillSuccess:9007199254740993", 2500));
});
it("does not query or offer termination if an old Xugu tab belongs to a non-SYSDBA login", async () => {
app = createApp(ProcessListPanel, { connection: { ...xuguConnection, username: "DBX_7102_DBA" } });
app.mount(root!);
await nextTick();
expect(root?.textContent).toContain("processList.transactionRequiresSysdba");
expect(root?.textContent).not.toContain("processList.transactionCount");
expect(gridButtons().filter((button) => button.textContent?.includes("processList.transactionKill"))).toHaveLength(0);
expect(mocks.ensureConnected).not.toHaveBeenCalled();
expect(mocks.executeQuery).not.toHaveBeenCalled();
});
it("keeps Xugu termination disabled when its own session cannot be identified", async () => {
mocks.executeQuery.mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
if (sql === XUGU_OWN_SESSION_SQL) throw new Error("session unavailable");
if (sql === XUGU_TRANSACTION_LIST_SQL) return xuguTransactionResult();
return { columns: [], rows: [] };
});
await mountXuguPanel();
expect(root?.textContent).toContain("processList.transactionNeedsSession");
expect(
gridButtons()
.filter((button) => button.textContent?.trim() === "processList.transactionKill")
.every((button) => button.disabled),
).toBe(true);
});
it("rechecks the Xugu session before killing when the connection changed during confirmation", async () => {
await mountXuguPanel();
gridButtons()
.filter((button) => button.textContent?.trim() === "processList.transactionKill")[1]
?.click();
await nextTick();
mocks.executeQuery.mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
if (sql === XUGU_OWN_SESSION_SQL) return { columns: ["NODEID", "SESSION_ID"], rows: [[2, 7]] };
if (sql === XUGU_TRANSACTION_LIST_SQL) return xuguTransactionResult();
return { columns: [], rows: [] };
});
findDialogButton("processList.transactionKill")?.click();
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("processList.transactionKillFailed:processList.transactionCannotKillSelf", 5000));
expect(mocks.executeMulti).not.toHaveBeenCalled();
});
it("explains failed Xugu SYSDBA reads without exposing a termination action", async () => {
mocks.executeQuery.mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
if (sql === XUGU_OWN_SESSION_SQL) return { columns: ["NODEID", "SESSION_ID"], rows: [[1, 5]] };
if (sql === XUGU_TRANSACTION_LIST_SQL) throw new Error("E18012: permission denied");
return { columns: [], rows: [] };
});
app = createApp(ProcessListPanel, { connection: xuguConnection });
app.mount(root!);
await vi.waitFor(() => expect(root?.textContent).toContain("processList.transactionLoadFailed:E18012: permission denied"));
expect(gridButtons().filter((button) => button.textContent?.trim() === "processList.transactionKill")).toHaveLength(0);
expect(root?.textContent).not.toContain("processList.transactionEmpty");
expect(root?.textContent).not.toContain("processList.transactionCount:0");
});
it("clears stale Xugu kill targets after a connection failure on refresh", async () => {
await mountXuguPanel();
expect(gridButtons().filter((button) => button.textContent?.trim() === "processList.transactionKill")).toHaveLength(2);
mocks.executeQuery.mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
if (sql === XUGU_TRANSACTION_LIST_SQL) throw new Error("connection reset by peer");
return { columns: ["NODEID", "SESSION_ID"], rows: [[1, 5]] };
});
findGridButton("grid.refresh")?.click();
await vi.waitFor(() => expect(root?.textContent).toContain("processList.transactionLoadFailed:connection reset by peer"));
expect(gridButtons().filter((button) => button.textContent?.trim() === "processList.transactionKill")).toHaveLength(0);
expect(root?.textContent).not.toContain("processList.transactionEmpty");
expect(root?.textContent).not.toContain("processList.transactionCount:0");
});
});
@@ -5671,7 +5671,7 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
items.push({ label: t("contextMenu.userAdmin"), action: openUserAdmin, icon: UsersRound });
}
if (node.connectionId && connectionSupportsProcessList(connectionStore.getConfig(node.connectionId))) {
items.push({ label: t("contextMenu.processList"), action: openProcessList, icon: Activity });
items.push({ label: t(currentDatabaseType() === "xugu" ? "processList.transactionTitle" : "contextMenu.processList"), action: openProcessList, icon: Activity });
}
if (currentDatabaseType() === "sqlserver") {
items.push({ label: t("contextMenu.sqlServerTrace"), action: openSqlServerActivityTrace, icon: Activity });
+18
View File
@@ -4410,6 +4410,24 @@ export default {
noInstalled: "No extensions installed.",
},
processList: {
transactionTitle: "Active Transactions",
transactionScope: "SYSDBA only. Reads active transactions from SYSTEM; idle sessions are not listed.",
transactionRequiresSysdba: "Xugu transaction management requires a SYSDBA connection to query SYSTEM. A business-database DBA grant is not equivalent to SYSDBA.",
transactionCount: "{count} transactions",
transactionFilter: "Filter transactions",
transactionNode: "Node",
transactionId: "Transaction ID",
transactionSession: "Session ID",
transactionStart: "Started",
transactionEmpty: "No active transactions.",
transactionNeedsSession: "Current session could not be identified; transaction termination is disabled.",
transactionLoadFailed: "Cannot load Xugu transactions. Check that the SYSDBA connection can access SYSTEM and that the database is reachable. Details: {message}",
transactionKill: "Terminate transaction",
transactionCannotKillSelf: "Cannot terminate the current session's transaction",
transactionKillTitle: "Terminate transaction",
transactionKillConfirm: "Terminate transaction {id} on node {node} (user {user}, database {db})? Its uncommitted work will roll back; the session will remain connected.",
transactionKillSuccess: "Terminated transaction {id}",
transactionKillFailed: "Could not terminate transaction: {message}",
batchCancel: "Cancel selected queries ({count})",
batchNeedsSession: "Current session is unknown. Refresh and try again.",
selectAll: "Select eligible loaded sessions in the current filter",
+18
View File
@@ -4320,6 +4320,24 @@ export default withEnglishFallback({
noInstalled: "暂无已安装的扩展。",
},
processList: {
transactionTitle: "活跃事务",
transactionScope: "仅 SYSDBA 可用;在 SYSTEM 库查询实例的活跃事务,不包含空闲会话。",
transactionRequiresSysdba: "虚谷事务管理需要使用 SYSDBA 连接,并在 SYSTEM 库查询。业务库的 DBA 权限不等同于 SYSDBA。",
transactionCount: "{count} 个事务",
transactionFilter: "筛选事务",
transactionNode: "节点",
transactionId: "事务 ID",
transactionSession: "会话 ID",
transactionStart: "开始时间",
transactionEmpty: "没有活跃事务。",
transactionNeedsSession: "无法识别当前会话,已禁用中止事务操作。",
transactionLoadFailed: "无法读取虚谷事务;请确认 SYSDBA 连接可访问 SYSTEM 库,并检查数据库连接状态。详情:{message}",
transactionKill: "中止事务",
transactionCannotKillSelf: "不能中止当前会话的事务",
transactionKillTitle: "中止事务",
transactionKillConfirm: "确定中止节点 {node} 上的事务 {id}(用户 {user},数据库 {db})?未提交的操作将回滚,但会话不会断开。",
transactionKillSuccess: "已中止事务 {id}",
transactionKillFailed: "中止事务失败:{message}",
batchCancel: "批量取消查询({count})",
batchNeedsSession: "尚未识别当前会话,请刷新后重试",
selectAll: "选择当前筛选结果中已加载的可选会话",
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { connectionSupportsProcessList, resolveProcessListDriver, resolveProcessListDriverForConnection } from "@/lib/database/processListDrivers";
import { assessProductionSql } from "@/lib/database/productionSafety";
import { buildXuguKillTransactionSql, mapXuguTransactionRows, XUGU_OWN_SESSION_SQL, XUGU_TRANSACTION_LIST_SQL, xuguTransactionKey } from "@/lib/database/xuguProcessList";
describe("Xugu active transaction management", () => {
it("uses the system-wide active-transaction join, not all idle sessions", () => {
expect(XUGU_TRANSACTION_LIST_SQL).toContain("FROM SYS_ALL_TRANS T");
expect(XUGU_TRANSACTION_LIST_SQL).toContain("JOIN SYS_ALL_SESSIONS S");
expect(XUGU_TRANSACTION_LIST_SQL).toContain("T.NODEID = S.NODEID AND T.TRANID = S.CURR_TID");
expect(XUGU_TRANSACTION_LIST_SQL).toContain("CAST(T.TRANID AS VARCHAR(32))");
expect(XUGU_OWN_SESSION_SQL).toContain("USERENV('SID')");
});
it("maps shuffled columns while preserving a BIGINT transaction id exactly", () => {
const rows = mapXuguTransactionRows({
columns: ["TRANSACTION_ID", "USER_NAME", "SESSION_ID", "NODE_ID", "CLIENT_IP", "START_TIME", "DB_NAME"],
rows: [["9007199254740993", "APP_TEST", 7, 2, "10.0.0.1", "2026-09-30 10:00:00", "SHOP_DEMO"]],
});
expect(rows).toEqual([{ id: 7, nodeId: 2, transactionId: "9007199254740993", user: "APP_TEST", db: "SHOP_DEMO", host: "10.0.0.1", startTime: "2026-09-30 10:00:00" }]);
expect(xuguTransactionKey(rows[0]!)).toBe("2:9007199254740993:7");
expect(buildXuguKillTransactionSql(rows[0]!)).toBe("CALL DBMS_DBA.KILL_TRANS(2, 9007199254740993)");
});
it("distinguishes equal session and transaction numbers on different nodes", () => {
const rows = mapXuguTransactionRows({
columns: ["NODE_ID", "TRANSACTION_ID", "SESSION_ID", "USER_NAME"],
rows: [
[1, "123", 5, "A"],
[2, "123", 5, "B"],
],
});
expect(rows).toHaveLength(2);
expect(rows.map(xuguTransactionKey)).toEqual(["1:123:5", "2:123:5"]);
});
it("drops unusable identities and rejects unsafe SQL inputs", () => {
expect(
mapXuguTransactionRows({
columns: ["NODE_ID", "TRANSACTION_ID", "SESSION_ID"],
rows: [
[1, "0", 2],
[0, "12", 2],
[1, "1; DROP TABLE X", 2],
[1, "9223372036854775808", 2],
[1, "12", "not-an-id"],
],
}),
).toEqual([]);
expect(() => buildXuguKillTransactionSql({ nodeId: 1, transactionId: "1); DROP TABLE X" })).toThrow("Invalid Xugu transaction target");
expect(() => buildXuguKillTransactionSql({ nodeId: Number.MAX_SAFE_INTEGER + 1, transactionId: "2" })).toThrow("Invalid Xugu transaction target");
});
it("registers Xugu without changing the other process-list drivers", () => {
const xugu = resolveProcessListDriver("xugu");
expect(xugu?.mode).toBe("transaction");
expect(xugu?.database).toBe("SYSTEM");
expect(xugu?.buildCancelQuerySql).toBeUndefined();
expect(xugu?.buildKillTransactionSql?.({ id: 7, nodeId: 2, transactionId: "9007199254740993" })).toBe("CALL DBMS_DBA.KILL_TRANS(2, 9007199254740993)");
expect(xugu?.buildTerminateSessionSql).toBeUndefined();
expect(xugu?.supportsBatchCancel).toBeUndefined();
expect(resolveProcessListDriverForConnection({ id: "x", db_type: "xugu", username: "SYSDBA", database: "SHOP_DEMO" } as any)).toBe(xugu);
for (const type of ["mysql", "postgres", "opengauss", "kingbase"] as const) {
expect(resolveProcessListDriver(type)?.mode).toBeUndefined();
expect(resolveProcessListDriver(type)?.database).toBeUndefined();
}
});
it("only exposes Xugu transaction management to SYSDBA connections", () => {
for (const username of ["SYSDBA", " sysdba "]) {
expect(connectionSupportsProcessList({ id: "x", db_type: "xugu", username, database: "SHOP_DEMO" } as any)).toBe(true);
}
for (const username of ["APP_TEST", "DBX_7102_DBA", "", undefined]) {
const connection = { id: "x", db_type: "xugu", username, database: "SYSTEM" } as any;
expect(resolveProcessListDriverForConnection(connection)).toBeNull();
expect(connectionSupportsProcessList(connection)).toBe(false);
}
});
it("classifies termination as a protected write for production connections", () => {
const sql = buildXuguKillTransactionSql({ nodeId: 1, transactionId: "42" });
expect(assessProductionSql(sql, { id: "x", db_type: "xugu", is_production: true } as any, "SYSTEM")).toMatchObject({ active: true, isMutation: true });
});
});
@@ -1,5 +1,6 @@
import type { ConnectionConfig, DatabaseType, QueryResult } from "@/types/database";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { buildXuguKillTransactionSql, mapXuguTransactionRows, XUGU_OWN_SESSION_SQL, XUGU_TRANSACTION_LIST_SQL, xuguTransactionKey } from "./xuguProcessList";
import { buildCancelQuerySql as buildMysqlCancelQuerySql, buildTerminateSessionSql as buildMysqlTerminateSessionSql, mapProcessRows as mapMysqlProcessRows, PROCESS_LIST_SQL as MYSQL_PROCESS_LIST_SQL, supportsProcessList as supportsMysqlProcessList } from "./mysqlProcessList";
import {
buildKingbaseCancelQuerySql,
@@ -32,12 +33,12 @@ import {
} from "./postgresProcessList";
/**
* Engine-agnostic process-list model. Each supported engine contributes a driver
* describing how to list sessions, identify the caller's own session, render the
* columns, and kill a session. The panel component stays entirely generic.
* Engine-agnostic operations model. Each supported engine contributes a driver
* describing how to list sessions or transactions, identify the caller's own
* session, render columns, and perform its explicitly named action.
*/
/** A displayable session row. `id` is the value passed to the driver's kill SQL. */
/** A displayable row. `id` is the session/process id; transaction drivers use a separate composite target. */
export type ProcessRow = { id: number } & Record<string, string | number | null>;
export interface ProcessColumn {
@@ -54,9 +55,15 @@ export interface ProcessColumn {
}
export interface ProcessListDriver {
/** Xugu displays active transactions rather than a full session list. */
mode?: "transaction";
/** Execution database for a system-wide administrative view. */
database?: string;
/** Stable row key when a server-local session id is not globally unique. */
rowKey?(row: ProcessRow): string;
/** Whether the panel offers multi-selection and batch query cancellation. */
supportsBatchCancel?: boolean;
/** SQL that lists current sessions, one row each. */
/** SQL that lists sessions or, for transaction mode, active transactions. */
listSql: string;
/** Compatibility query used when the primary list SQL references newer columns. */
fallbackListSql?: string;
@@ -77,7 +84,9 @@ export interface ProcessListDriver {
/** Map a raw list result into typed rows. */
mapRows(result: QueryResult | null | undefined): ProcessRow[];
/** Build the validated statement that cancels the selected session's running query. */
buildCancelQuerySql(id: number): string;
buildCancelQuerySql?(id: number): string;
/** Xugu-only action: end the selected transaction without disconnecting its session. */
buildKillTransactionSql?(row: ProcessRow): string;
/** Build the compatibility statement used when the primary cancellation function is unavailable. */
buildFallbackCancelQuerySql?(id: number): string;
/** Restrict cancellation fallback attempts to known compatibility failures. */
@@ -188,16 +197,40 @@ const KINGBASE_DRIVER: ProcessListDriver = {
fallbackTerminateSessionResultError: kingbasePgTerminateSessionResultError,
};
const XUGU_COLUMNS: ProcessColumn[] = [
{ key: "nodeId", labelKey: "processList.transactionNode", mono: true, numeric: true },
{ key: "transactionId", labelKey: "processList.transactionId", mono: true },
{ key: "id", labelKey: "processList.transactionSession", mono: true, numeric: true },
{ key: "user", labelKey: "processList.colUser" },
{ key: "db", labelKey: "processList.colDb" },
{ key: "host", labelKey: "processList.colClient" },
{ key: "startTime", labelKey: "processList.transactionStart" },
];
const XUGU_DRIVER: ProcessListDriver = {
mode: "transaction",
database: "SYSTEM",
listSql: XUGU_TRANSACTION_LIST_SQL,
ownSessionSql: XUGU_OWN_SESSION_SQL,
columns: XUGU_COLUMNS,
defaultSortKey: "startTime",
maxRows: 5000,
mapRows: (result) => mapXuguTransactionRows(result) as unknown as ProcessRow[],
rowKey: (row) => xuguTransactionKey(row as unknown as ReturnType<typeof mapXuguTransactionRows>[number]),
buildKillTransactionSql: (row) => buildXuguKillTransactionSql(row as unknown as ReturnType<typeof mapXuguTransactionRows>[number]),
};
/** Resolve the process-list driver for a connection, or null if unsupported. */
export function resolveProcessListDriver(dbType: DatabaseType | undefined): ProcessListDriver | null {
if (supportsMysqlProcessList(dbType)) return MYSQL_DRIVER;
if (dbType === "postgres") return POSTGRES_DRIVER;
if (dbType === "opengauss") return OPENGAUSS_DRIVER;
if (dbType === "kingbase") return KINGBASE_DRIVER;
if (dbType === "xugu") return XUGU_DRIVER;
return null;
}
/** Whether any process-list viewer (MySQL or Postgres family) covers this engine. */
/** Whether the shared operations panel covers this engine. */
export function supportsProcessList(dbType: DatabaseType | undefined): boolean {
return resolveProcessListDriver(dbType) !== null;
}
@@ -221,6 +254,9 @@ export function resolveProcessListDriverForConnection(connection: ConnectionConf
}
const dbType = effectiveDatabaseTypeForConnection(connection);
if (dbType === "gaussdb" && connection.driver_profile?.toLowerCase() === "opengauss") return OPENGAUSS_DRIVER;
// Xugu's SYS_* transaction views and DBMS_DBA.KILL_TRANS are queried through
// SYSTEM. A DBA grant in a business database does not make that login SYSDBA.
if (dbType === "xugu" && connection.username?.trim().toUpperCase() !== "SYSDBA") return null;
return resolveProcessListDriver(dbType);
}
@@ -0,0 +1,86 @@
import type { QueryResult } from "@/types/database";
/**
* Xugu's DBA operation kills a transaction, not its owning session. Keep the
* BIGINT transaction id as text so JSON/JavaScript never rounds the target.
* The join deliberately matches the transaction to its current session on
* both node and transaction id; idle sessions are not part of this view.
*/
export const XUGU_TRANSACTION_LIST_SQL = `SELECT T.NODEID AS NODE_ID,
CAST(T.TRANID AS VARCHAR(32)) AS TRANSACTION_ID,
S.SESSION_ID AS SESSION_ID,
S.USER_NAME AS USER_NAME,
S.DB_NAME AS DB_NAME,
S.IP AS CLIENT_IP,
T.START_T AS START_TIME
FROM SYS_ALL_TRANS T
JOIN SYS_ALL_SESSIONS S
ON T.NODEID = S.NODEID AND T.TRANID = S.CURR_TID
ORDER BY T.START_T`;
/** The SYSTEM control connection is serialized by the Xugu agent. */
export const XUGU_OWN_SESSION_SQL = "SELECT NODEID, SESSION_ID FROM SYS_SESSIONS WHERE SESSION_ID = USERENV('SID')";
export interface XuguTransactionRow {
/** Session id for display only; it is not the transaction kill target. */
id: number;
nodeId: number;
transactionId: string;
user: string;
db: string | null;
host: string | null;
startTime: string | null;
}
const MAX_SIGNED_BIGINT = 9223372036854775807n;
function columnIndex(columns: string[], name: string): number {
return columns.findIndex((column) => column.toUpperCase() === name);
}
function textAt(result: QueryResult, row: unknown[], name: string): string | null {
const index = columnIndex(result.columns, name);
const value = index < 0 ? null : row[index];
return value === null || value === undefined ? null : String(value);
}
function safeInteger(value: string | null): number | null {
if (value === null || !/^(0|[1-9]\d*)$/.test(value)) return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : null;
}
export function mapXuguTransactionRows(result: QueryResult | null | undefined): XuguTransactionRow[] {
if (!result?.rows) return [];
const mapped: XuguTransactionRow[] = [];
for (const raw of result.rows) {
const nodeId = safeInteger(textAt(result, raw, "NODE_ID"));
const sessionId = safeInteger(textAt(result, raw, "SESSION_ID"));
const transactionId = textAt(result, raw, "TRANSACTION_ID");
if (nodeId === null || nodeId <= 0 || sessionId === null || transactionId === null || !/^[1-9]\d*$/.test(transactionId)) continue;
const transaction = BigInt(transactionId);
if (transaction > MAX_SIGNED_BIGINT) continue;
mapped.push({
id: sessionId,
nodeId,
transactionId,
user: textAt(result, raw, "USER_NAME") ?? "",
db: textAt(result, raw, "DB_NAME"),
host: textAt(result, raw, "CLIENT_IP"),
startTime: textAt(result, raw, "START_TIME"),
});
}
return mapped;
}
export function xuguTransactionKey(row: XuguTransactionRow): string {
return `${row.nodeId}:${row.transactionId}:${row.id}`;
}
export function buildXuguKillTransactionSql(row: Pick<XuguTransactionRow, "nodeId" | "transactionId">): string {
const { nodeId, transactionId } = row;
if (!Number.isSafeInteger(nodeId) || nodeId <= 0 || !/^[1-9]\d*$/.test(transactionId)) throw new Error("Invalid Xugu transaction target");
const transaction = BigInt(transactionId);
if (transaction > MAX_SIGNED_BIGINT) throw new Error("Invalid Xugu transaction target");
return `CALL DBMS_DBA.KILL_TRANS(${nodeId}, ${transactionId})`;
}
+1 -1
View File
@@ -3420,7 +3420,7 @@ export const useQueryStore = defineStore("query", () => {
const id = uuid();
const tab: QueryTab = {
id,
title: conn?.name ? `${conn.name} - ${t("processList.title")}` : t("processList.title"),
title: conn?.name ? `${conn.name} - ${t(effectiveDatabaseTypeForConnection(conn) === "xugu" ? "processList.transactionTitle" : "processList.title")}` : t("processList.title"),
connectionId,
database: conn?.database || "",
sql: "",