feat(postgres): add process list viewer
This commit is contained in:
parent
9105df9a04
commit
7f096653db
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Activity, AlertTriangle, ArrowDown, ArrowUp, Ban, Loader2, RefreshCcw, Search } from "@lucide/vue";
|
||||
import { Activity, AlertTriangle, ArrowDown, ArrowUp, Ban, Copy, Loader2, RefreshCcw, Search } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -11,7 +11,8 @@ import { useToast } from "@/composables/useToast";
|
|||
import type { ConnectionConfig } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { buildKillSql, clampInterval, createProcessListLoadCoordinator, DEFAULT_REFRESH_SECONDS, mapProcessRows, processListExecutionError, processListSessionCount, PROCESS_LIST_SQL, type ProcessRow } from "@/lib/database/mysqlProcessList";
|
||||
import { clampInterval, createProcessListLoadCoordinator, DEFAULT_REFRESH_SECONDS, processListExecutionError, processListSessionCount } from "@/lib/database/mysqlProcessList";
|
||||
import { resolveProcessListDriverForConnection, type ProcessRow } from "@/lib/database/processListDrivers";
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionConfig;
|
||||
|
|
@ -21,7 +22,11 @@ const { t } = useI18n();
|
|||
const connectionStore = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
|
||||
type SortKey = keyof ProcessRow;
|
||||
// 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 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);
|
||||
|
|
@ -30,7 +35,7 @@ const loading = ref(false);
|
|||
const loadCoordinator = createProcessListLoadCoordinator();
|
||||
const loadError = ref("");
|
||||
const search = ref("");
|
||||
const sortKey = ref<SortKey>("time");
|
||||
const sortKey = ref<string>(driver.value?.defaultSortKey ?? "time");
|
||||
const sortDir = ref<"asc" | "desc">("desc");
|
||||
|
||||
const autoRefresh = ref(false);
|
||||
|
|
@ -39,21 +44,29 @@ let timer: ReturnType<typeof setInterval> | undefined;
|
|||
|
||||
const killTarget = ref<ProcessRow | null>(null);
|
||||
const killing = ref(false);
|
||||
const fallbackListSql = ref<string | null>(null);
|
||||
|
||||
const COLUMNS: { key: SortKey; labelKey: string; mono?: boolean }[] = [
|
||||
{ key: "id", labelKey: "processList.colId", mono: true },
|
||||
{ key: "user", labelKey: "processList.colUser" },
|
||||
{ key: "host", labelKey: "processList.colHost" },
|
||||
{ key: "db", labelKey: "processList.colDb" },
|
||||
{ key: "command", labelKey: "processList.colCommand" },
|
||||
{ key: "time", labelKey: "processList.colTime", mono: true },
|
||||
{ key: "state", labelKey: "processList.colState" },
|
||||
{ key: "info", labelKey: "processList.colInfo" },
|
||||
];
|
||||
// Full-text preview for long cells (SQL statement / info), opened by clicking them.
|
||||
const previewText = ref<string | null>(null);
|
||||
|
||||
function openPreview(value: string | number | null) {
|
||||
if (value === null || value === undefined || String(value).length === 0) return;
|
||||
previewText.value = String(value);
|
||||
}
|
||||
|
||||
async function copyPreview() {
|
||||
if (previewText.value === null) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(previewText.value);
|
||||
toast(t("processList.copied"), 1500);
|
||||
} catch (error: any) {
|
||||
toast(error?.message || String(error), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
const base = query ? rows.value.filter((row) => [row.id, row.user, row.host, row.db, row.command, row.state, row.info].some((value) => value !== null && value !== undefined && String(value).toLowerCase().includes(query))) : rows.value.slice();
|
||||
const base = query ? rows.value.filter((row) => Object.values(row).some((value) => value !== null && value !== undefined && String(value).toLowerCase().includes(query))) : rows.value.slice();
|
||||
const key = sortKey.value;
|
||||
const dir = sortDir.value === "asc" ? 1 : -1;
|
||||
return base.sort((a, b) => {
|
||||
|
|
@ -67,16 +80,18 @@ const filteredRows = computed(() => {
|
|||
});
|
||||
});
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
function toggleSort(key: string) {
|
||||
if (sortKey.value === key) {
|
||||
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortKey.value = key;
|
||||
sortDir.value = key === "time" || key === "id" ? "desc" : "asc";
|
||||
sortDir.value = numericKeys.value.has(key) ? "desc" : "asc";
|
||||
}
|
||||
}
|
||||
|
||||
async function load(options: { silent?: boolean } = {}) {
|
||||
const activeDriver = driver.value;
|
||||
if (!activeDriver) return;
|
||||
if (!loadCoordinator.tryStart()) return;
|
||||
if (!options.silent) loading.value = true;
|
||||
loadError.value = "";
|
||||
|
|
@ -84,7 +99,7 @@ async function load(options: { silent?: boolean } = {}) {
|
|||
await connectionStore.ensureConnected(props.connection.id);
|
||||
if (ownSessionId.value === null) {
|
||||
try {
|
||||
const idResult = await api.executeQuery(props.connection.id, "", "SELECT CONNECTION_ID()", undefined, undefined, { maxRows: 1 });
|
||||
const idResult = await api.executeQuery(props.connection.id, "", activeDriver.ownSessionSql, undefined, undefined, { maxRows: 1 });
|
||||
const raw = idResult?.rows?.[0]?.[0];
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed)) ownSessionId.value = parsed;
|
||||
|
|
@ -92,8 +107,17 @@ async function load(options: { silent?: boolean } = {}) {
|
|||
// Non-fatal: without our own id we simply cannot dim the self row.
|
||||
}
|
||||
}
|
||||
const result = await api.executeQuery(props.connection.id, "", PROCESS_LIST_SQL, undefined, undefined, { maxRows: 5000 });
|
||||
rows.value = mapProcessRows(result);
|
||||
const listSql = fallbackListSql.value ?? activeDriver.listSql;
|
||||
let result;
|
||||
try {
|
||||
result = await api.executeQuery(props.connection.id, "", listSql, undefined, undefined, { maxRows: activeDriver.maxRows });
|
||||
} catch (error) {
|
||||
if (fallbackListSql.value || !activeDriver.fallbackListSql || !activeDriver.shouldUseFallbackListSql?.(error)) throw error;
|
||||
result = await api.executeQuery(props.connection.id, "", activeDriver.fallbackListSql, undefined, undefined, { maxRows: activeDriver.maxRows });
|
||||
// Cache the compatible query so old servers do not fail once per refresh.
|
||||
fallbackListSql.value = activeDriver.fallbackListSql;
|
||||
}
|
||||
rows.value = activeDriver.mapRows(result);
|
||||
truncated.value = result.truncated === true;
|
||||
} catch (error: any) {
|
||||
loadError.value = error?.message || String(error);
|
||||
|
|
@ -114,10 +138,11 @@ function requestKill(row: ProcessRow) {
|
|||
|
||||
async function confirmKill() {
|
||||
const target = killTarget.value;
|
||||
if (!target) return;
|
||||
const activeDriver = driver.value;
|
||||
if (!target || !activeDriver) return;
|
||||
killing.value = true;
|
||||
try {
|
||||
const killSql = buildKillSql(target.id);
|
||||
const killSql = activeDriver.buildKillSql(target.id);
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection: props.connection,
|
||||
database: "",
|
||||
|
|
@ -128,6 +153,8 @@ async function confirmKill() {
|
|||
if (result === undefined) return;
|
||||
const executionError = processListExecutionError(result);
|
||||
if (executionError) throw new Error(executionError);
|
||||
const killResultError = activeDriver.killResultError?.(result);
|
||||
if (killResultError) throw new Error(killResultError);
|
||||
toast(t("processList.killSuccess", { id: target.id }), 2500);
|
||||
killTarget.value = null;
|
||||
await load({ silent: true });
|
||||
|
|
@ -174,6 +201,8 @@ watch(
|
|||
truncated.value = false;
|
||||
ownSessionId.value = null;
|
||||
search.value = "";
|
||||
sortKey.value = driver.value?.defaultSortKey ?? "time";
|
||||
sortDir.value = "desc";
|
||||
void load();
|
||||
},
|
||||
);
|
||||
|
|
@ -218,7 +247,7 @@ onBeforeUnmount(stopTimer);
|
|||
<table class="w-full border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-muted/40 backdrop-blur">
|
||||
<tr>
|
||||
<th v-for="column in COLUMNS" :key="column.key" class="cursor-pointer select-none whitespace-nowrap border-b px-3 py-2 text-left font-medium hover:bg-accent" @click="toggleSort(column.key)">
|
||||
<th v-for="column in columns" :key="column.key" class="cursor-pointer select-none whitespace-nowrap border-b px-3 py-2 text-left font-medium hover:bg-accent" @click="toggleSort(column.key)">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{{ t(column.labelKey) }}
|
||||
<ArrowUp v-if="sortKey === column.key && sortDir === 'asc'" class="h-3 w-3" />
|
||||
|
|
@ -230,17 +259,20 @@ onBeforeUnmount(stopTimer);
|
|||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in filteredRows" :key="row.id" class="border-b hover:bg-accent/40" :class="{ 'bg-primary/5': isOwnSession(row) }">
|
||||
<td class="whitespace-nowrap px-3 py-1.5 font-mono">
|
||||
{{ row.id }}
|
||||
<Badge v-if="isOwnSession(row)" variant="outline" class="ml-1 h-4 rounded px-1 text-[10px]">{{ t("processList.self") }}</Badge>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="px-3 py-1.5"
|
||||
:class="[column.mono ? 'font-mono' : '', column.wide ? 'max-w-md cursor-pointer truncate hover:text-foreground hover:underline' : 'whitespace-nowrap', column.wide || column.key === 'db' || column.key === 'state' || column.key === 'wait' ? 'text-muted-foreground' : '']"
|
||||
:title="column.wide ? (row[column.key] === null || row[column.key] === undefined ? '' : t('processList.previewTitle')) : undefined"
|
||||
@click="column.wide ? openPreview(row[column.key]) : undefined"
|
||||
>
|
||||
<template v-if="column.key === 'id'">
|
||||
{{ row.id }}
|
||||
<Badge v-if="isOwnSession(row)" variant="outline" class="ml-1 h-4 rounded px-1 text-[10px]">{{ t("processList.self") }}</Badge>
|
||||
</template>
|
||||
<template v-else>{{ row[column.key] === null || row[column.key] === undefined ? "—" : row[column.key] }}</template>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-1.5">{{ row.user }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-1.5">{{ row.host }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-1.5 text-muted-foreground">{{ row.db ?? "—" }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-1.5">{{ row.command }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-1.5 font-mono">{{ row.time }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-1.5 text-muted-foreground">{{ row.state ?? "—" }}</td>
|
||||
<td class="max-w-md truncate px-3 py-1.5 font-mono text-muted-foreground" :title="row.info ?? ''">{{ row.info ?? "—" }}</td>
|
||||
<td class="px-3 py-1.5 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -256,7 +288,7 @@ onBeforeUnmount(stopTimer);
|
|||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && filteredRows.length === 0">
|
||||
<td :colspan="COLUMNS.length + 1" class="px-3 py-10 text-center text-muted-foreground">
|
||||
<td :colspan="columns.length + 1" class="px-3 py-10 text-center text-muted-foreground">
|
||||
{{ search ? t("grid.noSearchResults") : t("processList.empty") }}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -291,5 +323,28 @@ onBeforeUnmount(stopTimer);
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
:open="previewText !== null"
|
||||
@update:open="
|
||||
(open) => {
|
||||
if (!open) previewText = null;
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogContent class="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("processList.previewTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<pre class="max-h-[60vh] overflow-auto whitespace-pre-wrap break-words rounded-md border bg-muted/30 p-3 font-mono text-xs">{{ previewText }}</pre>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" class="gap-1.5" @click="copyPreview">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
{{ t("processList.copy") }}
|
||||
</Button>
|
||||
<Button variant="secondary" @click="previewText = null">{{ t("dangerDialog.cancel") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -54,7 +54,7 @@ const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/
|
|||
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
|
||||
const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue"));
|
||||
const DatabaseUserAdmin = defineAsyncComponent(() => import("@/components/admin/DatabaseUserAdmin.vue"));
|
||||
const MySqlProcessList = defineAsyncComponent(() => import("@/components/admin/MySqlProcessList.vue"));
|
||||
const ProcessListPanel = defineAsyncComponent(() => import("@/components/admin/ProcessListPanel.vue"));
|
||||
const MySqlDashboard = defineAsyncComponent(() => import("@/components/admin/MySqlDashboard.vue"));
|
||||
const DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/DamengJobAdmin.vue"));
|
||||
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
|
||||
|
|
@ -1595,7 +1595,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'processlist' && activeConnection">
|
||||
<MySqlProcessList :key="activeTab.id" :connection="activeConnection" />
|
||||
<ProcessListPanel :key="activeTab.id" :connection="activeConnection" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'mysql-dashboard'">
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebar/sidebarLabelToo
|
|||
import { selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes, treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection";
|
||||
import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selectedConnectionDeleteTargets, selectedConnectionDuplicateTargets, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection";
|
||||
import { supportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin";
|
||||
import { supportsProcessList } from "@/lib/database/mysqlProcessList";
|
||||
import { connectionSupportsProcessList } from "@/lib/database/processListDrivers";
|
||||
import { connectionSupportsServerDashboard } from "@/lib/database/mysqlServerStatus";
|
||||
import { canCloseSidebarDatabaseConnection, isSidebarDatabaseOpened } from "@/lib/sidebar/sidebarDatabaseOpenState";
|
||||
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
|
||||
|
|
@ -4727,7 +4727,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
if (supportsDatabaseUserAdmin(currentDatabaseType())) {
|
||||
items.push({ label: t("contextMenu.userAdmin"), action: openUserAdmin, icon: UsersRound });
|
||||
}
|
||||
if (supportsProcessList(currentDatabaseType())) {
|
||||
if (node.connectionId && connectionSupportsProcessList(connectionStore.getConfig(node.connectionId))) {
|
||||
items.push({ label: t("contextMenu.processList"), action: openProcessList, icon: Activity });
|
||||
}
|
||||
if (node.connectionId && connectionSupportsServerDashboard(connectionStore.getConfig(node.connectionId))) {
|
||||
|
|
|
|||
|
|
@ -1717,6 +1717,11 @@ export default {
|
|||
colTime: "Time",
|
||||
colState: "State",
|
||||
colInfo: "Info",
|
||||
colPid: "PID",
|
||||
colClient: "Client",
|
||||
colApp: "Application",
|
||||
colWait: "Wait",
|
||||
colQuery: "Query",
|
||||
colActions: "Actions",
|
||||
self: "you",
|
||||
kill: "Kill",
|
||||
|
|
@ -1726,6 +1731,9 @@ export default {
|
|||
killConfirm: "Kill session {id} ({user})? Its current statement will be aborted and the connection closed.",
|
||||
killSuccess: "Session {id} killed",
|
||||
killFailed: "Failed to kill session: {message}",
|
||||
previewTitle: "Statement",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
},
|
||||
serverDashboard: {
|
||||
title: "Server Dashboard",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import { buildPgKillSql, isPgProcessListCompatibilityError, mapPgProcessRows, pgKillResultError, PG_PROCESS_LIST_LEGACY_SQL, PG_PROCESS_LIST_SQL, supportsPgProcessList } from "@/lib/database/postgresProcessList";
|
||||
import { connectionSupportsProcessList, resolveProcessListDriver, resolveProcessListDriverForConnection, supportsProcessList } from "@/lib/database/processListDrivers";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
function result(columns: string[], rows: (string | number | boolean | null)[][]): QueryResult {
|
||||
return { columns, rows, affected_rows: 0, execution_time_ms: 0 };
|
||||
}
|
||||
|
||||
describe("mapPgProcessRows", () => {
|
||||
it("maps a pg_stat_activity result into typed rows", () => {
|
||||
const rows = mapPgProcessRows(result(["pid", "user", "db", "client", "app", "state", "wait", "time", "query"], [[4211, "app", "shop", "10.0.0.4", "psql", "active", "Client:ClientRead", 12, "SELECT * FROM orders"]]));
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
id: 4211,
|
||||
user: "app",
|
||||
db: "shop",
|
||||
client: "10.0.0.4",
|
||||
app: "psql",
|
||||
state: "active",
|
||||
wait: "Client:ClientRead",
|
||||
time: 12,
|
||||
query: "SELECT * FROM orders",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("tolerates NULL columns and case-variant names", () => {
|
||||
const rows = mapPgProcessRows(result(["PID", "USER", "DB", "CLIENT", "APP", "STATE", "WAIT", "TIME", "QUERY"], [["4200", "postgres", null, "local", null, "idle", null, "340", null]]));
|
||||
expect(rows[0]).toMatchObject({ id: 4200, user: "postgres", db: null, app: null, state: "idle", wait: null, time: 340, query: null });
|
||||
});
|
||||
|
||||
it("returns an empty array for empty or malformed input", () => {
|
||||
expect(mapPgProcessRows(null)).toEqual([]);
|
||||
expect(mapPgProcessRows(undefined)).toEqual([]);
|
||||
expect(mapPgProcessRows(result([], []))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPgKillSql", () => {
|
||||
it("builds pg_terminate_backend for a valid pid", () => {
|
||||
expect(buildPgKillSql(4211)).toBe("SELECT pg_terminate_backend(4211)");
|
||||
});
|
||||
|
||||
it("rejects non-integer, zero, or negative pids", () => {
|
||||
expect(() => buildPgKillSql(1.5)).toThrow();
|
||||
expect(() => buildPgKillSql(0)).toThrow();
|
||||
expect(() => buildPgKillSql(-1)).toThrow();
|
||||
expect(() => buildPgKillSql(Number.NaN)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Postgres compatibility", () => {
|
||||
it("provides a pre-9.6 query and only falls back for missing wait-event columns", () => {
|
||||
expect(PG_PROCESS_LIST_SQL).toContain("wait_event_type");
|
||||
expect(PG_PROCESS_LIST_LEGACY_SQL).toContain("CASE WHEN waiting THEN 'Lock'");
|
||||
expect(PG_PROCESS_LIST_LEGACY_SQL).not.toContain("wait_event_type");
|
||||
expect(isPgProcessListCompatibilityError(new Error('column "wait_event_type" does not exist'))).toBe(true);
|
||||
expect(isPgProcessListCompatibilityError({ code: "42703" })).toBe(true);
|
||||
expect(isPgProcessListCompatibilityError(new Error("permission denied for view pg_stat_activity"))).toBe(false);
|
||||
});
|
||||
|
||||
it("requires pg_terminate_backend to confirm termination", () => {
|
||||
expect(pgKillResultError([result(["pg_terminate_backend"], [[true]])])).toBeNull();
|
||||
expect(pgKillResultError([result(["pg_terminate_backend"], [["t"]])])).toBeNull();
|
||||
expect(pgKillResultError([result(["pg_terminate_backend"], [[false]])])).toContain("did not terminate");
|
||||
expect(pgKillResultError([result(["pg_terminate_backend"], [])])).toContain("did not terminate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsPgProcessList", () => {
|
||||
it("covers the Postgres-kernel family and excludes divergent wire-protocol engines", () => {
|
||||
expect(supportsPgProcessList("postgres")).toBe(true);
|
||||
// Postgres-kernel forks are unverified for now, so they stay excluded.
|
||||
expect(supportsPgProcessList("opengauss")).toBe(false);
|
||||
expect(supportsPgProcessList("kingbase")).toBe(false);
|
||||
expect(supportsPgProcessList("redshift")).toBe(false);
|
||||
expect(supportsPgProcessList("questdb")).toBe(false);
|
||||
expect(supportsPgProcessList("mysql")).toBe(false);
|
||||
expect(supportsPgProcessList(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProcessListDriver", () => {
|
||||
it("routes MySQL and Postgres engines to distinct drivers", () => {
|
||||
const mysql = resolveProcessListDriver("mysql");
|
||||
const postgres = resolveProcessListDriver("postgres");
|
||||
expect(mysql?.buildKillSql(7)).toBe("KILL CONNECTION 7");
|
||||
expect(postgres?.buildKillSql(7)).toBe("SELECT pg_terminate_backend(7)");
|
||||
expect(postgres?.fallbackListSql).toBe(PG_PROCESS_LIST_LEGACY_SQL);
|
||||
expect(postgres?.shouldUseFallbackListSql?.(new Error('column "wait_event" does not exist'))).toBe(true);
|
||||
expect(postgres?.killResultError?.([result(["pg_terminate_backend"], [[false]])])).toContain("did not terminate");
|
||||
expect(resolveProcessListDriver("sqlite")).toBeNull();
|
||||
});
|
||||
|
||||
it("unifies process-list support across both families", () => {
|
||||
expect(supportsProcessList("mysql")).toBe(true);
|
||||
expect(supportsProcessList("postgres")).toBe(true);
|
||||
expect(supportsProcessList("sqlite")).toBe(false);
|
||||
expect(supportsProcessList(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function conn(partial: Partial<ConnectionConfig>): ConnectionConfig {
|
||||
return { db_type: "mysql", ...partial } as ConnectionConfig;
|
||||
}
|
||||
|
||||
describe("connectionSupportsProcessList", () => {
|
||||
it("gates on the real connection profile", () => {
|
||||
expect(connectionSupportsProcessList(conn({ db_type: "mysql" }))).toBe(true);
|
||||
expect(connectionSupportsProcessList(conn({ db_type: "postgres" }))).toBe(true);
|
||||
expect(connectionSupportsProcessList(conn({ db_type: "sqlite" }))).toBe(false);
|
||||
expect(connectionSupportsProcessList(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves JDBC connections by their effective engine", () => {
|
||||
expect(connectionSupportsProcessList(conn({ db_type: "jdbc", connection_string: "jdbc:mysql://db:3306/app" }))).toBe(true);
|
||||
expect(connectionSupportsProcessList(conn({ db_type: "jdbc", connection_string: "jdbc:postgresql://db:5432/app" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes Kyuubi / Hive2 JDBC that infer as MySQL but cannot serve SHOW PROCESSLIST", () => {
|
||||
expect(connectionSupportsProcessList(conn({ db_type: "jdbc", connection_string: "jdbc:kyuubi://gw:10009/" }))).toBe(false);
|
||||
expect(connectionSupportsProcessList(conn({ db_type: "jdbc", connection_string: "jdbc:hive2://hs2:10000/default" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProcessListDriverForConnection", () => {
|
||||
it("returns the engine driver for a JDBC MySQL connection and null for lookalikes", () => {
|
||||
expect(resolveProcessListDriverForConnection(conn({ db_type: "jdbc", connection_string: "jdbc:mysql://db/app" }))?.buildKillSql(9)).toBe("KILL CONNECTION 9");
|
||||
expect(resolveProcessListDriverForConnection(conn({ db_type: "jdbc", connection_string: "jdbc:hive2://hs2/default" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
|
||||
/**
|
||||
* PostgreSQL "current activity / process list" helpers. Pure and framework-free
|
||||
* so they can be unit-tested in isolation; the generic panel component wires them
|
||||
* to the SQL bridge and the production-safety guard via the driver registry.
|
||||
*
|
||||
* The MySQL family lives in `./mysqlProcessList`; the generic, engine-agnostic
|
||||
* bits (coordinator, interval clamping, session counting) are shared from there.
|
||||
*/
|
||||
|
||||
const PG_PROCESS_LIST_DB_TYPES = new Set<DatabaseType>(["postgres"]);
|
||||
|
||||
/**
|
||||
* One row per server-side backend. `now() - query_start` gives the age of the
|
||||
* currently running (or last) statement; we fall back to the transaction and
|
||||
* backend start so idle sessions still report a sensible age. Own session is
|
||||
* kept in the result set so the panel can dim it rather than hide it.
|
||||
*/
|
||||
export const PG_PROCESS_LIST_SQL = `SELECT pid,
|
||||
usename AS "user",
|
||||
datname AS db,
|
||||
coalesce(host(client_addr), client_hostname, 'local') AS client,
|
||||
application_name AS app,
|
||||
state,
|
||||
coalesce(nullif(wait_event_type, '') || ':' || wait_event, wait_event_type, '') AS wait,
|
||||
floor(extract(epoch FROM (now() - coalesce(query_start, xact_start, backend_start))))::bigint AS time,
|
||||
query
|
||||
FROM pg_stat_activity
|
||||
ORDER BY time DESC NULLS LAST`;
|
||||
|
||||
/** PostgreSQL 9.2-9.5 expose `waiting` instead of wait-event detail columns. */
|
||||
export const PG_PROCESS_LIST_LEGACY_SQL = `SELECT pid,
|
||||
usename AS "user",
|
||||
datname AS db,
|
||||
coalesce(host(client_addr), client_hostname, 'local') AS client,
|
||||
application_name AS app,
|
||||
state,
|
||||
CASE WHEN waiting THEN 'Lock' ELSE '' END AS wait,
|
||||
floor(extract(epoch FROM (now() - coalesce(query_start, xact_start, backend_start))))::bigint AS time,
|
||||
query
|
||||
FROM pg_stat_activity
|
||||
ORDER BY time DESC NULLS LAST`;
|
||||
|
||||
/** Scalar query that returns the viewer's own backend pid. */
|
||||
export const PG_OWN_SESSION_SQL = "SELECT pg_backend_pid()";
|
||||
|
||||
export interface PgProcessRow {
|
||||
id: number;
|
||||
user: string;
|
||||
db: string | null;
|
||||
client: string;
|
||||
app: string | null;
|
||||
state: string | null;
|
||||
wait: string | null;
|
||||
time: number;
|
||||
query: string | null;
|
||||
}
|
||||
|
||||
function columnIndex(columns: string[], name: string): number {
|
||||
const target = name.toLowerCase();
|
||||
return columns.findIndex((column) => column.toLowerCase() === target);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function asNullableString(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const text = String(value);
|
||||
return text.length === 0 ? null : text;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
if (typeof value === "number") return value;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a `pg_stat_activity` result into typed rows. Column names are matched
|
||||
* case-insensitively and any missing column degrades to an empty value rather
|
||||
* than throwing, so forks that rename or drop a column still render.
|
||||
*/
|
||||
export function mapPgProcessRows(result: QueryResult | null | undefined): PgProcessRow[] {
|
||||
if (!result || !Array.isArray(result.columns) || !Array.isArray(result.rows)) return [];
|
||||
const columns = result.columns;
|
||||
const pidIdx = columnIndex(columns, "pid");
|
||||
const userIdx = columnIndex(columns, "user");
|
||||
const dbIdx = columnIndex(columns, "db");
|
||||
const clientIdx = columnIndex(columns, "client");
|
||||
const appIdx = columnIndex(columns, "app");
|
||||
const stateIdx = columnIndex(columns, "state");
|
||||
const waitIdx = columnIndex(columns, "wait");
|
||||
const timeIdx = columnIndex(columns, "time");
|
||||
const queryIdx = columnIndex(columns, "query");
|
||||
|
||||
const cell = (row: (string | number | boolean | null)[], idx: number) => (idx >= 0 ? row[idx] : null);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: asNumber(cell(row, pidIdx)),
|
||||
user: asString(cell(row, userIdx)),
|
||||
db: asNullableString(cell(row, dbIdx)),
|
||||
client: asString(cell(row, clientIdx)),
|
||||
app: asNullableString(cell(row, appIdx)),
|
||||
state: asNullableString(cell(row, stateIdx)),
|
||||
wait: asNullableString(cell(row, waitIdx)),
|
||||
time: asNumber(cell(row, timeIdx)),
|
||||
query: asNullableString(cell(row, queryIdx)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `SELECT pg_terminate_backend(<pid>)` statement. `pid` is validated as a
|
||||
* finite positive integer (never interpolated as free text) so there is no
|
||||
* injection path.
|
||||
*/
|
||||
export function buildPgKillSql(pid: number): string {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
throw new Error(`Invalid backend pid: ${pid}`);
|
||||
}
|
||||
return `SELECT pg_terminate_backend(${pid})`;
|
||||
}
|
||||
|
||||
/** Return an error when PostgreSQL declines to terminate the target backend. */
|
||||
export function pgKillResultError(results: QueryResult[]): string | null {
|
||||
const result = results.find((item) => item.execution_error !== true);
|
||||
const value = result?.rows?.[0]?.[0];
|
||||
if (value === true || value === 1 || String(value).toLowerCase() === "t" || String(value).toLowerCase() === "true") return null;
|
||||
return "pg_terminate_backend did not terminate the backend";
|
||||
}
|
||||
|
||||
/** Detect the undefined-column failure produced by pre-9.6 pg_stat_activity. */
|
||||
export function isPgProcessListCompatibilityError(error: unknown): boolean {
|
||||
const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code ?? "") : "";
|
||||
if (code === "42703") return true;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /(?:wait_event_type|wait_event).*(?:does not exist|42703)|(?:does not exist|42703).*(?:wait_event_type|wait_event)/i.test(message);
|
||||
}
|
||||
|
||||
/** Whether the given database type exposes the Postgres process-list viewer. */
|
||||
export function supportsPgProcessList(dbType: DatabaseType | undefined): boolean {
|
||||
return !!dbType && PG_PROCESS_LIST_DB_TYPES.has(dbType);
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import type { ConnectionConfig, DatabaseType, QueryResult } from "@/types/database";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { buildKillSql as buildMysqlKillSql, mapProcessRows as mapMysqlProcessRows, PROCESS_LIST_SQL as MYSQL_PROCESS_LIST_SQL, supportsProcessList as supportsMysqlProcessList } from "./mysqlProcessList";
|
||||
import { buildPgKillSql, isPgProcessListCompatibilityError, mapPgProcessRows, pgKillResultError, PG_OWN_SESSION_SQL, PG_PROCESS_LIST_LEGACY_SQL, PG_PROCESS_LIST_SQL, supportsPgProcessList } 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.
|
||||
*/
|
||||
|
||||
/** A displayable session row. `id` is the value passed to the driver's kill SQL. */
|
||||
export type ProcessRow = { id: number } & Record<string, string | number | null>;
|
||||
|
||||
export interface ProcessColumn {
|
||||
/** Key into the mapped row. */
|
||||
key: string;
|
||||
/** i18n key for the header label. */
|
||||
labelKey: string;
|
||||
/** Render the cell in a monospace font. */
|
||||
mono?: boolean;
|
||||
/** Sort numerically and default to descending on first click. */
|
||||
numeric?: boolean;
|
||||
/** Long free text (SQL statement) — truncate with a hover title. */
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
export interface ProcessListDriver {
|
||||
/** SQL that lists current sessions, one row each. */
|
||||
listSql: string;
|
||||
/** Compatibility query used when the primary list SQL references newer columns. */
|
||||
fallbackListSql?: string;
|
||||
/** Restrict fallback attempts to known version-compatibility failures. */
|
||||
shouldUseFallbackListSql?(error: unknown): boolean;
|
||||
/** Scalar SQL returning the caller's own session id (nullable path tolerated). */
|
||||
ownSessionSql: string;
|
||||
/** Columns to render, in display order. */
|
||||
columns: ProcessColumn[];
|
||||
/** Column key used for the initial sort. */
|
||||
defaultSortKey: string;
|
||||
/** Upper bound on rows fetched per refresh. */
|
||||
maxRows: number;
|
||||
/** Map a raw list result into typed rows. */
|
||||
mapRows(result: QueryResult | null | undefined): ProcessRow[];
|
||||
/** Build the validated statement that kills the given session id. */
|
||||
buildKillSql(id: number): string;
|
||||
/** Validate any engine-specific success value returned by the kill statement. */
|
||||
killResultError?(results: QueryResult[]): string | null;
|
||||
}
|
||||
|
||||
const MYSQL_COLUMNS: ProcessColumn[] = [
|
||||
{ key: "id", labelKey: "processList.colId", mono: true, numeric: true },
|
||||
{ key: "user", labelKey: "processList.colUser" },
|
||||
{ key: "host", labelKey: "processList.colHost" },
|
||||
{ key: "db", labelKey: "processList.colDb" },
|
||||
{ key: "command", labelKey: "processList.colCommand" },
|
||||
{ key: "time", labelKey: "processList.colTime", mono: true, numeric: true },
|
||||
{ key: "state", labelKey: "processList.colState" },
|
||||
{ key: "info", labelKey: "processList.colInfo", mono: true, wide: true },
|
||||
];
|
||||
|
||||
const POSTGRES_COLUMNS: ProcessColumn[] = [
|
||||
{ key: "id", labelKey: "processList.colPid", mono: true, numeric: true },
|
||||
{ key: "user", labelKey: "processList.colUser" },
|
||||
{ key: "db", labelKey: "processList.colDb" },
|
||||
{ key: "client", labelKey: "processList.colClient" },
|
||||
{ key: "app", labelKey: "processList.colApp" },
|
||||
{ key: "state", labelKey: "processList.colState" },
|
||||
{ key: "wait", labelKey: "processList.colWait" },
|
||||
{ key: "time", labelKey: "processList.colTime", mono: true, numeric: true },
|
||||
{ key: "query", labelKey: "processList.colQuery", mono: true, wide: true },
|
||||
];
|
||||
|
||||
const MYSQL_DRIVER: ProcessListDriver = {
|
||||
listSql: MYSQL_PROCESS_LIST_SQL,
|
||||
ownSessionSql: "SELECT CONNECTION_ID()",
|
||||
columns: MYSQL_COLUMNS,
|
||||
defaultSortKey: "time",
|
||||
maxRows: 5000,
|
||||
// Typed structs carry no index signature; they are plain string-keyed objects at runtime.
|
||||
mapRows: (result) => mapMysqlProcessRows(result) as unknown as ProcessRow[],
|
||||
buildKillSql: buildMysqlKillSql,
|
||||
};
|
||||
|
||||
const POSTGRES_DRIVER: ProcessListDriver = {
|
||||
listSql: PG_PROCESS_LIST_SQL,
|
||||
fallbackListSql: PG_PROCESS_LIST_LEGACY_SQL,
|
||||
shouldUseFallbackListSql: isPgProcessListCompatibilityError,
|
||||
ownSessionSql: PG_OWN_SESSION_SQL,
|
||||
columns: POSTGRES_COLUMNS,
|
||||
defaultSortKey: "time",
|
||||
maxRows: 5000,
|
||||
mapRows: (result) => mapPgProcessRows(result) as unknown as ProcessRow[],
|
||||
buildKillSql: buildPgKillSql,
|
||||
killResultError: pgKillResultError,
|
||||
};
|
||||
|
||||
/** 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 (supportsPgProcessList(dbType)) return POSTGRES_DRIVER;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether any process-list viewer (MySQL or Postgres family) covers this engine. */
|
||||
export function supportsProcessList(dbType: DatabaseType | undefined): boolean {
|
||||
return resolveProcessListDriver(dbType) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* JDBC profiles that only borrow MySQL SQL syntax (Kyuubi / HiveServer2) infer as
|
||||
* `mysql` but are Spark/Hive engines that cannot serve `SHOW FULL PROCESSLIST`.
|
||||
*/
|
||||
const MYSQL_LOOKALIKE_JDBC = /(?:kyuubi|hive2|org\.apache\.hive\.jdbc\.HiveDriver|hive-jdbc)/i;
|
||||
|
||||
/**
|
||||
* Resolve the process-list driver from the real connection profile. Uses the
|
||||
* effective engine (so JDBC connections that resolve to MySQL/Postgres work) and
|
||||
* excludes MySQL-lookalike JDBC engines that cannot serve the process list.
|
||||
*/
|
||||
export function resolveProcessListDriverForConnection(connection: ConnectionConfig | undefined): ProcessListDriver | null {
|
||||
if (!connection) return null;
|
||||
if (connection.db_type === "jdbc") {
|
||||
const profile = [connection.driver_profile, connection.connection_string, connection.jdbc_driver_class, ...(connection.jdbc_driver_paths ?? [])].filter(Boolean).join("\n");
|
||||
if (MYSQL_LOOKALIKE_JDBC.test(profile)) return null;
|
||||
}
|
||||
return resolveProcessListDriver(effectiveDatabaseTypeForConnection(connection));
|
||||
}
|
||||
|
||||
/** Connection-aware process-list gate (mirrors the server-dashboard gate). */
|
||||
export function connectionSupportsProcessList(connection: ConnectionConfig | undefined): boolean {
|
||||
return resolveProcessListDriverForConnection(connection) !== null;
|
||||
}
|
||||
|
|
@ -1013,7 +1013,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
const id = uuid();
|
||||
const tab: QueryTab = {
|
||||
id,
|
||||
title: t("processList.title"),
|
||||
title: conn?.name ? `${conn.name} - ${t("processList.title")}` : t("processList.title"),
|
||||
connectionId,
|
||||
database: conn?.database || "",
|
||||
sql: "",
|
||||
|
|
|
|||
Loading…
Reference in New Issue