mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(xugu): add active transaction management
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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: "",
|
||||
|
||||
Reference in New Issue
Block a user