feat(mysql): add server metrics dashboard
This commit is contained in:
1 parent
c02b3967df
commit
c9c2fddfe2
17 files changed
+812
-2
No files matched your search
@@ -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<string, string>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<DatabaseType>(["mysql"]);
|
||||
|
||||
export type StatusMap = Record<string, string>;
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user