@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 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
- function registerAdvisorRoutes(app, pool, longQueryThreshold) {
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 RANGE_MAP2 = {
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 = RANGE_MAP2[range] || RANGE_MAP2["24h"];
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 RANGE_MAP3 = {
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 = RANGE_MAP3[range] || RANGE_MAP3["1h"];
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 = RANGE_MAP3[range] || RANGE_MAP3["1h"];
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})