From c9c2fddfe22c4d342bcb124feb6b139ddd655fbc Mon Sep 17 00:00:00 2001 From: Bagus Wahyu Aprianto Date: Mon, 13 Jul 2026 22:55:52 +0700 Subject: [PATCH] feat(mysql): add server metrics dashboard --- .../src/components/admin/MySqlDashboard.vue | 240 ++++++++++++++++++ .../src/components/chart/MetricLineChart.vue | 74 ++++++ .../src/components/common/MetricCard.vue | 21 ++ .../src/components/layout/AppTabBar.vue | 5 +- .../src/components/layout/ContentArea.vue | 7 + .../src/components/sidebar/TreeItem.vue | 17 ++ apps/desktop/src/i18n/locales/en.ts | 23 ++ apps/desktop/src/i18n/locales/es.ts | 23 ++ apps/desktop/src/i18n/locales/it.ts | 23 ++ apps/desktop/src/i18n/locales/ja.ts | 23 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 23 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 23 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 23 ++ .../database/mysqlServerStatus.spec.ts | 110 ++++++++ .../src/lib/database/mysqlServerStatus.ts | 151 +++++++++++ apps/desktop/src/stores/queryStore.ts | 26 ++ apps/desktop/src/types/database.ts | 2 +- 17 files changed, 812 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/components/admin/MySqlDashboard.vue create mode 100644 apps/desktop/src/components/chart/MetricLineChart.vue create mode 100644 apps/desktop/src/components/common/MetricCard.vue create mode 100644 apps/desktop/src/lib/__tests__/database/mysqlServerStatus.spec.ts create mode 100644 apps/desktop/src/lib/database/mysqlServerStatus.ts diff --git a/apps/desktop/src/components/admin/MySqlDashboard.vue b/apps/desktop/src/components/admin/MySqlDashboard.vue new file mode 100644 index 000000000..2e9008460 --- /dev/null +++ b/apps/desktop/src/components/admin/MySqlDashboard.vue @@ -0,0 +1,240 @@ + + + diff --git a/apps/desktop/src/components/chart/MetricLineChart.vue b/apps/desktop/src/components/chart/MetricLineChart.vue new file mode 100644 index 000000000..cd6d78599 --- /dev/null +++ b/apps/desktop/src/components/chart/MetricLineChart.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/desktop/src/components/common/MetricCard.vue b/apps/desktop/src/components/common/MetricCard.vue new file mode 100644 index 000000000..3990d4592 --- /dev/null +++ b/apps/desktop/src/components/common/MetricCard.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index c6b6a4984..a68e5ccef 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, Activity } from "@lucide/vue"; +import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge } 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"; @@ -451,6 +451,7 @@ function tabMenuIcon(tab: QueryTab) { if (tab.mode === "structure") return PencilRuler; if (tab.mode === "dameng-jobs") return CalendarClock; if (tab.mode === "processlist") return Activity; + if (tab.mode === "mysql-dashboard") return Gauge; return Code2; } @@ -586,6 +587,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul + + import("@/components/objects/Ob 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 MySqlDashboard = defineAsyncComponent(() => import("@/components/admin/MySqlDashboard.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")); @@ -1589,6 +1590,12 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe + + diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 73e864938..063330bc3 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -51,6 +51,7 @@ import { Check, UsersRound, Activity, + Gauge, CalendarClock, Lock, HardDriveDownload, @@ -146,6 +147,7 @@ import { selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes, treeSelectio import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selectedConnectionDeleteTargets, selectedConnectionDuplicateTargets, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection"; import { supportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin"; import { supportsProcessList } from "@/lib/database/mysqlProcessList"; +import { connectionSupportsServerDashboard } from "@/lib/database/mysqlServerStatus"; import { canCloseSidebarDatabaseConnection, isSidebarDatabaseOpened } from "@/lib/sidebar/sidebarDatabaseOpenState"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty"; @@ -1167,6 +1169,18 @@ async function openProcessList() { } } +async function openMysqlDashboard() { + const node = props.node; + if (!node.connectionId) return; + try { + await connectionStore.ensureConnected(node.connectionId); + connectionStore.activeConnectionId = node.connectionId; + queryStore.openMysqlDashboard(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; @@ -4714,6 +4728,9 @@ function treeItemMenuItems(): ContextMenuItem[] { if (supportsProcessList(currentDatabaseType())) { items.push({ label: t("contextMenu.processList"), action: openProcessList, icon: Activity }); } + if (node.connectionId && connectionSupportsServerDashboard(connectionStore.getConfig(node.connectionId))) { + items.push({ label: t("contextMenu.serverDashboard"), action: openMysqlDashboard, icon: Gauge }); + } 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 4bbf62c31..f88561934 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1403,6 +1403,7 @@ export default { userAdmin: "Users & Privileges", openUserAdmin: "Open Users & Privileges", processList: "Process List", + serverDashboard: "Server Dashboard", damengJobAdmin: "Dameng Agent Jobs", openDamengJobAdmin: "Open Dameng Agent Jobs", duplicateConnection: "Duplicate Connection", @@ -1723,6 +1724,28 @@ export default { killSuccess: "Session {id} killed", killFailed: "Failed to kill session: {message}", }, + serverDashboard: { + title: "Server Dashboard", + autoRefresh: "Auto-refresh", + off: "Off", + qps: "Queries / sec", + connections: "Connections", + running: "Threads running", + trafficIn: "Traffic in", + trafficOut: "Traffic out", + slowQueries: "Slow queries", + uptime: "Uptime", + innodbHit: "InnoDB hit rate", + in: "In", + out: "Out", + sessions: "Sessions", + qpsChart: "Queries per second", + sessionsChart: "Sessions per second", + trafficChart: "Network traffic /s", + commandChart: "Commands per second", + rawStatus: "Status variables", + filterStatus: "Filter status variables", + }, 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 d3169bf6d..d6db8f864 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1578,6 +1578,7 @@ export default withEnglishFallback({ manageExtension: "Administrar extensión...", dropExtension: "Eliminar extensión", processList: "Lista de procesos", + serverDashboard: "Panel de control del servidor", }, visibleDatabases: { title: "Bases de datos visibles", @@ -3866,4 +3867,26 @@ export default withEnglishFallback({ killSuccess: "Sesión {id} terminada", killFailed: "Error al terminar la sesión: {message}", }, + serverDashboard: { + title: "Panel de control del servidor", + autoRefresh: "Actualización automática", + off: "Desactivado", + qps: "Consultas por segundo", + connections: "Conexiones", + running: "Hilos en ejecución", + trafficIn: "Tráfico entrante", + trafficOut: "Tráfico saliente", + slowQueries: "Consultas lentas", + uptime: "Tiempo de actividad", + innodbHit: "Tasa de aciertos de InnoDB", + in: "Entrante", + out: "Saliente", + sessions: "Sesiones", + qpsChart: "Consultas por segundo", + sessionsChart: "Nuevas sesiones por segundo", + trafficChart: "Tráfico de red por segundo", + commandChart: "Comandos por segundo", + rawStatus: "Variables de estado", + filterStatus: "Filtrar variables de estado", + }, }); diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9d49090dd..8a22f3a3e 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1576,6 +1576,7 @@ export default withEnglishFallback({ manageExtension: "Gestisci estensione...", dropExtension: "Elimina estensione", processList: "Elenco processi", + serverDashboard: "Dashboard del server", }, visibleDatabases: { title: "Database Visibili", @@ -3864,4 +3865,26 @@ export default withEnglishFallback({ killSuccess: "Sessione {id} terminata", killFailed: "Terminazione sessione non riuscita: {message}", }, + serverDashboard: { + title: "Dashboard del server", + autoRefresh: "Aggiornamento automatico", + off: "Spento", + qps: "Query al secondo", + connections: "Connessioni", + running: "Thread in esecuzione", + trafficIn: "Traffico in ingresso", + trafficOut: "Traffico in uscita", + slowQueries: "Query lente", + uptime: "Uptime", + innodbHit: "Percentuale di hit InnoDB", + in: "In", + out: "Out", + sessions: "Sessioni", + qpsChart: "Query al secondo", + sessionsChart: "Nuove sessioni al secondo", + trafficChart: "Traffico di rete al secondo", + commandChart: "Comandi al secondo", + rawStatus: "Variabili di stato", + filterStatus: "Filtra variabili di stato", + }, }); diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index c799e754e..9b8b3667c 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1577,6 +1577,7 @@ export default withEnglishFallback({ manageExtension: "拡張機能を管理...", dropExtension: "拡張機能を削除", processList: "プロセス一覧", + serverDashboard: "サーバーダッシュボード", }, visibleDatabases: { title: "表示するデータベース", @@ -3865,4 +3866,26 @@ export default withEnglishFallback({ killSuccess: "セッション {id} を強制終了しました。", killFailed: "セッションの強制終了に失敗しました:{message}", }, + serverDashboard: { + title: "サーバーダッシュボード", + autoRefresh: "自動更新", + off: "オフ", + qps: "毎秒クエリ数", + connections: "接続数", + running: "実行スレッド数", + trafficIn: "受信トラフィック", + trafficOut: "送信トラフィック", + slowQueries: "スロークエリ", + uptime: "稼働時間", + innodbHit: "InnoDBヒット率", + in: "受信", + out: "送信", + sessions: "セッション", + qpsChart: "毎秒クエリ数", + sessionsChart: "毎秒新規セッション数", + trafficChart: "毎秒ネットワークトラフィック", + commandChart: "毎秒コマンド数", + rawStatus: "ステータス変数", + filterStatus: "ステータス変数の絞り込み", + }, }); diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 65d01f619..15e5ef259 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1578,6 +1578,7 @@ export default withEnglishFallback({ manageExtension: "Gerenciar extensão...", dropExtension: "Remover extensão", processList: "Lista de Processos", + serverDashboard: "Painel do Servidor", }, visibleDatabases: { title: "Bancos de dados visíveis", @@ -3866,4 +3867,26 @@ export default withEnglishFallback({ killSuccess: "Sessão {id} encerrada.", killFailed: "Falha ao encerrar a sessão: {message}", }, + serverDashboard: { + title: "Painel do Servidor", + autoRefresh: "Atualização automática", + off: "Desligado", + qps: "Consultas por segundo", + connections: "Conexões", + running: "Threads em execução", + trafficIn: "Tráfego de entrada", + trafficOut: "Tráfego de saída", + slowQueries: "Consultas lentas", + uptime: "Tempo de atividade", + innodbHit: "Taxa de acertos do InnoDB", + in: "Entrada", + out: "Saída", + sessions: "Sessões", + qpsChart: "Consultas por segundo", + sessionsChart: "Novas sessões por segundo", + trafficChart: "Tráfego de rede por segundo", + commandChart: "Comandos por segundo", + rawStatus: "Variáveis de estado", + filterStatus: "Filtrar variáveis de estado", + }, }); diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 312ce47df..a47f0e7e8 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1406,6 +1406,7 @@ export default withEnglishFallback({ userAdmin: "用户与权限", openUserAdmin: "打开用户与权限", processList: "进程列表", + serverDashboard: "服务器仪表盘", damengJobAdmin: "达梦代理作业", openDamengJobAdmin: "打开达梦代理作业", newQuery: "新建查询", @@ -1722,6 +1723,28 @@ export default withEnglishFallback({ killSuccess: "已终止会话 {id}", killFailed: "终止会话失败:{message}", }, + serverDashboard: { + title: "服务器仪表盘", + autoRefresh: "自动刷新", + off: "关闭", + qps: "每秒查询数", + connections: "连接数", + running: "运行线程", + trafficIn: "入流量", + trafficOut: "出流量", + slowQueries: "慢查询", + uptime: "运行时长", + innodbHit: "InnoDB 命中率", + in: "入", + out: "出", + sessions: "会话", + qpsChart: "每秒查询数", + sessionsChart: "每秒新建会话", + trafficChart: "每秒网络流量", + commandChart: "每秒命令数", + rawStatus: "状态变量", + filterStatus: "筛选状态变量", + }, 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 8d022f815..04978a08d 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1577,6 +1577,7 @@ export default withEnglishFallback({ manageExtension: "管理擴展...", dropExtension: "刪除擴展", processList: "處理程序清單", + serverDashboard: "伺服器儀表板", }, visibleDatabases: { title: "顯示資料庫", @@ -3865,4 +3866,26 @@ export default withEnglishFallback({ killSuccess: "已終止會話 {id}", killFailed: "終止會話失敗:{message}", }, + serverDashboard: { + title: "伺服器儀表板", + autoRefresh: "自動重新整理", + off: "關閉", + qps: "每秒查詢數", + connections: "連線數", + running: "執行中的執行緒", + trafficIn: "傳入流量", + trafficOut: "傳出流量", + slowQueries: "慢查詢", + uptime: "運行時間", + innodbHit: "InnoDB 命中率", + in: "入", + out: "出", + sessions: "工作階段", + qpsChart: "每秒查詢數", + sessionsChart: "每秒新工作階段", + trafficChart: "每秒網路流量", + commandChart: "每秒命令數", + rawStatus: "狀態變數", + filterStatus: "篩選狀態變數", + }, }); diff --git a/apps/desktop/src/lib/__tests__/database/mysqlServerStatus.spec.ts b/apps/desktop/src/lib/__tests__/database/mysqlServerStatus.spec.ts new file mode 100644 index 000000000..f35585cb9 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/mysqlServerStatus.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import type { QueryResult } from "@/types/database"; +import { computeQps, computeRate, connectionSupportsServerDashboard, formatBytes, formatBytesPerSec, formatUptime, innodbBufferHitRatio, parseStatusResult, statusNumber, supportsServerDashboard, type StatusSample } from "@/lib/database/mysqlServerStatus"; + +function statusResult(rows: [string, string][]): QueryResult { + return { columns: ["Variable_name", "Value"], rows, affected_rows: 0, execution_time_ms: 0 }; +} + +function sample(at: number, status: Record): StatusSample { + return { at, status }; +} + +describe("parseStatusResult", () => { + it("parses two-column status into a map", () => { + const map = parseStatusResult( + statusResult([ + ["Threads_connected", "12"], + ["Uptime", "3600"], + ]), + ); + expect(map).toEqual({ Threads_connected: "12", Uptime: "3600" }); + }); + + it("returns empty map for malformed input", () => { + expect(parseStatusResult(null)).toEqual({}); + expect(parseStatusResult({ columns: [], rows: [], affected_rows: 0, execution_time_ms: 0 })).toEqual({}); + }); +}); + +describe("statusNumber", () => { + it("reads numeric values and defaults to 0", () => { + expect(statusNumber({ Questions: "500" }, "Questions")).toBe(500); + expect(statusNumber({}, "Missing")).toBe(0); + expect(statusNumber({ X: "abc" }, "X")).toBe(0); + }); +}); + +describe("computeRate", () => { + it("computes per-second delta", () => { + const prev = sample(1000, { Bytes_sent: "1000" }); + const curr = sample(3000, { Bytes_sent: "5000" }); + expect(computeRate(prev, curr, "Bytes_sent")).toBe(2000); // 4000 bytes / 2s + }); + + it("returns 0 on counter reset (decrease)", () => { + const prev = sample(1000, { Queries: "9000" }); + const curr = sample(2000, { Queries: "10" }); + expect(computeRate(prev, curr, "Queries")).toBe(0); + }); + + it("returns 0 for non-positive time delta", () => { + const prev = sample(2000, { Queries: "10" }); + const curr = sample(2000, { Queries: "20" }); + expect(computeRate(prev, curr, "Queries")).toBe(0); + }); +}); + +describe("computeQps", () => { + it("prefers Queries and falls back to Questions", () => { + const withQueries = computeQps(sample(0, { Queries: "0", Questions: "0" }), sample(1000, { Queries: "100", Questions: "40" })); + expect(withQueries).toBe(100); + const withoutQueries = computeQps(sample(0, { Questions: "0" }), sample(1000, { Questions: "40" })); + expect(withoutQueries).toBe(40); + }); +}); + +describe("innodbBufferHitRatio", () => { + it("computes hit ratio as a percentage", () => { + expect(innodbBufferHitRatio({ Innodb_buffer_pool_read_requests: "1000", Innodb_buffer_pool_reads: "3" })).toBeCloseTo(99.7, 1); + }); + + it("returns null when counters are absent", () => { + expect(innodbBufferHitRatio({})).toBeNull(); + }); +}); + +describe("formatters", () => { + it("formats bytes and bytes/sec", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(1024)).toBe("1.0 KB"); + expect(formatBytes(1024 * 1024 * 2)).toBe("2.0 MB"); + expect(formatBytesPerSec(1024)).toBe("1.0 KB/s"); + }); + + it("formats uptime compactly", () => { + expect(formatUptime(0)).toBe("0s"); + expect(formatUptime(45)).toBe("45s"); + expect(formatUptime(3661)).toBe("1h 1m"); + expect(formatUptime(90061)).toBe("1d 1h 1m"); + }); +}); + +describe("supportsServerDashboard", () => { + it("is true for MySQL (incl. MariaDB/TiDB via mysql dbType) only", () => { + expect(supportsServerDashboard("mysql")).toBe(true); + // OLAP forks lack MySQL status counters — dashboard would be empty. + expect(supportsServerDashboard("doris")).toBe(false); + expect(supportsServerDashboard("starrocks")).toBe(false); + expect(supportsServerDashboard("goldendb")).toBe(false); + expect(supportsServerDashboard("postgres")).toBe(false); + expect(supportsServerDashboard(undefined)).toBe(false); + }); + + it("rejects JDBC profiles that only use the MySQL-compatible dialect", () => { + expect(connectionSupportsServerDashboard({ id: "mysql", name: "MySQL", db_type: "mysql" } as any)).toBe(true); + expect(connectionSupportsServerDashboard({ id: "jdbc-mysql", name: "JDBC MySQL", db_type: "jdbc", connection_string: "jdbc:mysql://localhost/db" } as any)).toBe(true); + expect(connectionSupportsServerDashboard({ id: "kyuubi", name: "Kyuubi", db_type: "jdbc", driver_profile: "kyuubi", connection_string: "jdbc:hive2://localhost/default" } as any)).toBe(false); + expect(connectionSupportsServerDashboard({ id: "hive", name: "Hive", db_type: "jdbc", jdbc_driver_class: "org.apache.hive.jdbc.HiveDriver" } as any)).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/database/mysqlServerStatus.ts b/apps/desktop/src/lib/database/mysqlServerStatus.ts new file mode 100644 index 000000000..6db97498b --- /dev/null +++ b/apps/desktop/src/lib/database/mysqlServerStatus.ts @@ -0,0 +1,151 @@ +import type { ConnectionConfig, DatabaseType, QueryResult } from "@/types/database"; +import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; + +/** + * MySQL server-monitoring helpers. Pure and framework-free so the rate math and + * formatting can be unit-tested; the dashboard component owns the polling loop + * and ring buffer, and feeds samples through these functions. + * + * Data comes from `SHOW GLOBAL STATUS` (cumulative counters, two columns + * Variable_name/Value) and a one-shot `SHOW GLOBAL VARIABLES` (config such as + * max_connections/version), both run through the generic query bridge. + */ + +export const GLOBAL_STATUS_SQL = "SHOW GLOBAL STATUS"; +export const GLOBAL_VARIABLES_SQL = "SHOW GLOBAL VARIABLES"; + +/** Max samples retained for the live charts (~ a few minutes at 5s cadence). */ +export const MAX_SAMPLES = 60; + +/** + * Engines with meaningful MySQL server-status counters (`SHOW GLOBAL STATUS`: + * QPS, Com_*, Bytes_*, InnoDB). MariaDB, TiDB, and OceanBase ride the `mysql` + * dbType via a driver profile, so they are covered by the `"mysql"` entry. + * + * Deliberately excludes the OLAP MySQL-protocol forks (Doris, StarRocks) — they + * have no InnoDB and do not expose these status counters, so the dashboard would + * be empty there. GoldenDB is excluded pending confirmation its proxy passes the + * counters through. + */ +const SERVER_DASHBOARD_DB_TYPES = new Set(["mysql"]); + +export type StatusMap = Record; + +export interface StatusSample { + /** Capture time in epoch milliseconds (captured by the caller). */ + at: number; + status: StatusMap; +} + +/** A key/value row for the raw status table. */ +export interface StatusEntry { + name: string; + value: string; +} + +/** Parse a two-column `SHOW GLOBAL STATUS` / `SHOW GLOBAL VARIABLES` result. */ +export function parseStatusResult(result: QueryResult | null | undefined): StatusMap { + const map: StatusMap = {}; + if (!result || !Array.isArray(result.columns) || !Array.isArray(result.rows)) return map; + const nameIdx = result.columns.findIndex((c) => c.toLowerCase() === "variable_name"); + const valueIdx = result.columns.findIndex((c) => c.toLowerCase() === "value"); + const nameCol = nameIdx >= 0 ? nameIdx : 0; + const valueCol = valueIdx >= 0 ? valueIdx : 1; + for (const row of result.rows) { + const name = row[nameCol]; + if (name === null || name === undefined) continue; + map[String(name)] = row[valueCol] === null || row[valueCol] === undefined ? "" : String(row[valueCol]); + } + return map; +} + +/** Read a status value as a number, defaulting to 0 when absent/non-numeric. */ +export function statusNumber(status: StatusMap, key: string): number { + const raw = status[key]; + if (raw === undefined) return 0; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : 0; +} + +/** + * Per-second rate of a cumulative counter between two samples. Guards against a + * counter reset (server restart / FLUSH STATUS) by treating a decrease as no + * measurable rate, and against a zero/negative time delta. + */ +export function computeRate(prev: StatusSample, curr: StatusSample, key: string): number { + const dtSeconds = (curr.at - prev.at) / 1000; + if (dtSeconds <= 0) return 0; + const delta = statusNumber(curr.status, key) - statusNumber(prev.status, key); + if (delta < 0) return 0; + return delta / dtSeconds; +} + +/** QPS between two samples, preferring `Queries` and falling back to `Questions`. */ +export function computeQps(prev: StatusSample, curr: StatusSample): number { + const hasQueries = curr.status.Queries !== undefined; + return computeRate(prev, curr, hasQueries ? "Queries" : "Questions"); +} + +/** + * InnoDB buffer pool hit ratio (0-100) from cumulative reads vs read requests. + * Returns null when the counters are unavailable (non-InnoDB / forks). + */ +export function innodbBufferHitRatio(status: StatusMap): number | null { + const requests = statusNumber(status, "Innodb_buffer_pool_read_requests"); + if (requests <= 0) return null; + const reads = statusNumber(status, "Innodb_buffer_pool_reads"); + const ratio = (1 - reads / requests) * 100; + if (!Number.isFinite(ratio)) return null; + return Math.max(0, Math.min(100, ratio)); +} + +/** Flatten a status map into sorted key/value rows for the raw table. */ +export function statusEntries(status: StatusMap): StatusEntry[] { + return Object.keys(status) + .sort((a, b) => a.localeCompare(b)) + .map((name) => ({ name, value: status[name] })); +} + +export function formatNumber(value: number): string { + return Math.round(value).toLocaleString("en-US"); +} + +const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"]; + +export function formatBytes(value: number): string { + if (!Number.isFinite(value) || value <= 0) return "0 B"; + const exponent = Math.min(Math.floor(Math.log(value) / Math.log(1024)), BYTE_UNITS.length - 1); + const scaled = value / 1024 ** exponent; + return `${scaled.toFixed(exponent === 0 ? 0 : 1)} ${BYTE_UNITS[exponent]}`; +} + +export function formatBytesPerSec(value: number): string { + return `${formatBytes(value)}/s`; +} + +/** Format an uptime in seconds as a compact `Nd Nh Nm` / `Nh Nm` / `Nm Ns` string. */ +export function formatUptime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return "0s"; + const total = Math.floor(seconds); + const days = Math.floor(total / 86400); + const hours = Math.floor((total % 86400) / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${secs}s`; + return `${secs}s`; +} + +/** Whether the given database type exposes the server dashboard (MySQL family). */ +export function supportsServerDashboard(dbType: DatabaseType | undefined): boolean { + return !!dbType && SERVER_DASHBOARD_DB_TYPES.has(dbType); +} + +/** Prevent JDBC profiles that only borrow MySQL SQL syntax from exposing MySQL server administration queries. */ +export function connectionSupportsServerDashboard(connection: ConnectionConfig | undefined): boolean { + if (!connection || !supportsServerDashboard(effectiveDatabaseTypeForConnection(connection))) return false; + if (connection.db_type !== "jdbc") return true; + const profile = [connection.driver_profile, connection.connection_string, connection.jdbc_driver_class, ...(connection.jdbc_driver_paths ?? [])].filter(Boolean).join("\n"); + return !/(?:kyuubi|hive2|org\.apache\.hive\.jdbc\.HiveDriver|hive-jdbc)/i.test(profile); +} diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 5b0ad9492..24fb1db00 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1025,6 +1025,31 @@ export const useQueryStore = defineStore("query", () => { return id; } + function openMysqlDashboard(connectionId: string) { + const existing = tabs.value.find((tab) => tab.mode === "mysql-dashboard" && tab.connectionId === connectionId); + if (existing) { + switchTab(existing.id); + return existing.id; + } + + const conn = useConnectionStore().getConfig(connectionId); + const id = uuid(); + const tab: QueryTab = { + id, + title: conn?.name ? `${conn.name} - ${t("serverDashboard.title")}` : t("serverDashboard.title"), + connectionId, + database: conn?.database || "", + sql: "", + isExecuting: false, + isCancelling: false, + isExplaining: false, + mode: "mysql-dashboard", + }; + 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) { @@ -3860,6 +3885,7 @@ export const useQueryStore = defineStore("query", () => { openMongoBucket, openUserAdmin, openProcessList, + openMysqlDashboard, openDamengJobAdmin, openMqAdmin, openNacosAdmin, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 84cd830a0..f400aac4b 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -779,7 +779,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" | "processlist"; + mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist" | "mysql-dashboard"; mqTenant?: string; mqInitialTab?: "topics"; nacosNamespace?: string;