@indiekitai/pg-dash 0.3.2 → 0.3.4
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 +88 -11
- package/README.zh-CN.md +87 -10
- package/dist/cli.js +318 -19
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +249 -9
- package/dist/mcp.js.map +1 -1
- package/dist/ui/assets/index-D5LMag3w.css +1 -0
- package/dist/ui/assets/index-RQDs_hnz.js +33 -0
- package/dist/ui/index.html +2 -2
- package/package.json +1 -1
- package/dist/ui/assets/index-BI4_c1SD.js +0 -33
- package/dist/ui/assets/index-F2MaHZFy.css +0 -1
package/dist/mcp.js
CHANGED
|
@@ -840,6 +840,28 @@ SELECT pg_reload_conf();`,
|
|
|
840
840
|
const ignoredSet = new Set(ignoredIds);
|
|
841
841
|
const activeIssues = issues.filter((i) => !ignoredSet.has(i.id));
|
|
842
842
|
const ignoredCount = issues.length - activeIssues.length;
|
|
843
|
+
const batchFixes = [];
|
|
844
|
+
const groups = /* @__PURE__ */ new Map();
|
|
845
|
+
for (const issue of activeIssues) {
|
|
846
|
+
const prefix = issue.id.replace(/-[^-]+$/, "");
|
|
847
|
+
if (!groups.has(prefix)) groups.set(prefix, []);
|
|
848
|
+
groups.get(prefix).push(issue);
|
|
849
|
+
}
|
|
850
|
+
const BATCH_TITLES = {
|
|
851
|
+
"schema-fk-no-idx": "Create all missing FK indexes",
|
|
852
|
+
"schema-unused-idx": "Drop all unused indexes",
|
|
853
|
+
"schema-no-pk": "Fix all tables missing primary keys",
|
|
854
|
+
"maint-vacuum": "VACUUM all overdue tables",
|
|
855
|
+
"maint-analyze": "ANALYZE all tables missing statistics",
|
|
856
|
+
"perf-bloated-idx": "REINDEX all bloated indexes",
|
|
857
|
+
"perf-bloat": "VACUUM FULL all bloated tables"
|
|
858
|
+
};
|
|
859
|
+
for (const [prefix, group] of groups) {
|
|
860
|
+
if (group.length <= 1) continue;
|
|
861
|
+
const title = BATCH_TITLES[prefix] || `Fix all ${group.length} ${prefix} issues`;
|
|
862
|
+
const sql = group.map((i) => i.fix.split("\n").filter((l) => !l.trim().startsWith("--")).join("\n").trim()).filter(Boolean).join(";\n") + ";";
|
|
863
|
+
batchFixes.push({ type: prefix, title: `${title} (${group.length})`, count: group.length, sql });
|
|
864
|
+
}
|
|
843
865
|
const score = computeAdvisorScore(activeIssues);
|
|
844
866
|
return {
|
|
845
867
|
score,
|
|
@@ -847,7 +869,8 @@ SELECT pg_reload_conf();`,
|
|
|
847
869
|
issues: activeIssues,
|
|
848
870
|
breakdown: computeBreakdown(activeIssues),
|
|
849
871
|
skipped,
|
|
850
|
-
ignoredCount
|
|
872
|
+
ignoredCount,
|
|
873
|
+
batchFixes
|
|
851
874
|
};
|
|
852
875
|
} finally {
|
|
853
876
|
client.release();
|
|
@@ -894,11 +917,81 @@ function isSafeFix(sql) {
|
|
|
894
917
|
return ALLOWED_PREFIXES.some((p) => upper.startsWith(p));
|
|
895
918
|
}
|
|
896
919
|
|
|
920
|
+
// src/server/queries/slow-queries.ts
|
|
921
|
+
async function getSlowQueries(pool2) {
|
|
922
|
+
const client = await pool2.connect();
|
|
923
|
+
try {
|
|
924
|
+
const extCheck = await client.query(
|
|
925
|
+
"SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'"
|
|
926
|
+
);
|
|
927
|
+
if (extCheck.rows.length === 0) {
|
|
928
|
+
return [];
|
|
929
|
+
}
|
|
930
|
+
const r = await client.query(`
|
|
931
|
+
SELECT
|
|
932
|
+
queryid::text,
|
|
933
|
+
query,
|
|
934
|
+
calls::int,
|
|
935
|
+
total_exec_time AS total_time,
|
|
936
|
+
mean_exec_time AS mean_time,
|
|
937
|
+
rows::int,
|
|
938
|
+
round(total_exec_time::numeric / 1000, 2)::text || 's' AS total_time_pretty,
|
|
939
|
+
round(mean_exec_time::numeric, 2)::text || 'ms' AS mean_time_pretty
|
|
940
|
+
FROM pg_stat_statements
|
|
941
|
+
WHERE query NOT LIKE '%pg_stat%'
|
|
942
|
+
AND query NOT LIKE '%pg_catalog%'
|
|
943
|
+
ORDER BY total_exec_time DESC
|
|
944
|
+
LIMIT 50
|
|
945
|
+
`);
|
|
946
|
+
return r.rows;
|
|
947
|
+
} catch {
|
|
948
|
+
return [];
|
|
949
|
+
} finally {
|
|
950
|
+
client.release();
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// src/server/snapshot.ts
|
|
955
|
+
import fs2 from "fs";
|
|
956
|
+
import path2 from "path";
|
|
957
|
+
var SNAPSHOT_FILE = "last-check.json";
|
|
958
|
+
function saveSnapshot(dataDir2, result) {
|
|
959
|
+
fs2.mkdirSync(dataDir2, { recursive: true });
|
|
960
|
+
const snapshot = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), result };
|
|
961
|
+
fs2.writeFileSync(path2.join(dataDir2, SNAPSHOT_FILE), JSON.stringify(snapshot, null, 2));
|
|
962
|
+
}
|
|
963
|
+
function loadSnapshot(dataDir2) {
|
|
964
|
+
const filePath = path2.join(dataDir2, SNAPSHOT_FILE);
|
|
965
|
+
if (!fs2.existsSync(filePath)) return null;
|
|
966
|
+
try {
|
|
967
|
+
return JSON.parse(fs2.readFileSync(filePath, "utf-8"));
|
|
968
|
+
} catch {
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function diffSnapshots(prev, current) {
|
|
973
|
+
const prevIds = new Set(prev.issues.map((i) => i.id));
|
|
974
|
+
const currIds = new Set(current.issues.map((i) => i.id));
|
|
975
|
+
const newIssues = current.issues.filter((i) => !prevIds.has(i.id));
|
|
976
|
+
const resolvedIssues = prev.issues.filter((i) => !currIds.has(i.id));
|
|
977
|
+
const unchanged = current.issues.filter((i) => prevIds.has(i.id));
|
|
978
|
+
return {
|
|
979
|
+
scoreDelta: current.score - prev.score,
|
|
980
|
+
previousScore: prev.score,
|
|
981
|
+
currentScore: current.score,
|
|
982
|
+
previousGrade: prev.grade,
|
|
983
|
+
currentGrade: current.grade,
|
|
984
|
+
newIssues,
|
|
985
|
+
resolvedIssues,
|
|
986
|
+
unchanged
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
897
990
|
// src/mcp.ts
|
|
898
991
|
import Database2 from "better-sqlite3";
|
|
899
|
-
import
|
|
992
|
+
import path3 from "path";
|
|
900
993
|
import os2 from "os";
|
|
901
|
-
import
|
|
994
|
+
import fs3, { readFileSync } from "fs";
|
|
902
995
|
var pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
903
996
|
var connString = process.argv[2] || process.env.PG_DASH_CONNECTION_STRING;
|
|
904
997
|
if (!connString) {
|
|
@@ -908,19 +1001,19 @@ if (!connString) {
|
|
|
908
1001
|
}
|
|
909
1002
|
var pool = new Pool({ connectionString: connString });
|
|
910
1003
|
var longQueryThreshold = parseInt(process.env.PG_DASH_LONG_QUERY_THRESHOLD || "5", 10);
|
|
911
|
-
var dataDir = process.env.PG_DASH_DATA_DIR ||
|
|
912
|
-
|
|
1004
|
+
var dataDir = process.env.PG_DASH_DATA_DIR || path3.join(os2.homedir(), ".pg-dash");
|
|
1005
|
+
fs3.mkdirSync(dataDir, { recursive: true });
|
|
913
1006
|
var schemaDb = null;
|
|
914
1007
|
var alertsDb = null;
|
|
915
1008
|
try {
|
|
916
|
-
const schemaPath =
|
|
917
|
-
if (
|
|
1009
|
+
const schemaPath = path3.join(dataDir, "schema.db");
|
|
1010
|
+
if (fs3.existsSync(schemaPath)) schemaDb = new Database2(schemaPath, { readonly: true });
|
|
918
1011
|
} catch (err) {
|
|
919
1012
|
console.error("[mcp] Error:", err.message);
|
|
920
1013
|
}
|
|
921
1014
|
try {
|
|
922
|
-
const alertsPath =
|
|
923
|
-
if (
|
|
1015
|
+
const alertsPath = path3.join(dataDir, "alerts.db");
|
|
1016
|
+
if (fs3.existsSync(alertsPath)) alertsDb = new Database2(alertsPath, { readonly: true });
|
|
924
1017
|
} catch (err) {
|
|
925
1018
|
console.error("[mcp] Error:", err.message);
|
|
926
1019
|
}
|
|
@@ -999,6 +1092,153 @@ server.tool("pg_dash_alerts", "Get alert history", {}, async () => {
|
|
|
999
1092
|
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
1000
1093
|
}
|
|
1001
1094
|
});
|
|
1095
|
+
server.tool("pg_dash_explain", "Run EXPLAIN ANALYZE on a SELECT query (read-only, wrapped in BEGIN/ROLLBACK)", { query: z.string().describe("SELECT query to explain") }, async ({ query }) => {
|
|
1096
|
+
try {
|
|
1097
|
+
if (!/^\s*SELECT\b/i.test(query)) return { content: [{ type: "text", text: "Error: Only SELECT queries are allowed" }], isError: true };
|
|
1098
|
+
const client = await pool.connect();
|
|
1099
|
+
try {
|
|
1100
|
+
await client.query("SET statement_timeout = '30s'");
|
|
1101
|
+
await client.query("BEGIN");
|
|
1102
|
+
try {
|
|
1103
|
+
const r = await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query}`);
|
|
1104
|
+
await client.query("ROLLBACK");
|
|
1105
|
+
await client.query("RESET statement_timeout");
|
|
1106
|
+
return { content: [{ type: "text", text: JSON.stringify(r.rows[0]["QUERY PLAN"], null, 2) }] };
|
|
1107
|
+
} catch (err) {
|
|
1108
|
+
await client.query("ROLLBACK").catch(() => {
|
|
1109
|
+
});
|
|
1110
|
+
await client.query("RESET statement_timeout").catch(() => {
|
|
1111
|
+
});
|
|
1112
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
1113
|
+
}
|
|
1114
|
+
} finally {
|
|
1115
|
+
client.release();
|
|
1116
|
+
}
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
server.tool("pg_dash_batch_fix", "Get batch fix SQL for issues (optionally filtered by category)", { category: z.string().optional().describe("Filter by issue type prefix, e.g. 'schema-missing-fk-index'") }, async ({ category }) => {
|
|
1122
|
+
try {
|
|
1123
|
+
const report = await getAdvisorReport(pool, longQueryThreshold);
|
|
1124
|
+
let fixes = report.batchFixes;
|
|
1125
|
+
if (category) fixes = fixes.filter((f) => f.type.startsWith(category));
|
|
1126
|
+
if (fixes.length === 0) return { content: [{ type: "text", text: "No batch fixes found" + (category ? ` for category '${category}'` : "") }] };
|
|
1127
|
+
const combined = fixes.map((f) => `-- ${f.title}
|
|
1128
|
+
${f.sql}`).join("\n\n");
|
|
1129
|
+
return { content: [{ type: "text", text: combined }] };
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
server.tool("pg_dash_slow_queries", "Get top slow queries from pg_stat_statements", {
|
|
1135
|
+
limit: z.number().optional().default(20).describe("Max queries to return (default 20)"),
|
|
1136
|
+
orderBy: z.enum(["total_time", "mean_time", "calls"]).optional().default("total_time").describe("Sort order")
|
|
1137
|
+
}, async ({ limit, orderBy }) => {
|
|
1138
|
+
try {
|
|
1139
|
+
const all = await getSlowQueries(pool);
|
|
1140
|
+
if (all.length === 0) return { content: [{ type: "text", text: "No slow query data available. pg_stat_statements may not be installed." }] };
|
|
1141
|
+
const sorted = [...all].sort((a, b) => b[orderBy] - a[orderBy]);
|
|
1142
|
+
return { content: [{ type: "text", text: JSON.stringify(sorted.slice(0, limit), null, 2) }] };
|
|
1143
|
+
} catch (err) {
|
|
1144
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
server.tool("pg_dash_table_sizes", "Get table sizes with data/index breakdown (top 30)", {}, async () => {
|
|
1148
|
+
try {
|
|
1149
|
+
const client = await pool.connect();
|
|
1150
|
+
try {
|
|
1151
|
+
const r = await client.query(`
|
|
1152
|
+
SELECT schemaname, relname,
|
|
1153
|
+
pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) as total_size,
|
|
1154
|
+
pg_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) as table_size,
|
|
1155
|
+
pg_indexes_size(quote_ident(schemaname) || '.' || quote_ident(relname)) as index_size
|
|
1156
|
+
FROM pg_stat_user_tables
|
|
1157
|
+
ORDER BY pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) DESC
|
|
1158
|
+
LIMIT 30
|
|
1159
|
+
`);
|
|
1160
|
+
const tables = r.rows.map((row) => ({
|
|
1161
|
+
schema: row.schemaname,
|
|
1162
|
+
name: row.relname,
|
|
1163
|
+
totalSize: parseInt(row.total_size),
|
|
1164
|
+
tableSize: parseInt(row.table_size),
|
|
1165
|
+
indexSize: parseInt(row.index_size)
|
|
1166
|
+
}));
|
|
1167
|
+
return { content: [{ type: "text", text: JSON.stringify(tables, null, 2) }] };
|
|
1168
|
+
} finally {
|
|
1169
|
+
client.release();
|
|
1170
|
+
}
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
server.tool("pg_dash_export", "Export full health report", { format: z.enum(["json", "md"]).optional().default("json").describe("Output format: json or md") }, async ({ format }) => {
|
|
1176
|
+
try {
|
|
1177
|
+
const [overview, advisor] = await Promise.all([
|
|
1178
|
+
getOverview(pool),
|
|
1179
|
+
getAdvisorReport(pool, longQueryThreshold)
|
|
1180
|
+
]);
|
|
1181
|
+
if (format === "md") {
|
|
1182
|
+
const lines = [];
|
|
1183
|
+
lines.push(`# pg-dash Health Report`);
|
|
1184
|
+
lines.push(`
|
|
1185
|
+
Generated: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1186
|
+
`);
|
|
1187
|
+
lines.push(`## Overview
|
|
1188
|
+
`);
|
|
1189
|
+
lines.push(`- **PostgreSQL**: ${overview.version}`);
|
|
1190
|
+
lines.push(`- **Database Size**: ${overview.dbSize}`);
|
|
1191
|
+
lines.push(`- **Connections**: ${overview.connections.active} active / ${overview.connections.idle} idle / ${overview.connections.max} max`);
|
|
1192
|
+
lines.push(`
|
|
1193
|
+
## Health Score: ${advisor.score}/100 (Grade: ${advisor.grade})
|
|
1194
|
+
`);
|
|
1195
|
+
lines.push(`| Category | Grade | Score | Issues |`);
|
|
1196
|
+
lines.push(`|----------|-------|-------|--------|`);
|
|
1197
|
+
for (const [cat, b] of Object.entries(advisor.breakdown)) {
|
|
1198
|
+
lines.push(`| ${cat} | ${b.grade} | ${b.score}/100 | ${b.count} |`);
|
|
1199
|
+
}
|
|
1200
|
+
if (advisor.issues.length > 0) {
|
|
1201
|
+
lines.push(`
|
|
1202
|
+
### Issues (${advisor.issues.length})
|
|
1203
|
+
`);
|
|
1204
|
+
for (const issue of advisor.issues) {
|
|
1205
|
+
const icon = issue.severity === "critical" ? "\u{1F534}" : issue.severity === "warning" ? "\u{1F7E1}" : "\u{1F535}";
|
|
1206
|
+
lines.push(`- ${icon} [${issue.severity}] ${issue.title}`);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
if (advisor.batchFixes.length > 0) {
|
|
1210
|
+
lines.push(`
|
|
1211
|
+
### \u{1F527} Batch Fixes
|
|
1212
|
+
`);
|
|
1213
|
+
for (const fix of advisor.batchFixes) {
|
|
1214
|
+
lines.push(`\`\`\`sql
|
|
1215
|
+
${fix.sql}
|
|
1216
|
+
\`\`\`
|
|
1217
|
+
`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1221
|
+
}
|
|
1222
|
+
return { content: [{ type: "text", text: JSON.stringify({ overview, advisor, exportedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) }] };
|
|
1223
|
+
} catch (err) {
|
|
1224
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
server.tool("pg_dash_diff", "Compare current health with last saved snapshot", {}, async () => {
|
|
1228
|
+
try {
|
|
1229
|
+
const prev = loadSnapshot(dataDir);
|
|
1230
|
+
const current = await getAdvisorReport(pool, longQueryThreshold);
|
|
1231
|
+
if (!prev) {
|
|
1232
|
+
saveSnapshot(dataDir, current);
|
|
1233
|
+
return { content: [{ type: "text", text: JSON.stringify({ message: "No previous snapshot found. Current result saved as baseline.", score: current.score, grade: current.grade, issues: current.issues.length }, null, 2) }] };
|
|
1234
|
+
}
|
|
1235
|
+
const diff = diffSnapshots(prev.result, current);
|
|
1236
|
+
saveSnapshot(dataDir, current);
|
|
1237
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...diff, previousTimestamp: prev.timestamp }, null, 2) }] };
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1002
1242
|
var transport = new StdioServerTransport();
|
|
1003
1243
|
await server.connect(transport);
|
|
1004
1244
|
//# sourceMappingURL=mcp.js.map
|