feat(mysql): add process list viewer
This commit is contained in:
1 parent
6f5dffb279
commit
2dec6ebeb8
15 files changed
+708
-2
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user