@indiekitai/pg-dash 0.3.2 → 0.3.3
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/dist/cli.js +186 -9
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +24 -1
- 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/cli.js
CHANGED
|
@@ -661,6 +661,28 @@ SELECT pg_reload_conf();`,
|
|
|
661
661
|
const ignoredSet = new Set(ignoredIds);
|
|
662
662
|
const activeIssues = issues.filter((i) => !ignoredSet.has(i.id));
|
|
663
663
|
const ignoredCount = issues.length - activeIssues.length;
|
|
664
|
+
const batchFixes = [];
|
|
665
|
+
const groups = /* @__PURE__ */ new Map();
|
|
666
|
+
for (const issue of activeIssues) {
|
|
667
|
+
const prefix = issue.id.replace(/-[^-]+$/, "");
|
|
668
|
+
if (!groups.has(prefix)) groups.set(prefix, []);
|
|
669
|
+
groups.get(prefix).push(issue);
|
|
670
|
+
}
|
|
671
|
+
const BATCH_TITLES = {
|
|
672
|
+
"schema-fk-no-idx": "Create all missing FK indexes",
|
|
673
|
+
"schema-unused-idx": "Drop all unused indexes",
|
|
674
|
+
"schema-no-pk": "Fix all tables missing primary keys",
|
|
675
|
+
"maint-vacuum": "VACUUM all overdue tables",
|
|
676
|
+
"maint-analyze": "ANALYZE all tables missing statistics",
|
|
677
|
+
"perf-bloated-idx": "REINDEX all bloated indexes",
|
|
678
|
+
"perf-bloat": "VACUUM FULL all bloated tables"
|
|
679
|
+
};
|
|
680
|
+
for (const [prefix, group] of groups) {
|
|
681
|
+
if (group.length <= 1) continue;
|
|
682
|
+
const title = BATCH_TITLES[prefix] || `Fix all ${group.length} ${prefix} issues`;
|
|
683
|
+
const sql = group.map((i) => i.fix.split("\n").filter((l) => !l.trim().startsWith("--")).join("\n").trim()).filter(Boolean).join(";\n") + ";";
|
|
684
|
+
batchFixes.push({ type: prefix, title: `${title} (${group.length})`, count: group.length, sql });
|
|
685
|
+
}
|
|
664
686
|
const score = computeAdvisorScore(activeIssues);
|
|
665
687
|
return {
|
|
666
688
|
score,
|
|
@@ -668,7 +690,8 @@ SELECT pg_reload_conf();`,
|
|
|
668
690
|
issues: activeIssues,
|
|
669
691
|
breakdown: computeBreakdown(activeIssues),
|
|
670
692
|
skipped,
|
|
671
|
-
ignoredCount
|
|
693
|
+
ignoredCount,
|
|
694
|
+
batchFixes
|
|
672
695
|
};
|
|
673
696
|
} finally {
|
|
674
697
|
client.release();
|
|
@@ -943,6 +966,7 @@ var Collector = class extends EventEmitter {
|
|
|
943
966
|
pruneTimer = null;
|
|
944
967
|
prev = null;
|
|
945
968
|
lastSnapshot = {};
|
|
969
|
+
collectCount = 0;
|
|
946
970
|
start() {
|
|
947
971
|
this.collect().catch((err) => console.error("[collector] Initial collection failed:", err));
|
|
948
972
|
this.timer = setInterval(() => {
|
|
@@ -1045,6 +1069,28 @@ var Collector = class extends EventEmitter {
|
|
|
1045
1069
|
console.error("[collector] Error collecting metrics:", err.message);
|
|
1046
1070
|
return snapshot;
|
|
1047
1071
|
}
|
|
1072
|
+
this.collectCount++;
|
|
1073
|
+
if (this.collectCount % 10 === 0) {
|
|
1074
|
+
try {
|
|
1075
|
+
const client = await this.pool.connect();
|
|
1076
|
+
try {
|
|
1077
|
+
const tableRes = await client.query(`
|
|
1078
|
+
SELECT schemaname, relname,
|
|
1079
|
+
pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) as total_size
|
|
1080
|
+
FROM pg_stat_user_tables
|
|
1081
|
+
ORDER BY pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) DESC
|
|
1082
|
+
LIMIT 20
|
|
1083
|
+
`);
|
|
1084
|
+
for (const row of tableRes.rows) {
|
|
1085
|
+
this.store.insert(`table_size:${row.schemaname}.${row.relname}`, parseInt(row.total_size), now);
|
|
1086
|
+
}
|
|
1087
|
+
} finally {
|
|
1088
|
+
client.release();
|
|
1089
|
+
}
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
console.error("[collector] Error collecting table sizes:", err.message);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1048
1094
|
const points = Object.entries(snapshot).map(([metric, value]) => ({
|
|
1049
1095
|
timestamp: now,
|
|
1050
1096
|
metric,
|
|
@@ -1896,7 +1942,12 @@ function registerActivityRoutes(app, pool) {
|
|
|
1896
1942
|
|
|
1897
1943
|
// src/server/routes/advisor.ts
|
|
1898
1944
|
init_advisor();
|
|
1899
|
-
|
|
1945
|
+
var RANGE_MAP2 = {
|
|
1946
|
+
"24h": 24 * 60 * 60 * 1e3,
|
|
1947
|
+
"7d": 7 * 24 * 60 * 60 * 1e3,
|
|
1948
|
+
"30d": 30 * 24 * 60 * 60 * 1e3
|
|
1949
|
+
};
|
|
1950
|
+
function registerAdvisorRoutes(app, pool, longQueryThreshold, store) {
|
|
1900
1951
|
app.get("/api/advisor", async (c) => {
|
|
1901
1952
|
try {
|
|
1902
1953
|
return c.json(await getAdvisorReport(pool, longQueryThreshold));
|
|
@@ -1931,6 +1982,18 @@ function registerAdvisorRoutes(app, pool, longQueryThreshold) {
|
|
|
1931
1982
|
return c.json({ error: err.message }, 500);
|
|
1932
1983
|
}
|
|
1933
1984
|
});
|
|
1985
|
+
app.get("/api/advisor/history", (c) => {
|
|
1986
|
+
if (!store) return c.json([]);
|
|
1987
|
+
try {
|
|
1988
|
+
const range = c.req.query("range") || "24h";
|
|
1989
|
+
const rangeMs = RANGE_MAP2[range] || RANGE_MAP2["24h"];
|
|
1990
|
+
const now = Date.now();
|
|
1991
|
+
const data = store.query("health_score", now - rangeMs, now);
|
|
1992
|
+
return c.json(data);
|
|
1993
|
+
} catch (err) {
|
|
1994
|
+
return c.json({ error: err.message }, 500);
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1934
1997
|
app.post("/api/fix", async (c) => {
|
|
1935
1998
|
try {
|
|
1936
1999
|
const body = await c.req.json();
|
|
@@ -2214,7 +2277,7 @@ var DiskPredictor = class {
|
|
|
2214
2277
|
};
|
|
2215
2278
|
|
|
2216
2279
|
// src/server/routes/disk.ts
|
|
2217
|
-
var
|
|
2280
|
+
var RANGE_MAP3 = {
|
|
2218
2281
|
"24h": 24 * 60 * 60 * 1e3,
|
|
2219
2282
|
"7d": 7 * 24 * 60 * 60 * 1e3,
|
|
2220
2283
|
"30d": 30 * 24 * 60 * 60 * 1e3
|
|
@@ -2274,10 +2337,22 @@ function registerDiskRoutes(app, pool, store) {
|
|
|
2274
2337
|
return c.json({ error: err.message }, 500);
|
|
2275
2338
|
}
|
|
2276
2339
|
});
|
|
2340
|
+
app.get("/api/disk/table-history/:table", (c) => {
|
|
2341
|
+
try {
|
|
2342
|
+
const table = c.req.param("table");
|
|
2343
|
+
const range = c.req.query("range") || "24h";
|
|
2344
|
+
const rangeMs = RANGE_MAP3[range] || RANGE_MAP3["24h"];
|
|
2345
|
+
const now = Date.now();
|
|
2346
|
+
const data = store.query(`table_size:${table}`, now - rangeMs, now);
|
|
2347
|
+
return c.json(data);
|
|
2348
|
+
} catch (err) {
|
|
2349
|
+
return c.json({ error: err.message }, 500);
|
|
2350
|
+
}
|
|
2351
|
+
});
|
|
2277
2352
|
app.get("/api/disk/history", (c) => {
|
|
2278
2353
|
try {
|
|
2279
2354
|
const range = c.req.query("range") || "24h";
|
|
2280
|
-
const rangeMs =
|
|
2355
|
+
const rangeMs = RANGE_MAP3[range] || RANGE_MAP3["24h"];
|
|
2281
2356
|
const now = Date.now();
|
|
2282
2357
|
const data = store.query("db_size_bytes", now - rangeMs, now);
|
|
2283
2358
|
return c.json(data);
|
|
@@ -2476,7 +2551,7 @@ var QueryStatsStore = class {
|
|
|
2476
2551
|
};
|
|
2477
2552
|
|
|
2478
2553
|
// src/server/routes/query-stats.ts
|
|
2479
|
-
var
|
|
2554
|
+
var RANGE_MAP4 = {
|
|
2480
2555
|
"1h": 60 * 60 * 1e3,
|
|
2481
2556
|
"6h": 6 * 60 * 60 * 1e3,
|
|
2482
2557
|
"24h": 24 * 60 * 60 * 1e3,
|
|
@@ -2488,7 +2563,7 @@ function registerQueryStatsRoutes(app, store) {
|
|
|
2488
2563
|
const range = c.req.query("range") || "1h";
|
|
2489
2564
|
const orderBy = c.req.query("orderBy") || "total_time";
|
|
2490
2565
|
const limit = parseInt(c.req.query("limit") || "20", 10);
|
|
2491
|
-
const rangeMs =
|
|
2566
|
+
const rangeMs = RANGE_MAP4[range] || RANGE_MAP4["1h"];
|
|
2492
2567
|
const now = Date.now();
|
|
2493
2568
|
const data = store.getTopQueries(now - rangeMs, now, orderBy, limit);
|
|
2494
2569
|
return c.json(data);
|
|
@@ -2500,7 +2575,7 @@ function registerQueryStatsRoutes(app, store) {
|
|
|
2500
2575
|
try {
|
|
2501
2576
|
const queryid = c.req.param("queryid");
|
|
2502
2577
|
const range = c.req.query("range") || "1h";
|
|
2503
|
-
const rangeMs =
|
|
2578
|
+
const rangeMs = RANGE_MAP4[range] || RANGE_MAP4["1h"];
|
|
2504
2579
|
const now = Date.now();
|
|
2505
2580
|
const data = store.getTrend(queryid, now - rangeMs, now);
|
|
2506
2581
|
return c.json(data);
|
|
@@ -2510,6 +2585,74 @@ function registerQueryStatsRoutes(app, store) {
|
|
|
2510
2585
|
});
|
|
2511
2586
|
}
|
|
2512
2587
|
|
|
2588
|
+
// src/server/routes/export.ts
|
|
2589
|
+
init_advisor();
|
|
2590
|
+
function registerExportRoutes(app, pool, longQueryThreshold) {
|
|
2591
|
+
app.get("/api/export", async (c) => {
|
|
2592
|
+
const format = c.req.query("format") || "json";
|
|
2593
|
+
try {
|
|
2594
|
+
const [overview, advisor] = await Promise.all([
|
|
2595
|
+
getOverview(pool),
|
|
2596
|
+
getAdvisorReport(pool, longQueryThreshold)
|
|
2597
|
+
]);
|
|
2598
|
+
if (format === "md") {
|
|
2599
|
+
const lines = [];
|
|
2600
|
+
lines.push(`# pg-dash Health Report`);
|
|
2601
|
+
lines.push(`
|
|
2602
|
+
Generated: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
2603
|
+
`);
|
|
2604
|
+
lines.push(`## Overview
|
|
2605
|
+
`);
|
|
2606
|
+
lines.push(`- **PostgreSQL**: ${overview.version}`);
|
|
2607
|
+
lines.push(`- **Database Size**: ${overview.dbSize}`);
|
|
2608
|
+
lines.push(`- **Connections**: ${overview.connections.active} active / ${overview.connections.idle} idle / ${overview.connections.max} max`);
|
|
2609
|
+
lines.push(`
|
|
2610
|
+
## Health Score: ${advisor.score}/100 (Grade: ${advisor.grade})
|
|
2611
|
+
`);
|
|
2612
|
+
lines.push(`### Category Breakdown
|
|
2613
|
+
`);
|
|
2614
|
+
lines.push(`| Category | Grade | Score | Issues |`);
|
|
2615
|
+
lines.push(`|----------|-------|-------|--------|`);
|
|
2616
|
+
for (const [cat, b] of Object.entries(advisor.breakdown)) {
|
|
2617
|
+
lines.push(`| ${cat} | ${b.grade} | ${b.score}/100 | ${b.count} |`);
|
|
2618
|
+
}
|
|
2619
|
+
if (advisor.issues.length > 0) {
|
|
2620
|
+
lines.push(`
|
|
2621
|
+
### Issues (${advisor.issues.length})
|
|
2622
|
+
`);
|
|
2623
|
+
for (const issue of advisor.issues) {
|
|
2624
|
+
const icon = issue.severity === "critical" ? "\u{1F534}" : issue.severity === "warning" ? "\u{1F7E1}" : "\u{1F535}";
|
|
2625
|
+
lines.push(`#### ${icon} [${issue.severity}] ${issue.title}
|
|
2626
|
+
`);
|
|
2627
|
+
lines.push(`${issue.description}
|
|
2628
|
+
`);
|
|
2629
|
+
lines.push(`**Impact**: ${issue.impact}
|
|
2630
|
+
`);
|
|
2631
|
+
lines.push(`**Fix**:
|
|
2632
|
+
\`\`\`sql
|
|
2633
|
+
${issue.fix}
|
|
2634
|
+
\`\`\`
|
|
2635
|
+
`);
|
|
2636
|
+
}
|
|
2637
|
+
} else {
|
|
2638
|
+
lines.push(`
|
|
2639
|
+
\u2705 No issues found!
|
|
2640
|
+
`);
|
|
2641
|
+
}
|
|
2642
|
+
const md = lines.join("\n");
|
|
2643
|
+
c.header("Content-Type", "text/markdown; charset=utf-8");
|
|
2644
|
+
c.header("Content-Disposition", `attachment; filename="pg-dash-report-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.md"`);
|
|
2645
|
+
return c.body(md);
|
|
2646
|
+
}
|
|
2647
|
+
const data = { overview, advisor, exportedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2648
|
+
c.header("Content-Disposition", `attachment; filename="pg-dash-report-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.json"`);
|
|
2649
|
+
return c.json(data);
|
|
2650
|
+
} catch (err) {
|
|
2651
|
+
return c.json({ error: err.message }, 500);
|
|
2652
|
+
}
|
|
2653
|
+
});
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2513
2656
|
// src/server/index.ts
|
|
2514
2657
|
import Database3 from "better-sqlite3";
|
|
2515
2658
|
import { WebSocketServer, WebSocket } from "ws";
|
|
@@ -2610,12 +2753,13 @@ async function startServer(opts) {
|
|
|
2610
2753
|
registerOverviewRoutes(app, pool);
|
|
2611
2754
|
registerMetricsRoutes(app, store, collector);
|
|
2612
2755
|
registerActivityRoutes(app, pool);
|
|
2613
|
-
registerAdvisorRoutes(app, pool, longQueryThreshold);
|
|
2756
|
+
registerAdvisorRoutes(app, pool, longQueryThreshold, store);
|
|
2614
2757
|
registerSchemaRoutes(app, pool, schemaTracker);
|
|
2615
2758
|
registerAlertsRoutes(app, alertManager);
|
|
2616
2759
|
registerExplainRoutes(app, pool);
|
|
2617
2760
|
registerDiskRoutes(app, pool, store);
|
|
2618
2761
|
registerQueryStatsRoutes(app, queryStatsStore);
|
|
2762
|
+
registerExportRoutes(app, pool, longQueryThreshold);
|
|
2619
2763
|
const uiPath = path3.resolve(__dirname, "ui");
|
|
2620
2764
|
const MIME_TYPES = {
|
|
2621
2765
|
".html": "text/html",
|
|
@@ -2763,6 +2907,7 @@ async function startServer(opts) {
|
|
|
2763
2907
|
try {
|
|
2764
2908
|
const report = await getAdvisorReport(pool, longQueryThreshold);
|
|
2765
2909
|
alertMetrics.health_score = report.score;
|
|
2910
|
+
store.insert("health_score", report.score);
|
|
2766
2911
|
} catch (err) {
|
|
2767
2912
|
console.error("[alerts] Error checking health score:", err.message);
|
|
2768
2913
|
}
|
|
@@ -2918,7 +3063,7 @@ Options:
|
|
|
2918
3063
|
--query-stats-interval <min> Query stats snapshot interval in minutes (default: 5)
|
|
2919
3064
|
--long-query-threshold <min> Long query threshold in minutes (default: 5)
|
|
2920
3065
|
--threshold <score> Health score threshold for check command (default: 70)
|
|
2921
|
-
-f, --format <fmt> Output format: text|json (default: text)
|
|
3066
|
+
-f, --format <fmt> Output format: text|json|md (default: text)
|
|
2922
3067
|
-v, --version Show version
|
|
2923
3068
|
-h, --help Show this help
|
|
2924
3069
|
|
|
@@ -2957,6 +3102,38 @@ if (subcommand === "check") {
|
|
|
2957
3102
|
const report = await getAdvisorReport2(pool, lqt);
|
|
2958
3103
|
if (format === "json") {
|
|
2959
3104
|
console.log(JSON.stringify(report, null, 2));
|
|
3105
|
+
} else if (format === "md") {
|
|
3106
|
+
console.log(`# pg-dash Health Report
|
|
3107
|
+
`);
|
|
3108
|
+
console.log(`Generated: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
3109
|
+
`);
|
|
3110
|
+
console.log(`## Health Score: ${report.score}/100 (Grade: ${report.grade})
|
|
3111
|
+
`);
|
|
3112
|
+
console.log(`| Category | Grade | Score | Issues |`);
|
|
3113
|
+
console.log(`|----------|-------|-------|--------|`);
|
|
3114
|
+
for (const [cat, b] of Object.entries(report.breakdown)) {
|
|
3115
|
+
console.log(`| ${cat} | ${b.grade} | ${b.score}/100 | ${b.count} |`);
|
|
3116
|
+
}
|
|
3117
|
+
if (report.issues.length > 0) {
|
|
3118
|
+
console.log(`
|
|
3119
|
+
### Issues (${report.issues.length})
|
|
3120
|
+
`);
|
|
3121
|
+
for (const issue of report.issues) {
|
|
3122
|
+
const icon = issue.severity === "critical" ? "\u{1F534}" : issue.severity === "warning" ? "\u{1F7E1}" : "\u{1F535}";
|
|
3123
|
+
console.log(`#### ${icon} [${issue.severity}] ${issue.title}
|
|
3124
|
+
`);
|
|
3125
|
+
console.log(`${issue.description}
|
|
3126
|
+
`);
|
|
3127
|
+
console.log(`**Fix**:
|
|
3128
|
+
\`\`\`sql
|
|
3129
|
+
${issue.fix}
|
|
3130
|
+
\`\`\`
|
|
3131
|
+
`);
|
|
3132
|
+
}
|
|
3133
|
+
} else {
|
|
3134
|
+
console.log(`
|
|
3135
|
+
\u2705 No issues found!`);
|
|
3136
|
+
}
|
|
2960
3137
|
} else {
|
|
2961
3138
|
console.log(`
|
|
2962
3139
|
Health Score: ${report.score}/100 (Grade: ${report.grade})
|