From 2dec6ebeb8c5922e4a6dfd5b337926a9f2ccc875 Mon Sep 17 00:00:00 2001 From: Bagus Wahyu Aprianto Date: Mon, 13 Jul 2026 18:07:05 +0700 Subject: [PATCH] feat(mysql): add process list viewer --- .../src/components/admin/MySqlProcessList.vue | 288 ++++++++++++++++++ .../src/components/layout/AppTabBar.vue | 5 +- .../src/components/layout/ContentArea.vue | 5 + .../src/components/sidebar/TreeItem.vue | 17 ++ apps/desktop/src/i18n/locales/en.ts | 25 ++ apps/desktop/src/i18n/locales/es.ts | 25 ++ apps/desktop/src/i18n/locales/it.ts | 25 ++ apps/desktop/src/i18n/locales/ja.ts | 25 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 25 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 25 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 25 ++ .../database/mysqlProcessList.spec.ts | 76 +++++ .../src/lib/database/mysqlProcessList.ts | 116 +++++++ apps/desktop/src/stores/queryStore.ts | 26 ++ apps/desktop/src/types/database.ts | 2 +- 15 files changed, 708 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/components/admin/MySqlProcessList.vue create mode 100644 apps/desktop/src/lib/__tests__/database/mysqlProcessList.spec.ts create mode 100644 apps/desktop/src/lib/database/mysqlProcessList.ts diff --git a/apps/desktop/src/components/admin/MySqlProcessList.vue b/apps/desktop/src/components/admin/MySqlProcessList.vue new file mode 100644 index 000000000..4cb6f3578 --- /dev/null +++ b/apps/desktop/src/components/admin/MySqlProcessList.vue @@ -0,0 +1,288 @@ + + + diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index 3f3002890..c6b6a4984 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -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 + + 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 + + diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 62d8cecac..5e6b5c9f4 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -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 }); } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 168cb9e40..98645be84 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 596a2d4fb..a8a5c8094 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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}", + }, }); diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9ebebdb1e..636157df1 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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}", + }, }); diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 0e8f9c54e..76c2ae716 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -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}", + }, }); diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 9b233a52d..28bd12d18 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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}", + }, }); diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 0d6bc765a..d8d110603 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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 等会按各自权限模型继续扩展。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 893871db5..a5ce719aa 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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}", + }, }); diff --git a/apps/desktop/src/lib/__tests__/database/mysqlProcessList.spec.ts b/apps/desktop/src/lib/__tests__/database/mysqlProcessList.spec.ts new file mode 100644 index 000000000..4d556be5f --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/mysqlProcessList.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/database/mysqlProcessList.ts b/apps/desktop/src/lib/database/mysqlProcessList.ts new file mode 100644 index 000000000..81b59f19e --- /dev/null +++ b/apps/desktop/src/lib/database/mysqlProcessList.ts @@ -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(["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 ` 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); +} diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index b6b7dcbe6..f9b876997 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 6b59e0d8f..4c2b04d0d 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -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;