@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/README.md
CHANGED
|
@@ -27,6 +27,9 @@ npx @indiekitai/pg-dash explain "SELECT * FROM orders WHERE user_id = 1" postgre
|
|
|
27
27
|
# Real-time lock + long-query monitor (Ctrl+C to exit)
|
|
28
28
|
npx @indiekitai/pg-dash watch-locks postgres://...
|
|
29
29
|
|
|
30
|
+
# Analyze slow queries from pg_stat_statements
|
|
31
|
+
npx @indiekitai/pg-dash slow-queries postgres://... --limit 20 --min-calls 5
|
|
32
|
+
|
|
30
33
|
# Compare two environments (local vs staging)
|
|
31
34
|
npx @indiekitai/pg-dash diff-env --source postgres://localhost/db --target postgres://staging/db
|
|
32
35
|
|
package/README.zh-CN.md
CHANGED
|
@@ -27,6 +27,9 @@ npx @indiekitai/pg-dash explain "SELECT * FROM orders WHERE user_id = 1" postgre
|
|
|
27
27
|
# 实时锁监控(Ctrl+C 退出)
|
|
28
28
|
npx @indiekitai/pg-dash watch-locks postgres://...
|
|
29
29
|
|
|
30
|
+
# 慢查询分析(基于 pg_stat_statements)
|
|
31
|
+
npx @indiekitai/pg-dash slow-queries postgres://... --limit 20 --min-calls 5
|
|
32
|
+
|
|
30
33
|
# 对比两个环境(本地 vs 预发)
|
|
31
34
|
npx @indiekitai/pg-dash diff-env --source postgres://localhost/db --target postgres://staging/db
|
|
32
35
|
|
package/dist/cli.js
CHANGED
|
@@ -125,11 +125,13 @@ CREATE INDEX CONCURRENTLY idx_${row.relname}_<column> ON ${row.schemaname}.${row
|
|
|
125
125
|
try {
|
|
126
126
|
const r = await client.query(`
|
|
127
127
|
SELECT schemaname, relname, n_dead_tup, n_live_tup,
|
|
128
|
-
CASE WHEN n_live_tup
|
|
128
|
+
CASE WHEN (n_live_tup + n_dead_tup) > 0
|
|
129
|
+
THEN round(n_dead_tup::numeric / (n_live_tup + n_dead_tup) * 100, 1) ELSE 0 END AS dead_pct,
|
|
129
130
|
pg_size_pretty(pg_total_relation_size(relid)) AS size
|
|
130
131
|
FROM pg_stat_user_tables
|
|
131
|
-
WHERE n_live_tup
|
|
132
|
-
|
|
132
|
+
WHERE (n_live_tup + n_dead_tup) > 0
|
|
133
|
+
AND n_dead_tup::float / GREATEST(n_live_tup + n_dead_tup, 1) > 0.1
|
|
134
|
+
ORDER BY n_dead_tup::float / GREATEST(n_live_tup + n_dead_tup, 1) DESC LIMIT 20
|
|
133
135
|
`);
|
|
134
136
|
for (const row of r.rows) {
|
|
135
137
|
const pct = parseFloat(row.dead_pct);
|
|
@@ -138,7 +140,7 @@ CREATE INDEX CONCURRENTLY idx_${row.relname}_<column> ON ${row.schemaname}.${row
|
|
|
138
140
|
severity: pct > 30 ? "critical" : "warning",
|
|
139
141
|
category: "performance",
|
|
140
142
|
title: `Table bloat on ${row.relname} (${row.dead_pct}% dead)`,
|
|
141
|
-
description: `${row.schemaname}.${row.relname} has ${Number(row.n_dead_tup).toLocaleString()} dead tuples (${row.dead_pct}% of ${Number(row.n_live_tup).toLocaleString()}
|
|
143
|
+
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}.`,
|
|
142
144
|
fix: `VACUUM FULL ${row.schemaname}.${row.relname};`,
|
|
143
145
|
impact: "Dead tuples waste storage and degrade scan performance.",
|
|
144
146
|
effort: pct > 30 ? "moderate" : "quick"
|
|
@@ -202,6 +204,18 @@ SHOW shared_buffers;`,
|
|
|
202
204
|
effort: "moderate"
|
|
203
205
|
});
|
|
204
206
|
}
|
|
207
|
+
} else {
|
|
208
|
+
issues.push({
|
|
209
|
+
id: `perf-no-pg-stat-statements`,
|
|
210
|
+
severity: "warning",
|
|
211
|
+
category: "performance",
|
|
212
|
+
title: `pg_stat_statements extension not installed`,
|
|
213
|
+
description: `The pg_stat_statements extension is not installed. Without it, slow query detection, query regression analysis, and query performance trending are all unavailable.`,
|
|
214
|
+
fix: `CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
|
215
|
+
-- Also add to shared_preload_libraries in postgresql.conf and restart`,
|
|
216
|
+
impact: "No visibility into slow queries, query regressions, or performance trends. You are flying blind on query performance.",
|
|
217
|
+
effort: "moderate"
|
|
218
|
+
});
|
|
205
219
|
}
|
|
206
220
|
} catch (err) {
|
|
207
221
|
console.error("[advisor] Error checking slow queries:", err.message);
|
|
@@ -373,8 +387,8 @@ SHOW shared_buffers;`,
|
|
|
373
387
|
FROM pg_stat_user_indexes
|
|
374
388
|
WHERE idx_scan = 0
|
|
375
389
|
AND indexrelname NOT LIKE '%_pkey'
|
|
376
|
-
AND pg_relation_size(indexrelid) >
|
|
377
|
-
ORDER BY pg_relation_size(indexrelid) DESC LIMIT
|
|
390
|
+
AND pg_relation_size(indexrelid) > 8192
|
|
391
|
+
ORDER BY pg_relation_size(indexrelid) DESC LIMIT 20
|
|
378
392
|
`);
|
|
379
393
|
for (const row of r.rows) {
|
|
380
394
|
issues.push({
|
|
@@ -555,6 +569,35 @@ SELECT pg_reload_conf();`,
|
|
|
555
569
|
console.error("[advisor] Error checking autovacuum:", err.message);
|
|
556
570
|
skipped.push("autovacuum: " + err.message);
|
|
557
571
|
}
|
|
572
|
+
try {
|
|
573
|
+
const r = await client.query(`
|
|
574
|
+
SELECT schemaname, relname, n_live_tup, n_dead_tup,
|
|
575
|
+
last_autovacuum, last_vacuum
|
|
576
|
+
FROM pg_stat_user_tables
|
|
577
|
+
WHERE n_live_tup < 50
|
|
578
|
+
AND n_dead_tup > 0
|
|
579
|
+
AND last_autovacuum IS NULL
|
|
580
|
+
AND last_vacuum IS NULL
|
|
581
|
+
`);
|
|
582
|
+
if (r.rows.length >= 5) {
|
|
583
|
+
const tables = r.rows.map((row) => row.relname);
|
|
584
|
+
const totalDead = r.rows.reduce((sum, row) => sum + parseInt(row.n_dead_tup, 10), 0);
|
|
585
|
+
issues.push({
|
|
586
|
+
id: `maint-autovacuum-small-tables`,
|
|
587
|
+
severity: "warning",
|
|
588
|
+
category: "maintenance",
|
|
589
|
+
title: `${r.rows.length} small tables never autovacuumed (default threshold=50 too high)`,
|
|
590
|
+
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` : ""}.`,
|
|
591
|
+
fix: `-- Lower per-table threshold for small tables:
|
|
592
|
+
${tables.slice(0, 5).map((t) => `ALTER TABLE ${t} SET (autovacuum_vacuum_threshold = 5, autovacuum_vacuum_scale_factor = 0.1);`).join("\n")}`,
|
|
593
|
+
impact: "Dead tuples accumulate on small tables indefinitely, causing bloat.",
|
|
594
|
+
effort: "moderate"
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
} catch (err) {
|
|
598
|
+
console.error("[advisor] Error checking autovacuum small tables:", err.message);
|
|
599
|
+
skipped.push("autovacuum small tables: " + err.message);
|
|
600
|
+
}
|
|
558
601
|
try {
|
|
559
602
|
const sbRes = await client.query(`SELECT setting, unit FROM pg_settings WHERE name = 'shared_buffers'`);
|
|
560
603
|
const memRes = await client.query(`
|
|
@@ -737,12 +780,7 @@ function unignoreIssue(issueId) {
|
|
|
737
780
|
const db = getIgnoredDb();
|
|
738
781
|
db.prepare("DELETE FROM ignored_issues WHERE issue_id = ?").run(issueId);
|
|
739
782
|
}
|
|
740
|
-
function
|
|
741
|
-
const trimmed = sql.trim();
|
|
742
|
-
if (!trimmed) return false;
|
|
743
|
-
const statements = trimmed.replace(/;\s*$/, "").split(";").map((s) => s.trim()).filter(Boolean);
|
|
744
|
-
if (statements.length !== 1) return false;
|
|
745
|
-
const upper = statements[0].toUpperCase();
|
|
783
|
+
function isSafeSingleStatement(upper) {
|
|
746
784
|
if (upper.startsWith("EXPLAIN ANALYZE")) {
|
|
747
785
|
const afterExplain = upper.replace(/^EXPLAIN\s+ANALYZE\s+/, "").trimStart();
|
|
748
786
|
return afterExplain.startsWith("SELECT");
|
|
@@ -756,8 +794,18 @@ function isSafeFix(sql) {
|
|
|
756
794
|
"SELECT PG_TERMINATE_BACKEND(",
|
|
757
795
|
"SELECT PG_CANCEL_BACKEND("
|
|
758
796
|
];
|
|
797
|
+
if (upper.startsWith("ALTER TABLE")) {
|
|
798
|
+
return /^ALTER TABLE\s+\S+\s+SET\s*\(/i.test(upper);
|
|
799
|
+
}
|
|
759
800
|
return ALLOWED_PREFIXES.some((p) => upper.startsWith(p));
|
|
760
801
|
}
|
|
802
|
+
function isSafeFix(sql) {
|
|
803
|
+
const trimmed = sql.trim();
|
|
804
|
+
if (!trimmed) return false;
|
|
805
|
+
const statements = trimmed.replace(/;\s*$/, "").split(";").map((s) => s.trim()).filter(Boolean);
|
|
806
|
+
if (statements.length === 0) return false;
|
|
807
|
+
return statements.every((stmt) => isSafeSingleStatement(stmt.toUpperCase()));
|
|
808
|
+
}
|
|
761
809
|
var SEVERITY_WEIGHT, MAX_DEDUCTION, _ignoredDb;
|
|
762
810
|
var init_advisor = __esm({
|
|
763
811
|
"src/server/advisor.ts"() {
|
|
@@ -4748,6 +4796,8 @@ var { values, positionals } = parseArgs({
|
|
|
4748
4796
|
"snapshot-interval": { type: "string" },
|
|
4749
4797
|
"query-stats-interval": { type: "string" },
|
|
4750
4798
|
"long-query-threshold": { type: "string" },
|
|
4799
|
+
limit: { type: "string" },
|
|
4800
|
+
"min-calls": { type: "string" },
|
|
4751
4801
|
help: { type: "boolean", short: "h" },
|
|
4752
4802
|
version: { type: "boolean", short: "v" },
|
|
4753
4803
|
threshold: { type: "string" },
|
|
@@ -4787,6 +4837,8 @@ Usage:
|
|
|
4787
4837
|
pg-dash schema-diff <connection-string> Show latest schema changes
|
|
4788
4838
|
pg-dash query-stats export <connection> Export query statistics (PG 18+)
|
|
4789
4839
|
pg-dash query-stats import <file> <connection> Import query statistics (PG 18+)
|
|
4840
|
+
pg-dash slow-queries <connection> Analyze slow queries from pg_stat_statements
|
|
4841
|
+
pg-dash slow-queries <connection> --limit 20 --min-calls 5
|
|
4790
4842
|
pg-dash --host localhost --user postgres --db mydb
|
|
4791
4843
|
|
|
4792
4844
|
Options:
|
|
@@ -4828,7 +4880,7 @@ Environment variables:
|
|
|
4828
4880
|
`);
|
|
4829
4881
|
process.exit(0);
|
|
4830
4882
|
}
|
|
4831
|
-
var KNOWN_SUBCOMMANDS = ["check", "health", "check-migration", "schema-diff", "diff-env", "explain", "watch-locks", "query-stats"];
|
|
4883
|
+
var KNOWN_SUBCOMMANDS = ["check", "health", "check-migration", "schema-diff", "diff-env", "explain", "watch-locks", "query-stats", "slow-queries"];
|
|
4832
4884
|
var subcommand = positionals[0];
|
|
4833
4885
|
function isValidConnectionString(s) {
|
|
4834
4886
|
return s.startsWith("postgresql://") || s.startsWith("postgres://") || s.includes("@") || // user@host shorthand
|
|
@@ -5537,6 +5589,93 @@ ${bold}${yellow} Long-running Queries (${report.longRunningQueries.length})${re
|
|
|
5537
5589
|
console.error("Error: specify 'export' or 'import'.\n\nUsage:\n pg-dash query-stats export <connection> [--file output.json]\n pg-dash query-stats import <file> <connection>");
|
|
5538
5590
|
process.exit(1);
|
|
5539
5591
|
}
|
|
5592
|
+
} else if (subcommand === "slow-queries") {
|
|
5593
|
+
const connStr = positionals[1] || resolveConnectionString(1);
|
|
5594
|
+
if (!connStr) {
|
|
5595
|
+
console.error("Error: provide a connection string.\n\nUsage: pg-dash slow-queries <connection> [--limit 20] [--sort total|time|calls] [--min-calls 5] [--json]");
|
|
5596
|
+
process.exit(1);
|
|
5597
|
+
}
|
|
5598
|
+
const limit = parseInt(values.limit || values.threshold || "20", 10);
|
|
5599
|
+
const sortBy = values.format || "total";
|
|
5600
|
+
const minCalls = parseInt(values["min-calls"] || values["long-query-threshold"] || "5", 10);
|
|
5601
|
+
const outputJson = values.json || false;
|
|
5602
|
+
const { Pool: Pool3 } = await import("pg");
|
|
5603
|
+
const pool = new Pool3({ connectionString: connStr, connectionTimeoutMillis: 1e4 });
|
|
5604
|
+
try {
|
|
5605
|
+
let fmtTime = function(ms) {
|
|
5606
|
+
if (ms < 1) return `${ms.toFixed(2)}ms`;
|
|
5607
|
+
if (ms < 1e3) return `${ms.toFixed(1)}ms`;
|
|
5608
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
|
|
5609
|
+
return `${(ms / 6e4).toFixed(1)}m`;
|
|
5610
|
+
}, fmtNum = function(n) {
|
|
5611
|
+
return n.toLocaleString();
|
|
5612
|
+
}, truncateQuery = function(q, maxLen = 120) {
|
|
5613
|
+
const cleaned = q.replace(/\s+/g, " ").trim();
|
|
5614
|
+
return cleaned.length <= maxLen ? cleaned : cleaned.slice(0, maxLen) + "...";
|
|
5615
|
+
};
|
|
5616
|
+
fmtTime2 = fmtTime, fmtNum2 = fmtNum, truncateQuery2 = truncateQuery;
|
|
5617
|
+
const extCheck = await pool.query(`SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'`);
|
|
5618
|
+
if (extCheck.rows.length === 0) {
|
|
5619
|
+
console.error("Error: pg_stat_statements extension is not installed.");
|
|
5620
|
+
console.error(" Run: CREATE EXTENSION pg_stat_statements;");
|
|
5621
|
+
process.exit(1);
|
|
5622
|
+
}
|
|
5623
|
+
let orderCol = "total_exec_time";
|
|
5624
|
+
if (sortBy === "time") orderCol = "mean_exec_time";
|
|
5625
|
+
else if (sortBy === "calls") orderCol = "calls";
|
|
5626
|
+
const res = await pool.query(`
|
|
5627
|
+
SELECT query, calls, total_exec_time, mean_exec_time, stddev_exec_time, rows,
|
|
5628
|
+
shared_blks_hit, shared_blks_read, shared_blks_dirtied, shared_blks_written,
|
|
5629
|
+
temp_blks_read, temp_blks_written
|
|
5630
|
+
FROM pg_stat_statements
|
|
5631
|
+
WHERE calls >= $1
|
|
5632
|
+
ORDER BY ${orderCol} DESC
|
|
5633
|
+
LIMIT $2
|
|
5634
|
+
`, [minCalls, limit]);
|
|
5635
|
+
const totalTimeRes = await pool.query(`SELECT SUM(total_exec_time) as total FROM pg_stat_statements WHERE calls >= $1`, [minCalls]);
|
|
5636
|
+
const totalTime = totalTimeRes.rows[0]?.total || 0;
|
|
5637
|
+
if (outputJson) {
|
|
5638
|
+
console.log(JSON.stringify({
|
|
5639
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5640
|
+
sortBy,
|
|
5641
|
+
minCalls,
|
|
5642
|
+
limit,
|
|
5643
|
+
totalQueryTime: totalTime,
|
|
5644
|
+
queries: res.rows.map((r) => ({
|
|
5645
|
+
query: r.query,
|
|
5646
|
+
calls: Number(r.calls),
|
|
5647
|
+
totalTimeMs: Math.round(r.total_exec_time),
|
|
5648
|
+
meanTimeMs: Math.round(r.mean_exec_time),
|
|
5649
|
+
rows: Number(r.rows),
|
|
5650
|
+
cacheHitRatio: r.shared_blks_hit + r.shared_blks_read > 0 ? Math.round(100 * r.shared_blks_hit / (r.shared_blks_hit + r.shared_blks_read)) : null
|
|
5651
|
+
}))
|
|
5652
|
+
}, null, 2));
|
|
5653
|
+
} else {
|
|
5654
|
+
console.log(`
|
|
5655
|
+
pg-dash slow-queries (sorted by: ${sortBy}, min calls: ${minCalls})
|
|
5656
|
+
`);
|
|
5657
|
+
console.log("\u2550".repeat(100));
|
|
5658
|
+
for (let i = 0; i < res.rows.length; i++) {
|
|
5659
|
+
const r = res.rows[i];
|
|
5660
|
+
const pct = totalTime > 0 ? (r.total_exec_time / totalTime * 100).toFixed(1) : "0.0";
|
|
5661
|
+
const cacheHit = r.shared_blks_hit + r.shared_blks_read > 0 ? (100 * r.shared_blks_hit / (r.shared_blks_hit + r.shared_blks_read)).toFixed(1) : "N/A";
|
|
5662
|
+
console.log(`
|
|
5663
|
+
#${i + 1} (${pct}% of total time)`);
|
|
5664
|
+
console.log(` Calls: ${fmtNum(r.calls)} | Total: ${fmtTime(r.total_exec_time)} | Mean: ${fmtTime(r.mean_exec_time)}`);
|
|
5665
|
+
console.log(` Rows: ${fmtNum(r.rows)} | Cache hit: ${cacheHit}%`);
|
|
5666
|
+
console.log(` Query: ${truncateQuery(r.query)}`);
|
|
5667
|
+
}
|
|
5668
|
+
console.log("\n" + "\u2550".repeat(100));
|
|
5669
|
+
console.log(`
|
|
5670
|
+
Tip: CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
|
5671
|
+
`);
|
|
5672
|
+
}
|
|
5673
|
+
await pool.end();
|
|
5674
|
+
} catch (err) {
|
|
5675
|
+
console.error(`Error: ${err.message}`);
|
|
5676
|
+
await pool.end();
|
|
5677
|
+
process.exit(1);
|
|
5678
|
+
}
|
|
5540
5679
|
} else {
|
|
5541
5680
|
if (subcommand && !isValidConnectionString(subcommand) && KNOWN_SUBCOMMANDS.indexOf(subcommand) === -1) {
|
|
5542
5681
|
console.error(
|
|
@@ -5581,4 +5720,7 @@ Run pg-dash --help for usage.`
|
|
|
5581
5720
|
var nodeColor2;
|
|
5582
5721
|
var renderNode2;
|
|
5583
5722
|
var collectNodes3;
|
|
5723
|
+
var fmtTime2;
|
|
5724
|
+
var fmtNum2;
|
|
5725
|
+
var truncateQuery2;
|
|
5584
5726
|
//# sourceMappingURL=cli.js.map
|