feat(mysql): add process list viewer
This commit is contained in:
parent
6f5dffb279
commit
2dec6ebeb8
|
|
@ -0,0 +1,288 @@
|
|||
<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 { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
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, DEFAULT_REFRESH_SECONDS, mapProcessRows, PROCESS_LIST_SQL, type ProcessRow } from "@/lib/database/mysqlProcessList";
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionConfig;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
|
||||
type SortKey = keyof ProcessRow;
|
||||
|
||||
const rows = ref<ProcessRow[]>([]);
|
||||
const ownSessionId = ref<number | null>(null);
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const search = ref("");
|
||||
const sortKey = ref<SortKey>("time");
|
||||
const sortDir = ref<"asc" | "desc">("desc");
|
||||
|
||||
const autoRefresh = ref(false);
|
||||
const intervalSeconds = ref(DEFAULT_REFRESH_SECONDS);
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const killTarget = ref<ProcessRow | null>(null);
|
||||
const killing = ref(false);
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
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 key = sortKey.value;
|
||||
const dir = sortDir.value === "asc" ? 1 : -1;
|
||||
return base.sort((a, b) => {
|
||||
const av = a[key];
|
||||
const bv = b[key];
|
||||
if (av === bv) return 0;
|
||||
if (av === null || av === undefined) return 1;
|
||||
if (bv === null || bv === undefined) return -1;
|
||||
if (typeof av === "number" && typeof bv === "number") return (av - bv) * dir;
|
||||
return String(av).localeCompare(String(bv)) * dir;
|
||||
});
|
||||
});
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey.value === key) {
|
||||
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortKey.value = key;
|
||||
sortDir.value = key === "time" || key === "id" ? "desc" : "asc";
|
||||
}
|
||||
}
|
||||
|
||||
async function load(options: { silent?: boolean } = {}) {
|
||||
if (loading.value) return;
|
||||
if (!options.silent) loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
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 raw = idResult?.rows?.[0]?.[0];
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed)) ownSessionId.value = parsed;
|
||||
} catch {
|
||||
// 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);
|
||||
} catch (error: any) {
|
||||
loadError.value = error?.message || String(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isOwnSession(row: ProcessRow): boolean {
|
||||
return ownSessionId.value !== null && row.id === ownSessionId.value;
|
||||
}
|
||||
|
||||
function requestKill(row: ProcessRow) {
|
||||
if (isOwnSession(row)) return;
|
||||
killTarget.value = row;
|
||||
}
|
||||
|
||||
async function confirmKill() {
|
||||
const target = killTarget.value;
|
||||
if (!target) return;
|
||||
killing.value = true;
|
||||
try {
|
||||
const killSql = buildKillSql(target.id);
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection: props.connection,
|
||||
database: "",
|
||||
sql: killSql,
|
||||
source: t("production.sourceAdmin"),
|
||||
execute: () => api.executeMulti(props.connection.id, "", killSql, undefined, undefined, { maxRows: 1 }),
|
||||
});
|
||||
if (result === undefined) return;
|
||||
toast(t("processList.killSuccess", { id: target.id }), 2500);
|
||||
killTarget.value = null;
|
||||
await load({ silent: true });
|
||||
} catch (error: any) {
|
||||
toast(t("processList.killFailed", { message: error?.message || String(error) }), 5000);
|
||||
} finally {
|
||||
killing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function restartTimer() {
|
||||
stopTimer();
|
||||
if (!autoRefresh.value) return;
|
||||
const seconds = clampInterval(intervalSeconds.value);
|
||||
timer = setInterval(() => {
|
||||
// Skip polling while the window is hidden to avoid needless server load.
|
||||
if (document.hidden) return;
|
||||
void load({ silent: true });
|
||||
}, seconds * 1000);
|
||||
}
|
||||
|
||||
function onIntervalInput() {
|
||||
intervalSeconds.value = clampInterval(Number(intervalSeconds.value));
|
||||
if (autoRefresh.value) restartTimer();
|
||||
}
|
||||
|
||||
watch(autoRefresh, restartTimer);
|
||||
|
||||
watch(intervalSeconds, () => {
|
||||
if (autoRefresh.value) restartTimer();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.connection.id,
|
||||
() => {
|
||||
rows.value = [];
|
||||
ownSessionId.value = null;
|
||||
search.value = "";
|
||||
void load();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => void load());
|
||||
onBeforeUnmount(stopTimer);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col bg-background">
|
||||
<div class="flex h-11 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
|
||||
<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>
|
||||
<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: filteredRows.length }) }}</Badge>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<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" class="h-full w-40 min-w-0 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('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" />
|
||||
{{ t("processList.autoRefresh") }}
|
||||
</label>
|
||||
<div class="flex h-7 items-center gap-1 rounded-md border bg-background px-1.5">
|
||||
<Input v-model.number="intervalSeconds" type="number" min="1" max="3600" class="h-6 w-14 border-0 px-1 text-xs shadow-none focus-visible:ring-0" @change="onIntervalInput" />
|
||||
<span class="pr-1 text-[11px] text-muted-foreground">{{ t("processList.seconds") }}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" :disabled="loading" @click="load()">
|
||||
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
|
||||
<RefreshCcw v-else class="h-3.5 w-3.5" />
|
||||
{{ t("grid.refresh") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadError" class="border-b bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ loadError }}</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<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)">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{{ t(column.labelKey) }}
|
||||
<ArrowUp v-if="sortKey === column.key && sortDir === 'asc'" class="h-3 w-3" />
|
||||
<ArrowDown v-else-if="sortKey === column.key && sortDir === 'desc'" class="h-3 w-3" />
|
||||
</span>
|
||||
</th>
|
||||
<th class="w-16 whitespace-nowrap border-b px-3 py-2 text-right font-medium">{{ t("processList.colActions") }}</th>
|
||||
</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) }">
|
||||
<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>
|
||||
<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"
|
||||
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)"
|
||||
:title="isOwnSession(row) ? t('processList.cannotKillSelf') : t('processList.kill')"
|
||||
@click="requestKill(row)"
|
||||
>
|
||||
<Ban class="h-3.5 w-3.5" />
|
||||
{{ t("processList.kill") }}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && filteredRows.length === 0">
|
||||
<td :colspan="COLUMNS.length + 1" class="px-3 py-10 text-center text-muted-foreground">
|
||||
{{ search ? t("grid.noSearchResults") : t("processList.empty") }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
:open="killTarget !== null"
|
||||
@update:open="
|
||||
(open) => {
|
||||
if (!open) killTarget = null;
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogContent class="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<AlertTriangle class="h-4 w-4 text-destructive" />
|
||||
{{ t("processList.killTitle") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p v-if="killTarget" class="text-sm text-muted-foreground">
|
||||
{{ t("processList.killConfirm", { id: killTarget.id, user: killTarget.user }) }}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="killTarget = null">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button variant="destructive" :disabled="killing" @click="confirmKill">
|
||||
<Loader2 v-if="killing" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("processList.kill") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
import { computed, ref, watch, nextTick, onUnmounted } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock } from "@lucide/vue";
|
||||
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity } from "@lucide/vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -450,6 +450,7 @@ function tabMenuIcon(tab: QueryTab) {
|
|||
if (tab.mode === "objects") return TableProperties;
|
||||
if (tab.mode === "structure") return PencilRuler;
|
||||
if (tab.mode === "dameng-jobs") return CalendarClock;
|
||||
if (tab.mode === "processlist") return Activity;
|
||||
return Code2;
|
||||
}
|
||||
|
||||
|
|
@ -584,6 +585,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
|||
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
|
||||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
|
||||
<Activity v-else-if="tab.mode === 'processlist'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<input
|
||||
|
|
@ -766,6 +768,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
|||
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
|
||||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
|
||||
<Activity v-else-if="tab.mode === 'processlist'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -54,6 +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 DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/DamengJobAdmin.vue"));
|
||||
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
|
||||
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
|
||||
|
|
@ -1578,6 +1579,10 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
<DatabaseUserAdmin :key="activeTab.id" :connection="activeConnection" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'processlist' && activeConnection">
|
||||
<MySqlProcessList :key="activeTab.id" :connection="activeConnection" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'dameng-jobs' && activeConnection">
|
||||
<DamengJobAdmin :key="activeTab.id" :connection="activeConnection" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
Clipboard,
|
||||
Check,
|
||||
UsersRound,
|
||||
Activity,
|
||||
CalendarClock,
|
||||
Lock,
|
||||
HardDriveDownload,
|
||||
|
|
@ -144,6 +145,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 { canCloseSidebarDatabaseConnection, isSidebarDatabaseOpened } from "@/lib/sidebar/sidebarDatabaseOpenState";
|
||||
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
|
||||
import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty";
|
||||
|
|
@ -1152,6 +1154,18 @@ async function openUserAdmin() {
|
|||
}
|
||||
}
|
||||
|
||||
async function openProcessList() {
|
||||
const node = props.node;
|
||||
if (!node.connectionId) return;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
connectionStore.activeConnectionId = node.connectionId;
|
||||
queryStore.openProcessList(node.connectionId);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDamengJobAdmin() {
|
||||
const node = props.node;
|
||||
if (!node.connectionId) return;
|
||||
|
|
@ -4695,6 +4709,9 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
if (supportsDatabaseUserAdmin(currentDatabaseType())) {
|
||||
items.push({ label: t("contextMenu.userAdmin"), action: openUserAdmin, icon: UsersRound });
|
||||
}
|
||||
if (supportsProcessList(currentDatabaseType())) {
|
||||
items.push({ label: t("contextMenu.processList"), action: openProcessList, icon: Activity });
|
||||
}
|
||||
if (currentDatabaseType() === "dameng") {
|
||||
items.push({ label: t("contextMenu.damengJobAdmin"), action: openDamengJobAdmin, icon: CalendarClock });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1398,6 +1398,7 @@ export default {
|
|||
configureVisibleObjects: "Visible Object Filter",
|
||||
userAdmin: "Users & Privileges",
|
||||
openUserAdmin: "Open Users & Privileges",
|
||||
processList: "Process List",
|
||||
damengJobAdmin: "Dameng Agent Jobs",
|
||||
openDamengJobAdmin: "Open Dameng Agent Jobs",
|
||||
duplicateConnection: "Duplicate Connection",
|
||||
|
|
@ -1694,6 +1695,30 @@ export default {
|
|||
noAvailable: "All available extensions are already installed.",
|
||||
noInstalled: "No extensions installed.",
|
||||
},
|
||||
processList: {
|
||||
title: "Process List",
|
||||
sessionCount: "{count} sessions",
|
||||
filter: "Filter sessions",
|
||||
autoRefresh: "Auto-refresh",
|
||||
seconds: "s",
|
||||
colId: "Id",
|
||||
colUser: "User",
|
||||
colHost: "Host",
|
||||
colDb: "DB",
|
||||
colCommand: "Command",
|
||||
colTime: "Time",
|
||||
colState: "State",
|
||||
colInfo: "Info",
|
||||
colActions: "Actions",
|
||||
self: "you",
|
||||
kill: "Kill",
|
||||
cannotKillSelf: "You cannot kill your own session",
|
||||
empty: "No active sessions.",
|
||||
killTitle: "Kill session",
|
||||
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}",
|
||||
},
|
||||
userAdmin: {
|
||||
title: "Users & Privileges",
|
||||
unsupported: "MySQL-compatible and PostgreSQL-compatible connections are supported. SQL Server, Oracle, and other permission models can be added next.",
|
||||
|
|
|
|||
|
|
@ -1573,6 +1573,7 @@ export default withEnglishFallback({
|
|||
editSchemaCommentSuccess: 'Comentario del esquema "{name}" actualizado',
|
||||
manageExtension: "Administrar extensión...",
|
||||
dropExtension: "Eliminar extensión",
|
||||
processList: "Lista de procesos",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "Bases de datos visibles",
|
||||
|
|
@ -3837,4 +3838,28 @@ export default withEnglishFallback({
|
|||
sourceAdmin: "Administración de bases de datos",
|
||||
aiReviewRequired: "El SQL de producción se ha colocado en el editor. Revíselo primero y luego ejecútelo manualmente para confirmar.",
|
||||
},
|
||||
processList: {
|
||||
title: "Lista de procesos",
|
||||
sessionCount: "{count} sesiones",
|
||||
filter: "Filtrar sesiones",
|
||||
autoRefresh: "Actualización automática",
|
||||
seconds: "segundos",
|
||||
colId: "Id",
|
||||
colUser: "Usuario",
|
||||
colHost: "Host",
|
||||
colDb: "Base de datos",
|
||||
colCommand: "Comando",
|
||||
colTime: "Tiempo",
|
||||
colState: "Estado",
|
||||
colInfo: "SQL",
|
||||
colActions: "Acciones",
|
||||
self: "Actual",
|
||||
kill: "Terminar",
|
||||
cannotKillSelf: "No se puede terminar la sesión actual",
|
||||
empty: "No hay sesiones activas.",
|
||||
killTitle: "Terminar sesión",
|
||||
killConfirm: "¿Está seguro de que desea terminar la sesión {id} ({user})? La sentencia actual se cancelará y la conexión se cerrará.",
|
||||
killSuccess: "Sesión {id} terminada",
|
||||
killFailed: "Error al terminar la sesión: {message}",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1571,6 +1571,7 @@ export default withEnglishFallback({
|
|||
editSchemaCommentSuccess: 'Commento dello schema "{name}" aggiornato',
|
||||
manageExtension: "Gestisci estensione...",
|
||||
dropExtension: "Elimina estensione",
|
||||
processList: "Elenco processi",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "Database Visibili",
|
||||
|
|
@ -3835,4 +3836,28 @@ export default withEnglishFallback({
|
|||
sourceAdmin: "Amministrazione database",
|
||||
aiReviewRequired: "La SQL di produzione è stata inserita nell'editor. Controllare prima, quindi eseguire manualmente per confermare.",
|
||||
},
|
||||
processList: {
|
||||
title: "Elenco processi",
|
||||
sessionCount: "{count} sessioni",
|
||||
filter: "Filtra sessioni",
|
||||
autoRefresh: "Aggiornamento automatico",
|
||||
seconds: "secondi",
|
||||
colId: "Id",
|
||||
colUser: "Utente",
|
||||
colHost: "Host",
|
||||
colDb: "Database",
|
||||
colCommand: "Comando",
|
||||
colTime: "Durata",
|
||||
colState: "Stato",
|
||||
colInfo: "SQL",
|
||||
colActions: "Azioni",
|
||||
self: "Corrente",
|
||||
kill: "Termina",
|
||||
cannotKillSelf: "Impossibile terminare la sessione corrente",
|
||||
empty: "Nessuna sessione attiva.",
|
||||
killTitle: "Termina sessione",
|
||||
killConfirm: "Sei sicuro di voler terminare la sessione {id} ({user})? La dichiarazione corrente verrà interrotta e la connessione verrà chiusa.",
|
||||
killSuccess: "Sessione {id} terminata",
|
||||
killFailed: "Terminazione sessione non riuscita: {message}",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1572,6 +1572,7 @@ export default withEnglishFallback({
|
|||
ddlCopied: "DDLをコピーしました",
|
||||
manageExtension: "拡張機能を管理...",
|
||||
dropExtension: "拡張機能を削除",
|
||||
processList: "プロセス一覧",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "表示するデータベース",
|
||||
|
|
@ -3836,4 +3837,28 @@ export default withEnglishFallback({
|
|||
sourceAdmin: "データベース管理",
|
||||
aiReviewRequired: "本番SQLがエディターに配置されました。まず確認し、手動で実行して確定してください。",
|
||||
},
|
||||
processList: {
|
||||
title: "プロセス一覧",
|
||||
sessionCount: "{count} セッション",
|
||||
filter: "セッションをフィルター",
|
||||
autoRefresh: "自動更新",
|
||||
seconds: "秒",
|
||||
colId: "ID",
|
||||
colUser: "ユーザー",
|
||||
colHost: "ホスト",
|
||||
colDb: "データベース",
|
||||
colCommand: "コマンド",
|
||||
colTime: "経過時間",
|
||||
colState: "状態",
|
||||
colInfo: "SQL",
|
||||
colActions: "操作",
|
||||
self: "現在",
|
||||
kill: "強制終了",
|
||||
cannotKillSelf: "現在のセッションは強制終了できません",
|
||||
empty: "アクティブなセッションはありません。",
|
||||
killTitle: "セッションの強制終了",
|
||||
killConfirm: "セッション {id}({user})を強制終了しますか?現在のステートメントは中断され、接続が閉じられます。",
|
||||
killSuccess: "セッション {id} を強制終了しました。",
|
||||
killFailed: "セッションの強制終了に失敗しました:{message}",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1573,6 +1573,7 @@ export default withEnglishFallback({
|
|||
editSchemaCommentSuccess: 'Comentário do schema "{name}" atualizado',
|
||||
manageExtension: "Gerenciar extensão...",
|
||||
dropExtension: "Remover extensão",
|
||||
processList: "Lista de Processos",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "Bancos de dados visíveis",
|
||||
|
|
@ -3837,4 +3838,28 @@ export default withEnglishFallback({
|
|||
sourceAdmin: "Administração de banco de dados",
|
||||
aiReviewRequired: "O SQL de produção foi colocado no editor. Verifique-o primeiro e execute manualmente para confirmar.",
|
||||
},
|
||||
processList: {
|
||||
title: "Lista de Processos",
|
||||
sessionCount: "{count} sessões",
|
||||
filter: "Filtrar sessões",
|
||||
autoRefresh: "Atualização automática",
|
||||
seconds: "segundos",
|
||||
colId: "Id",
|
||||
colUser: "Usuário",
|
||||
colHost: "Host",
|
||||
colDb: "Banco de dados",
|
||||
colCommand: "Comando",
|
||||
colTime: "Duração",
|
||||
colState: "Estado",
|
||||
colInfo: "SQL",
|
||||
colActions: "Ações",
|
||||
self: "Atual",
|
||||
kill: "Encerrar",
|
||||
cannotKillSelf: "Não é possível encerrar a sessão atual",
|
||||
empty: "Nenhuma sessão ativa.",
|
||||
killTitle: "Encerrar sessão",
|
||||
killConfirm: "Tem certeza de que deseja encerrar a sessão {id} ({user})? Sua instrução atual será abortada e a conexão será fechada.",
|
||||
killSuccess: "Sessão {id} encerrada.",
|
||||
killFailed: "Falha ao encerrar a sessão: {message}",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1401,6 +1401,7 @@ export default withEnglishFallback({
|
|||
duplicateSelectedConnections: "复制选中的 {count} 个连接",
|
||||
userAdmin: "用户与权限",
|
||||
openUserAdmin: "打开用户与权限",
|
||||
processList: "进程列表",
|
||||
damengJobAdmin: "达梦代理作业",
|
||||
openDamengJobAdmin: "打开达梦代理作业",
|
||||
newQuery: "新建查询",
|
||||
|
|
@ -1693,6 +1694,30 @@ export default withEnglishFallback({
|
|||
noAvailable: "所有可用扩展均已安装。",
|
||||
noInstalled: "暂无已安装的扩展。",
|
||||
},
|
||||
processList: {
|
||||
title: "进程列表",
|
||||
sessionCount: "{count} 个会话",
|
||||
filter: "筛选会话",
|
||||
autoRefresh: "自动刷新",
|
||||
seconds: "秒",
|
||||
colId: "Id",
|
||||
colUser: "用户",
|
||||
colHost: "主机",
|
||||
colDb: "数据库",
|
||||
colCommand: "命令",
|
||||
colTime: "时长",
|
||||
colState: "状态",
|
||||
colInfo: "SQL",
|
||||
colActions: "操作",
|
||||
self: "当前",
|
||||
kill: "终止",
|
||||
cannotKillSelf: "无法终止当前会话",
|
||||
empty: "没有活动会话。",
|
||||
killTitle: "终止会话",
|
||||
killConfirm: "确定终止会话 {id}({user})?其当前语句将被中止,连接将被关闭。",
|
||||
killSuccess: "已终止会话 {id}",
|
||||
killFailed: "终止会话失败:{message}",
|
||||
},
|
||||
userAdmin: {
|
||||
title: "用户与权限",
|
||||
unsupported: "当前支持 MySQL 兼容与 PostgreSQL 兼容连接。SQL Server、Oracle 等会按各自权限模型继续扩展。",
|
||||
|
|
|
|||
|
|
@ -1572,6 +1572,7 @@ export default withEnglishFallback({
|
|||
editSchemaCommentSuccess: "Schema「{name}」註解已更新",
|
||||
manageExtension: "管理擴展...",
|
||||
dropExtension: "刪除擴展",
|
||||
processList: "處理程序清單",
|
||||
},
|
||||
visibleDatabases: {
|
||||
title: "顯示資料庫",
|
||||
|
|
@ -3836,4 +3837,28 @@ export default withEnglishFallback({
|
|||
sourceAdmin: "資料庫管理",
|
||||
aiReviewRequired: "生產 SQL 已放入編輯器。請先檢查,再手動執行以確認。",
|
||||
},
|
||||
processList: {
|
||||
title: "處理程序清單",
|
||||
sessionCount: "{count} 個會話",
|
||||
filter: "篩選會話",
|
||||
autoRefresh: "自動重新整理",
|
||||
seconds: "秒",
|
||||
colId: "ID",
|
||||
colUser: "使用者",
|
||||
colHost: "主機",
|
||||
colDb: "資料庫",
|
||||
colCommand: "指令",
|
||||
colTime: "持續時間",
|
||||
colState: "狀態",
|
||||
colInfo: "SQL",
|
||||
colActions: "操作",
|
||||
self: "目前",
|
||||
kill: "終止",
|
||||
cannotKillSelf: "無法終止目前會話",
|
||||
empty: "沒有活動會話。",
|
||||
killTitle: "終止會話",
|
||||
killConfirm: "確定終止會話 {id}({user})?其目前的陳述式將被中止,連線將被關閉。",
|
||||
killSuccess: "已終止會話 {id}",
|
||||
killFailed: "終止會話失敗:{message}",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import { buildKillSql, clampInterval, mapProcessRows, supportsProcessList } from "@/lib/database/mysqlProcessList";
|
||||
|
||||
function result(columns: string[], rows: (string | number | boolean | null)[][]): QueryResult {
|
||||
return { columns, rows, affected_rows: 0, execution_time_ms: 0 };
|
||||
}
|
||||
|
||||
describe("mapProcessRows", () => {
|
||||
it("maps a SHOW FULL PROCESSLIST result into typed rows", () => {
|
||||
const rows = mapProcessRows(result(["Id", "User", "Host", "db", "Command", "Time", "State", "Info"], [[8213, "app", "10.0.0.4:5123", "shop", "Query", 12, "Sending data", "SELECT * FROM orders"]]));
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
id: 8213,
|
||||
user: "app",
|
||||
host: "10.0.0.4:5123",
|
||||
db: "shop",
|
||||
command: "Query",
|
||||
time: 12,
|
||||
state: "Sending data",
|
||||
info: "SELECT * FROM orders",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("tolerates NULL db/state/info and case-variant column names", () => {
|
||||
const rows = mapProcessRows(result(["ID", "USER", "HOST", "DB", "COMMAND", "TIME", "STATE", "INFO"], [["8199", "root", "localhost", null, "Sleep", "340", null, null]]));
|
||||
expect(rows[0]).toMatchObject({ id: 8199, user: "root", db: null, state: null, info: null, time: 340 });
|
||||
});
|
||||
|
||||
it("returns an empty array for empty or malformed input", () => {
|
||||
expect(mapProcessRows(null)).toEqual([]);
|
||||
expect(mapProcessRows(undefined)).toEqual([]);
|
||||
expect(mapProcessRows(result([], []))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildKillSql", () => {
|
||||
it("builds KILL CONNECTION for a valid id", () => {
|
||||
expect(buildKillSql(8213)).toBe("KILL CONNECTION 8213");
|
||||
});
|
||||
|
||||
it("rejects non-integer or negative ids", () => {
|
||||
expect(() => buildKillSql(1.5)).toThrow();
|
||||
expect(() => buildKillSql(-1)).toThrow();
|
||||
expect(() => buildKillSql(Number.NaN)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampInterval", () => {
|
||||
it("clamps below the minimum to 1 second", () => {
|
||||
expect(clampInterval(0)).toBe(1);
|
||||
expect(clampInterval(-5)).toBe(1);
|
||||
});
|
||||
|
||||
it("caps at the maximum", () => {
|
||||
expect(clampInterval(999999)).toBe(3600);
|
||||
});
|
||||
|
||||
it("floors fractional seconds and falls back for non-finite input", () => {
|
||||
expect(clampInterval(4.9)).toBe(4);
|
||||
expect(clampInterval(Number.NaN)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsProcessList", () => {
|
||||
it("is limited to connections using the MySQL driver type", () => {
|
||||
expect(supportsProcessList("mysql")).toBe(true);
|
||||
expect(supportsProcessList("doris")).toBe(false);
|
||||
expect(supportsProcessList("starrocks")).toBe(false);
|
||||
expect(supportsProcessList("goldendb")).toBe(false);
|
||||
expect(supportsProcessList("postgres")).toBe(false);
|
||||
expect(supportsProcessList("sqlite")).toBe(false);
|
||||
expect(supportsProcessList(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
|
||||
/**
|
||||
* Engines that speak the MySQL protocol and support `SHOW FULL PROCESSLIST` /
|
||||
* `KILL CONNECTION`. MariaDB, TiDB, and OceanBase ride the `mysql` dbType via a
|
||||
* driver profile, so they are covered by the `"mysql"` entry.
|
||||
*/
|
||||
const PROCESS_LIST_DB_TYPES = new Set<DatabaseType>(["mysql"]);
|
||||
|
||||
/**
|
||||
* MySQL "current connections / process list" helpers. Pure and framework-free so
|
||||
* they can be unit-tested in isolation; the panel component wires them to the
|
||||
* generic SQL bridge and the production-safety guard.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `SHOW FULL PROCESSLIST` is available on every MySQL-family server without extra
|
||||
* privileges (it only reveals the caller's own sessions when PROCESS is missing),
|
||||
* and the backend already forces the correct text protocol for it. `FULL` keeps
|
||||
* the `Info` column from being truncated at 100 chars.
|
||||
*/
|
||||
export const PROCESS_LIST_SQL = "SHOW FULL PROCESSLIST";
|
||||
|
||||
/** Bounds for the auto-refresh interval, in seconds. */
|
||||
export const MIN_REFRESH_SECONDS = 1;
|
||||
export const MAX_REFRESH_SECONDS = 3600;
|
||||
export const DEFAULT_REFRESH_SECONDS = 5;
|
||||
|
||||
export interface ProcessRow {
|
||||
id: number;
|
||||
user: string;
|
||||
host: string;
|
||||
db: string | null;
|
||||
command: string;
|
||||
time: number;
|
||||
state: string | null;
|
||||
info: 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 generic `SHOW FULL PROCESSLIST` result into typed rows. Column names are
|
||||
* matched case-insensitively because forks differ (e.g. `Id` vs `ID`), and any
|
||||
* missing column degrades to a sensible empty value rather than throwing.
|
||||
*/
|
||||
export function mapProcessRows(result: QueryResult | null | undefined): ProcessRow[] {
|
||||
if (!result || !Array.isArray(result.columns) || !Array.isArray(result.rows)) return [];
|
||||
const columns = result.columns;
|
||||
const idIdx = columnIndex(columns, "Id");
|
||||
const userIdx = columnIndex(columns, "User");
|
||||
const hostIdx = columnIndex(columns, "Host");
|
||||
const dbIdx = columnIndex(columns, "db");
|
||||
const commandIdx = columnIndex(columns, "Command");
|
||||
const timeIdx = columnIndex(columns, "Time");
|
||||
const stateIdx = columnIndex(columns, "State");
|
||||
const infoIdx = columnIndex(columns, "Info");
|
||||
|
||||
const cell = (row: (string | number | boolean | null)[], idx: number) => (idx >= 0 ? row[idx] : null);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: asNumber(cell(row, idIdx)),
|
||||
user: asString(cell(row, userIdx)),
|
||||
host: asString(cell(row, hostIdx)),
|
||||
db: asNullableString(cell(row, dbIdx)),
|
||||
command: asString(cell(row, commandIdx)),
|
||||
time: asNumber(cell(row, timeIdx)),
|
||||
state: asNullableString(cell(row, stateIdx)),
|
||||
info: asNullableString(cell(row, infoIdx)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `KILL CONNECTION <id>` statement. `id` must be a finite integer; it
|
||||
* is validated (never interpolated as free text) so there is no injection path.
|
||||
*/
|
||||
export function buildKillSql(id: number): string {
|
||||
if (!Number.isInteger(id) || id < 0) {
|
||||
throw new Error(`Invalid session id: ${id}`);
|
||||
}
|
||||
return `KILL CONNECTION ${id}`;
|
||||
}
|
||||
|
||||
/** Clamp a user-entered refresh interval to a safe integer range of seconds. */
|
||||
export function clampInterval(seconds: number): number {
|
||||
if (!Number.isFinite(seconds)) return DEFAULT_REFRESH_SECONDS;
|
||||
const floored = Math.floor(seconds);
|
||||
if (floored < MIN_REFRESH_SECONDS) return MIN_REFRESH_SECONDS;
|
||||
if (floored > MAX_REFRESH_SECONDS) return MAX_REFRESH_SECONDS;
|
||||
return floored;
|
||||
}
|
||||
|
||||
/** Whether the given database type exposes a process-list viewer (MySQL family). */
|
||||
export function supportsProcessList(dbType: DatabaseType | undefined): boolean {
|
||||
return !!dbType && PROCESS_LIST_DB_TYPES.has(dbType);
|
||||
}
|
||||
|
|
@ -996,6 +996,31 @@ export const useQueryStore = defineStore("query", () => {
|
|||
return id;
|
||||
}
|
||||
|
||||
function openProcessList(connectionId: string) {
|
||||
const existing = tabs.value.find((tab) => tab.mode === "processlist" && tab.connectionId === connectionId);
|
||||
if (existing) {
|
||||
switchTab(existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const conn = useConnectionStore().getConfig(connectionId);
|
||||
const id = uuid();
|
||||
const tab: QueryTab = {
|
||||
id,
|
||||
title: t("processList.title"),
|
||||
connectionId,
|
||||
database: conn?.database || "",
|
||||
sql: "",
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
mode: "processlist",
|
||||
};
|
||||
tabs.value.push(tab);
|
||||
activeTabId.value = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
function openDamengJobAdmin(connectionId: string) {
|
||||
const existing = tabs.value.find((tab) => tab.mode === "dameng-jobs" && tab.connectionId === connectionId);
|
||||
if (existing) {
|
||||
|
|
@ -3812,6 +3837,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
openMongoGridFs,
|
||||
openMongoBucket,
|
||||
openUserAdmin,
|
||||
openProcessList,
|
||||
openDamengJobAdmin,
|
||||
openMqAdmin,
|
||||
openNacosAdmin,
|
||||
|
|
|
|||
|
|
@ -775,7 +775,7 @@ export interface QueryTab {
|
|||
explainExecutionId?: string;
|
||||
/** Per-run connection session for sequential MySQL explain formats. */
|
||||
explainClientSessionId?: string;
|
||||
mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users" | "dameng-jobs";
|
||||
mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist";
|
||||
mqTenant?: string;
|
||||
mqInitialTab?: "topics";
|
||||
nacosNamespace?: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue