@indiekitai/pg-dash 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/README.zh-CN.md +3 -0
- package/dist/cli.js +155 -13
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +78 -19
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -347,11 +347,13 @@ CREATE INDEX CONCURRENTLY idx_${row.relname}_<column> ON ${row.schemaname}.${row
|
|
|
347
347
|
try {
|
|
348
348
|
const r = await client.query(`
|
|
349
349
|
SELECT schemaname, relname, n_dead_tup, n_live_tup,
|
|
350
|
-
CASE WHEN n_live_tup
|
|
350
|
+
CASE WHEN (n_live_tup + n_dead_tup) > 0
|
|
351
|
+
THEN round(n_dead_tup::numeric / (n_live_tup + n_dead_tup) * 100, 1) ELSE 0 END AS dead_pct,
|
|
351
352
|
pg_size_pretty(pg_total_relation_size(relid)) AS size
|
|
352
353
|
FROM pg_stat_user_tables
|
|
353
|
-
WHERE n_live_tup
|
|
354
|
-
|
|
354
|
+
WHERE (n_live_tup + n_dead_tup) > 0
|
|
355
|
+
AND n_dead_tup::float / GREATEST(n_live_tup + n_dead_tup, 1) > 0.1
|
|
356
|
+
ORDER BY n_dead_tup::float / GREATEST(n_live_tup + n_dead_tup, 1) DESC LIMIT 20
|
|
355
357
|
`);
|
|
356
358
|
for (const row of r.rows) {
|
|
357
359
|
const pct = parseFloat(row.dead_pct);
|
|
@@ -360,7 +362,7 @@ CREATE INDEX CONCURRENTLY idx_${row.relname}_<column> ON ${row.schemaname}.${row
|
|
|
360
362
|
severity: pct > 30 ? "critical" : "warning",
|
|
361
363
|
category: "performance",
|
|
362
364
|
title: `Table bloat on ${row.relname} (${row.dead_pct}% dead)`,
|
|
363
|
-
description: `${row.schemaname}.${row.relname} has ${Number(row.n_dead_tup).toLocaleString()} dead tuples (${row.dead_pct}% of ${Number(row.n_live_tup).toLocaleString()}
|
|
365
|
+
description: `${row.schemaname}.${row.relname} has ${Number(row.n_dead_tup).toLocaleString()} dead tuples (${row.dead_pct}% of ${(Number(row.n_live_tup) + Number(row.n_dead_tup)).toLocaleString()} total rows). Size: ${row.size}.`,
|
|
364
366
|
fix: `VACUUM FULL ${row.schemaname}.${row.relname};`,
|
|
365
367
|
impact: "Dead tuples waste storage and degrade scan performance.",
|
|
366
368
|
effort: pct > 30 ? "moderate" : "quick"
|
|
@@ -424,6 +426,18 @@ SHOW shared_buffers;`,
|
|
|
424
426
|
effort: "moderate"
|
|
425
427
|
});
|
|
426
428
|
}
|
|
429
|
+
} else {
|
|
430
|
+
issues.push({
|
|
431
|
+
id: `perf-no-pg-stat-statements`,
|
|
432
|
+
severity: "warning",
|
|
433
|
+
category: "performance",
|
|
434
|
+
title: `pg_stat_statements extension not installed`,
|
|
435
|
+
description: `The pg_stat_statements extension is not installed. Without it, slow query detection, query regression analysis, and query performance trending are all unavailable.`,
|
|
436
|
+
fix: `CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
|
437
|
+
-- Also add to shared_preload_libraries in postgresql.conf and restart`,
|
|
438
|
+
impact: "No visibility into slow queries, query regressions, or performance trends. You are flying blind on query performance.",
|
|
439
|
+
effort: "moderate"
|
|
440
|
+
});
|
|
427
441
|
}
|
|
428
442
|
} catch (err) {
|
|
429
443
|
console.error("[advisor] Error checking slow queries:", err.message);
|
|
@@ -595,8 +609,8 @@ SHOW shared_buffers;`,
|
|
|
595
609
|
FROM pg_stat_user_indexes
|
|
596
610
|
WHERE idx_scan = 0
|
|
597
611
|
AND indexrelname NOT LIKE '%_pkey'
|
|
598
|
-
AND pg_relation_size(indexrelid) >
|
|
599
|
-
ORDER BY pg_relation_size(indexrelid) DESC LIMIT
|
|
612
|
+
AND pg_relation_size(indexrelid) > 8192
|
|
613
|
+
ORDER BY pg_relation_size(indexrelid) DESC LIMIT 20
|
|
600
614
|
`);
|
|
601
615
|
for (const row of r.rows) {
|
|
602
616
|
issues.push({
|
|
@@ -777,6 +791,35 @@ SELECT pg_reload_conf();`,
|
|
|
777
791
|
console.error("[advisor] Error checking autovacuum:", err.message);
|
|
778
792
|
skipped.push("autovacuum: " + err.message);
|
|
779
793
|
}
|
|
794
|
+
try {
|
|
795
|
+
const r = await client.query(`
|
|
796
|
+
SELECT schemaname, relname, n_live_tup, n_dead_tup,
|
|
797
|
+
last_autovacuum, last_vacuum
|
|
798
|
+
FROM pg_stat_user_tables
|
|
799
|
+
WHERE n_live_tup < 50
|
|
800
|
+
AND n_dead_tup > 0
|
|
801
|
+
AND last_autovacuum IS NULL
|
|
802
|
+
AND last_vacuum IS NULL
|
|
803
|
+
`);
|
|
804
|
+
if (r.rows.length >= 5) {
|
|
805
|
+
const tables = r.rows.map((row) => row.relname);
|
|
806
|
+
const totalDead = r.rows.reduce((sum, row) => sum + parseInt(row.n_dead_tup, 10), 0);
|
|
807
|
+
issues.push({
|
|
808
|
+
id: `maint-autovacuum-small-tables`,
|
|
809
|
+
severity: "warning",
|
|
810
|
+
category: "maintenance",
|
|
811
|
+
title: `${r.rows.length} small tables never autovacuumed (default threshold=50 too high)`,
|
|
812
|
+
description: `${r.rows.length} tables with fewer than 50 live rows have never been autovacuumed because the default autovacuum_vacuum_threshold (50) exceeds their row count. Total dead tuples: ${totalDead}. Tables: ${tables.slice(0, 10).join(", ")}${tables.length > 10 ? `, ... and ${tables.length - 10} more` : ""}.`,
|
|
813
|
+
fix: `-- Lower per-table threshold for small tables:
|
|
814
|
+
${tables.slice(0, 5).map((t) => `ALTER TABLE ${t} SET (autovacuum_vacuum_threshold = 5, autovacuum_vacuum_scale_factor = 0.1);`).join("\n")}`,
|
|
815
|
+
impact: "Dead tuples accumulate on small tables indefinitely, causing bloat.",
|
|
816
|
+
effort: "moderate"
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
} catch (err) {
|
|
820
|
+
console.error("[advisor] Error checking autovacuum small tables:", err.message);
|
|
821
|
+
skipped.push("autovacuum small tables: " + err.message);
|
|
822
|
+
}
|
|
780
823
|
try {
|
|
781
824
|
const sbRes = await client.query(`SELECT setting, unit FROM pg_settings WHERE name = 'shared_buffers'`);
|
|
782
825
|
const memRes = await client.query(`
|
|
@@ -952,12 +995,7 @@ function getIgnoredIssues() {
|
|
|
952
995
|
return [];
|
|
953
996
|
}
|
|
954
997
|
}
|
|
955
|
-
function
|
|
956
|
-
const trimmed = sql.trim();
|
|
957
|
-
if (!trimmed) return false;
|
|
958
|
-
const statements = trimmed.replace(/;\s*$/, "").split(";").map((s) => s.trim()).filter(Boolean);
|
|
959
|
-
if (statements.length !== 1) return false;
|
|
960
|
-
const upper = statements[0].toUpperCase();
|
|
998
|
+
function isSafeSingleStatement(upper) {
|
|
961
999
|
if (upper.startsWith("EXPLAIN ANALYZE")) {
|
|
962
1000
|
const afterExplain = upper.replace(/^EXPLAIN\s+ANALYZE\s+/, "").trimStart();
|
|
963
1001
|
return afterExplain.startsWith("SELECT");
|
|
@@ -971,8 +1009,18 @@ function isSafeFix(sql) {
|
|
|
971
1009
|
"SELECT PG_TERMINATE_BACKEND(",
|
|
972
1010
|
"SELECT PG_CANCEL_BACKEND("
|
|
973
1011
|
];
|
|
1012
|
+
if (upper.startsWith("ALTER TABLE")) {
|
|
1013
|
+
return /^ALTER TABLE\s+\S+\s+SET\s*\(/i.test(upper);
|
|
1014
|
+
}
|
|
974
1015
|
return ALLOWED_PREFIXES.some((p) => upper.startsWith(p));
|
|
975
1016
|
}
|
|
1017
|
+
function isSafeFix(sql) {
|
|
1018
|
+
const trimmed = sql.trim();
|
|
1019
|
+
if (!trimmed) return false;
|
|
1020
|
+
const statements = trimmed.replace(/;\s*$/, "").split(";").map((s) => s.trim()).filter(Boolean);
|
|
1021
|
+
if (statements.length === 0) return false;
|
|
1022
|
+
return statements.every((stmt) => isSafeSingleStatement(stmt.toUpperCase()));
|
|
1023
|
+
}
|
|
976
1024
|
|
|
977
1025
|
// src/server/queries/slow-queries.ts
|
|
978
1026
|
async function getSlowQueries(pool2) {
|
|
@@ -2986,14 +3034,25 @@ server.tool("pg_dash_schema_changes", "Get recent schema changes", {}, async ()
|
|
|
2986
3034
|
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
2987
3035
|
}
|
|
2988
3036
|
});
|
|
2989
|
-
server.tool("pg_dash_fix", "Execute
|
|
3037
|
+
server.tool("pg_dash_fix", "Execute safe fix(es) \u2014 supports batch: multiple VACUUM/ANALYZE/REINDEX statements separated by semicolons", { sql: z.string().describe("SQL to execute (must be safe operations; multiple statements separated by ; are supported)") }, async ({ sql }) => {
|
|
2990
3038
|
try {
|
|
2991
|
-
if (!isSafeFix(sql)) return { content: [{ type: "text", text: "Operation not allowed. Only VACUUM, ANALYZE, REINDEX, CREATE/DROP INDEX CONCURRENTLY, pg_terminate_backend, pg_cancel_backend, and EXPLAIN ANALYZE are permitted." }], isError: true };
|
|
3039
|
+
if (!isSafeFix(sql)) return { content: [{ type: "text", text: "Operation not allowed. Only VACUUM, ANALYZE, REINDEX, CREATE/DROP INDEX CONCURRENTLY, ALTER TABLE SET, pg_terminate_backend, pg_cancel_backend, and EXPLAIN ANALYZE are permitted." }], isError: true };
|
|
2992
3040
|
const client = await pool.connect();
|
|
2993
3041
|
try {
|
|
3042
|
+
const statements = sql.trim().replace(/;\s*$/, "").split(";").map((s) => s.trim()).filter(Boolean);
|
|
2994
3043
|
const start = Date.now();
|
|
2995
|
-
const
|
|
2996
|
-
|
|
3044
|
+
const results = [];
|
|
3045
|
+
for (const stmt of statements) {
|
|
3046
|
+
try {
|
|
3047
|
+
await client.query(stmt);
|
|
3048
|
+
results.push({ sql: stmt, ok: true });
|
|
3049
|
+
} catch (err) {
|
|
3050
|
+
results.push({ sql: stmt, ok: false, error: err.message });
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
const succeeded = results.filter((r) => r.ok).length;
|
|
3054
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
3055
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: failed === 0, duration: Date.now() - start, total: statements.length, succeeded, failed, results }, null, 2) }] };
|
|
2997
3056
|
} finally {
|
|
2998
3057
|
client.release();
|
|
2999
3058
|
}
|
|
@@ -3010,15 +3069,15 @@ server.tool("pg_dash_alerts", "Get alert history", {}, async () => {
|
|
|
3010
3069
|
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
3011
3070
|
}
|
|
3012
3071
|
});
|
|
3013
|
-
server.tool("pg_dash_explain", "Run EXPLAIN ANALYZE on a SELECT query (read-only, wrapped in BEGIN/ROLLBACK)", {
|
|
3072
|
+
server.tool("pg_dash_explain", "Run EXPLAIN ANALYZE on a SELECT query (read-only, wrapped in BEGIN/ROLLBACK)", { sql: z.string().describe("SELECT query to explain") }, async ({ sql }) => {
|
|
3014
3073
|
try {
|
|
3015
|
-
if (!/^\s*SELECT\b/i.test(
|
|
3074
|
+
if (!/^\s*SELECT\b/i.test(sql)) return { content: [{ type: "text", text: "Error: Only SELECT queries are allowed" }], isError: true };
|
|
3016
3075
|
const client = await pool.connect();
|
|
3017
3076
|
try {
|
|
3018
3077
|
await client.query("SET statement_timeout = '30s'");
|
|
3019
3078
|
await client.query("BEGIN");
|
|
3020
3079
|
try {
|
|
3021
|
-
const r = await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${
|
|
3080
|
+
const r = await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${sql}`);
|
|
3022
3081
|
await client.query("ROLLBACK");
|
|
3023
3082
|
await client.query("RESET statement_timeout");
|
|
3024
3083
|
return { content: [{ type: "text", text: JSON.stringify(r.rows[0]["QUERY PLAN"], null, 2) }] };
|