@indiekitai/pg-dash 0.3.1 → 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
@@ -14,19 +14,32 @@ var advisor_exports = {};
14
14
  __export(advisor_exports, {
15
15
  computeAdvisorScore: () => computeAdvisorScore,
16
16
  getAdvisorReport: () => getAdvisorReport,
17
+ getIgnoredIssues: () => getIgnoredIssues,
17
18
  gradeFromScore: () => gradeFromScore,
18
- isSafeFix: () => isSafeFix
19
+ ignoreIssue: () => ignoreIssue,
20
+ isSafeFix: () => isSafeFix,
21
+ unignoreIssue: () => unignoreIssue
19
22
  });
23
+ import Database from "better-sqlite3";
24
+ import path from "path";
25
+ import os from "os";
26
+ import fs from "fs";
20
27
  function computeAdvisorScore(issues) {
21
28
  let score = 100;
29
+ const deductions = { critical: 0, warning: 0, info: 0 };
22
30
  const counts = { critical: 0, warning: 0, info: 0 };
23
31
  for (const issue of issues) {
24
32
  counts[issue.severity]++;
25
33
  const n = counts[issue.severity];
26
34
  const weight = SEVERITY_WEIGHT[issue.severity];
27
- if (n <= 5) score -= weight;
28
- else if (n <= 15) score -= weight * 0.5;
29
- else score -= weight * 0.25;
35
+ let penalty;
36
+ if (n <= 3) penalty = weight;
37
+ else if (n <= 10) penalty = weight * 0.5;
38
+ else penalty = weight * 0.25;
39
+ deductions[issue.severity] += penalty;
40
+ }
41
+ for (const sev of ["critical", "warning", "info"]) {
42
+ score -= Math.min(deductions[sev], MAX_DEDUCTION[sev]);
30
43
  }
31
44
  return Math.max(0, Math.min(100, Math.round(score)));
32
45
  }
@@ -50,7 +63,10 @@ function computeBreakdown(issues) {
50
63
  async function getAdvisorReport(pool, longQueryThreshold = 5) {
51
64
  const client = await pool.connect();
52
65
  const issues = [];
66
+ const skipped = [];
53
67
  try {
68
+ const versionResult = await client.query("SHOW server_version_num");
69
+ const pgVersion = parseInt(versionResult.rows[0].server_version_num);
54
70
  try {
55
71
  const r = await client.query(`
56
72
  SELECT schemaname, relname, seq_scan, seq_tup_read, n_live_tup,
@@ -75,6 +91,7 @@ CREATE INDEX CONCURRENTLY idx_${row.relname}_<column> ON ${row.schemaname}.${row
75
91
  }
76
92
  } catch (err) {
77
93
  console.error("[advisor] Error checking seq scans:", err.message);
94
+ skipped.push("seq scans: " + err.message);
78
95
  }
79
96
  try {
80
97
  const r = await client.query(`
@@ -103,6 +120,7 @@ CREATE INDEX CONCURRENTLY idx_${row.relname}_<column> ON ${row.schemaname}.${row
103
120
  }
104
121
  } catch (err) {
105
122
  console.error("[advisor] Error checking bloated indexes:", err.message);
123
+ skipped.push("bloated indexes: " + err.message);
106
124
  }
107
125
  try {
108
126
  const r = await client.query(`
@@ -128,6 +146,7 @@ CREATE INDEX CONCURRENTLY idx_${row.relname}_<column> ON ${row.schemaname}.${row
128
146
  }
129
147
  } catch (err) {
130
148
  console.error("[advisor] Error checking table bloat:", err.message);
149
+ skipped.push("table bloat: " + err.message);
131
150
  }
132
151
  try {
133
152
  const r = await client.query(`
@@ -157,6 +176,7 @@ SHOW shared_buffers;`,
157
176
  }
158
177
  } catch (err) {
159
178
  console.error("[advisor] Error checking cache efficiency:", err.message);
179
+ skipped.push("cache efficiency: " + err.message);
160
180
  }
161
181
  try {
162
182
  const extCheck = await client.query("SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'");
@@ -185,6 +205,7 @@ SHOW shared_buffers;`,
185
205
  }
186
206
  } catch (err) {
187
207
  console.error("[advisor] Error checking slow queries:", err.message);
208
+ skipped.push("slow queries: " + err.message);
188
209
  }
189
210
  try {
190
211
  const r = await client.query(`
@@ -210,6 +231,7 @@ SHOW shared_buffers;`,
210
231
  }
211
232
  } catch (err) {
212
233
  console.error("[advisor] Error checking vacuum overdue:", err.message);
234
+ skipped.push("vacuum overdue: " + err.message);
213
235
  }
214
236
  try {
215
237
  const r = await client.query(`
@@ -238,6 +260,7 @@ SHOW shared_buffers;`,
238
260
  }
239
261
  } catch (err) {
240
262
  console.error("[advisor] Error checking analyze overdue:", err.message);
263
+ skipped.push("analyze overdue: " + err.message);
241
264
  }
242
265
  try {
243
266
  const r = await client.query(`
@@ -273,6 +296,7 @@ SHOW shared_buffers;`,
273
296
  }
274
297
  } catch (err) {
275
298
  console.error("[advisor] Error checking xid wraparound:", err.message);
299
+ skipped.push("xid wraparound: " + err.message);
276
300
  }
277
301
  try {
278
302
  const r = await client.query(`
@@ -299,6 +323,7 @@ SHOW shared_buffers;`,
299
323
  }
300
324
  } catch (err) {
301
325
  console.error("[advisor] Error checking idle connections:", err.message);
326
+ skipped.push("idle connections: " + err.message);
302
327
  }
303
328
  try {
304
329
  const r = await client.query(`
@@ -324,6 +349,7 @@ SHOW shared_buffers;`,
324
349
  }
325
350
  } catch (err) {
326
351
  console.error("[advisor] Error checking missing primary keys:", err.message);
352
+ skipped.push("missing primary keys: " + err.message);
327
353
  }
328
354
  try {
329
355
  const r = await client.query(`
@@ -350,6 +376,7 @@ SHOW shared_buffers;`,
350
376
  }
351
377
  } catch (err) {
352
378
  console.error("[advisor] Error checking unused indexes:", err.message);
379
+ skipped.push("unused indexes: " + err.message);
353
380
  }
354
381
  try {
355
382
  const r = await client.query(`
@@ -375,6 +402,7 @@ DROP INDEX CONCURRENTLY ${row.indexes.slice(1).join(";\nDROP INDEX CONCURRENTLY
375
402
  }
376
403
  } catch (err) {
377
404
  console.error("[advisor] Error checking duplicate indexes:", err.message);
405
+ skipped.push("duplicate indexes: " + err.message);
378
406
  }
379
407
  try {
380
408
  const r = await client.query(`
@@ -405,6 +433,7 @@ DROP INDEX CONCURRENTLY ${row.indexes.slice(1).join(";\nDROP INDEX CONCURRENTLY
405
433
  }
406
434
  } catch (err) {
407
435
  console.error("[advisor] Error checking missing FK indexes:", err.message);
436
+ skipped.push("missing FK indexes: " + err.message);
408
437
  }
409
438
  try {
410
439
  const r = await client.query(`
@@ -440,6 +469,7 @@ DROP INDEX CONCURRENTLY ${row.indexes.slice(1).join(";\nDROP INDEX CONCURRENTLY
440
469
  }
441
470
  } catch (err) {
442
471
  console.error("[advisor] Error checking locks:", err.message);
472
+ skipped.push("locks: " + err.message);
443
473
  }
444
474
  try {
445
475
  const r = await client.query(`
@@ -463,13 +493,15 @@ SELECT * FROM pg_stat_replication;`,
463
493
  }
464
494
  } catch (err) {
465
495
  console.error("[advisor] Error checking replication lag:", err.message);
496
+ skipped.push("replication lag: " + err.message);
466
497
  }
467
498
  try {
499
+ const checkpointView = pgVersion >= 17e4 ? "pg_stat_checkpointer" : "pg_stat_bgwriter";
468
500
  const r = await client.query(`
469
501
  SELECT checkpoints_req, checkpoints_timed,
470
502
  CASE WHEN (checkpoints_req + checkpoints_timed) = 0 THEN 0
471
503
  ELSE round(checkpoints_req::numeric / (checkpoints_req + checkpoints_timed) * 100, 1) END AS req_pct
472
- FROM pg_stat_bgwriter
504
+ FROM ${checkpointView}
473
505
  `);
474
506
  const reqPct = parseFloat(r.rows[0]?.req_pct ?? "0");
475
507
  if (reqPct > 50) {
@@ -488,6 +520,7 @@ SELECT pg_reload_conf();`,
488
520
  }
489
521
  } catch (err) {
490
522
  console.error("[advisor] Error checking checkpoint frequency:", err.message);
523
+ skipped.push("checkpoint frequency: " + err.message);
491
524
  }
492
525
  try {
493
526
  const r = await client.query(`SELECT setting FROM pg_settings WHERE name = 'autovacuum'`);
@@ -506,6 +539,7 @@ SELECT pg_reload_conf();`,
506
539
  }
507
540
  } catch (err) {
508
541
  console.error("[advisor] Error checking autovacuum:", err.message);
542
+ skipped.push("autovacuum: " + err.message);
509
543
  }
510
544
  try {
511
545
  const sbRes = await client.query(`SELECT setting, unit FROM pg_settings WHERE name = 'shared_buffers'`);
@@ -529,6 +563,7 @@ SELECT pg_reload_conf();`,
529
563
  }
530
564
  } catch (err) {
531
565
  console.error("[advisor] Error checking shared_buffers:", err.message);
566
+ skipped.push("shared_buffers: " + err.message);
532
567
  }
533
568
  try {
534
569
  const r = await client.query(`SELECT setting, unit FROM pg_settings WHERE name = 'work_mem'`);
@@ -548,6 +583,7 @@ SELECT pg_reload_conf();`,
548
583
  }
549
584
  } catch (err) {
550
585
  console.error("[advisor] Error checking work_mem:", err.message);
586
+ skipped.push("work_mem: " + err.message);
551
587
  }
552
588
  try {
553
589
  const r = await client.query(`
@@ -573,6 +609,7 @@ SELECT pg_reload_conf();`,
573
609
  }
574
610
  } catch (err) {
575
611
  console.error("[advisor] Error checking superuser connections:", err.message);
612
+ skipped.push("superuser connections: " + err.message);
576
613
  }
577
614
  try {
578
615
  const r = await client.query(`SELECT setting FROM pg_settings WHERE name = 'ssl'`);
@@ -594,6 +631,7 @@ SELECT pg_reload_conf();`,
594
631
  }
595
632
  } catch (err) {
596
633
  console.error("[advisor] Error checking SSL check:", err.message);
634
+ skipped.push("SSL check: " + err.message);
597
635
  }
598
636
  try {
599
637
  const r = await client.query(`
@@ -617,18 +655,74 @@ SELECT pg_reload_conf();`,
617
655
  }
618
656
  } catch (err) {
619
657
  console.error("[advisor] Error checking trust auth:", err.message);
658
+ skipped.push("trust auth: " + err.message);
659
+ }
660
+ const ignoredIds = getIgnoredIssues();
661
+ const ignoredSet = new Set(ignoredIds);
662
+ const activeIssues = issues.filter((i) => !ignoredSet.has(i.id));
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 });
620
685
  }
621
- const score = computeAdvisorScore(issues);
686
+ const score = computeAdvisorScore(activeIssues);
622
687
  return {
623
688
  score,
624
689
  grade: gradeFromScore(score),
625
- issues,
626
- breakdown: computeBreakdown(issues)
690
+ issues: activeIssues,
691
+ breakdown: computeBreakdown(activeIssues),
692
+ skipped,
693
+ ignoredCount,
694
+ batchFixes
627
695
  };
628
696
  } finally {
629
697
  client.release();
630
698
  }
631
699
  }
700
+ function getIgnoredDb() {
701
+ if (_ignoredDb) return _ignoredDb;
702
+ const dataDir = process.env.PG_DASH_DATA_DIR || path.join(os.homedir(), ".pg-dash");
703
+ fs.mkdirSync(dataDir, { recursive: true });
704
+ const dbPath = path.join(dataDir, "alerts.db");
705
+ _ignoredDb = new Database(dbPath);
706
+ _ignoredDb.pragma("journal_mode = WAL");
707
+ _ignoredDb.exec("CREATE TABLE IF NOT EXISTS ignored_issues (issue_id TEXT PRIMARY KEY, ignored_at INTEGER)");
708
+ return _ignoredDb;
709
+ }
710
+ function getIgnoredIssues() {
711
+ try {
712
+ const db = getIgnoredDb();
713
+ return db.prepare("SELECT issue_id FROM ignored_issues").all().map((r) => r.issue_id);
714
+ } catch {
715
+ return [];
716
+ }
717
+ }
718
+ function ignoreIssue(issueId) {
719
+ const db = getIgnoredDb();
720
+ db.prepare("INSERT OR REPLACE INTO ignored_issues (issue_id, ignored_at) VALUES (?, ?)").run(issueId, Date.now());
721
+ }
722
+ function unignoreIssue(issueId) {
723
+ const db = getIgnoredDb();
724
+ db.prepare("DELETE FROM ignored_issues WHERE issue_id = ?").run(issueId);
725
+ }
632
726
  function isSafeFix(sql) {
633
727
  const trimmed = sql.trim();
634
728
  if (!trimmed) return false;
@@ -650,11 +744,13 @@ function isSafeFix(sql) {
650
744
  ];
651
745
  return ALLOWED_PREFIXES.some((p) => upper.startsWith(p));
652
746
  }
653
- var SEVERITY_WEIGHT;
747
+ var SEVERITY_WEIGHT, MAX_DEDUCTION, _ignoredDb;
654
748
  var init_advisor = __esm({
655
749
  "src/server/advisor.ts"() {
656
750
  "use strict";
657
- SEVERITY_WEIGHT = { critical: 20, warning: 8, info: 3 };
751
+ SEVERITY_WEIGHT = { critical: 15, warning: 5, info: 1 };
752
+ MAX_DEDUCTION = { critical: 60, warning: 30, info: 10 };
753
+ _ignoredDb = null;
658
754
  }
659
755
  });
660
756
 
@@ -663,9 +759,9 @@ import { parseArgs } from "util";
663
759
 
664
760
  // src/server/index.ts
665
761
  import { Hono } from "hono";
666
- import path2 from "path";
667
- import fs2 from "fs";
668
- import os2 from "os";
762
+ import path3 from "path";
763
+ import fs3 from "fs";
764
+ import os3 from "os";
669
765
  import { fileURLToPath } from "url";
670
766
  import { Pool } from "pg";
671
767
 
@@ -675,7 +771,7 @@ async function getOverview(pool) {
675
771
  try {
676
772
  const version = await client.query("SHOW server_version");
677
773
  const uptime = await client.query(
678
- "SELECT now() - pg_postmaster_start_time() AS uptime"
774
+ `SELECT to_char(now() - pg_postmaster_start_time(), 'DD "d" HH24 "h" MI "m"') AS uptime`
679
775
  );
680
776
  const dbSize = await client.query(
681
777
  "SELECT pg_size_pretty(pg_database_size(current_database())) AS size"
@@ -781,24 +877,24 @@ async function getActivity(pool) {
781
877
  init_advisor();
782
878
 
783
879
  // src/server/timeseries.ts
784
- import Database from "better-sqlite3";
785
- import path from "path";
786
- import os from "os";
787
- import fs from "fs";
788
- var DEFAULT_DIR = path.join(os.homedir(), ".pg-dash");
880
+ import Database2 from "better-sqlite3";
881
+ import path2 from "path";
882
+ import os2 from "os";
883
+ import fs2 from "fs";
884
+ var DEFAULT_DIR = path2.join(os2.homedir(), ".pg-dash");
789
885
  var DEFAULT_RETENTION_DAYS = 7;
790
886
  var TimeseriesStore = class {
791
887
  db;
792
888
  insertStmt;
793
889
  retentionMs;
794
890
  constructor(dbOrDir, retentionDays = DEFAULT_RETENTION_DAYS) {
795
- if (dbOrDir instanceof Database) {
891
+ if (dbOrDir instanceof Database2) {
796
892
  this.db = dbOrDir;
797
893
  } else {
798
894
  const dir = dbOrDir || DEFAULT_DIR;
799
- fs.mkdirSync(dir, { recursive: true });
800
- const dbPath = path.join(dir, "metrics.db");
801
- this.db = new Database(dbPath);
895
+ fs2.mkdirSync(dir, { recursive: true });
896
+ const dbPath = path2.join(dir, "metrics.db");
897
+ this.db = new Database2(dbPath);
802
898
  }
803
899
  this.retentionMs = retentionDays * 24 * 60 * 60 * 1e3;
804
900
  this.db.pragma("journal_mode = WAL");
@@ -870,6 +966,7 @@ var Collector = class extends EventEmitter {
870
966
  pruneTimer = null;
871
967
  prev = null;
872
968
  lastSnapshot = {};
969
+ collectCount = 0;
873
970
  start() {
874
971
  this.collect().catch((err) => console.error("[collector] Initial collection failed:", err));
875
972
  this.timer = setInterval(() => {
@@ -972,6 +1069,28 @@ var Collector = class extends EventEmitter {
972
1069
  console.error("[collector] Error collecting metrics:", err.message);
973
1070
  return snapshot;
974
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
+ }
975
1094
  const points = Object.entries(snapshot).map(([metric, value]) => ({
976
1095
  timestamp: now,
977
1096
  metric,
@@ -1823,7 +1942,12 @@ function registerActivityRoutes(app, pool) {
1823
1942
 
1824
1943
  // src/server/routes/advisor.ts
1825
1944
  init_advisor();
1826
- 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) {
1827
1951
  app.get("/api/advisor", async (c) => {
1828
1952
  try {
1829
1953
  return c.json(await getAdvisorReport(pool, longQueryThreshold));
@@ -1831,6 +1955,45 @@ function registerAdvisorRoutes(app, pool, longQueryThreshold) {
1831
1955
  return c.json({ error: err.message }, 500);
1832
1956
  }
1833
1957
  });
1958
+ app.get("/api/advisor/ignored", (c) => {
1959
+ try {
1960
+ return c.json(getIgnoredIssues());
1961
+ } catch (err) {
1962
+ return c.json({ error: err.message }, 500);
1963
+ }
1964
+ });
1965
+ app.post("/api/advisor/ignore", async (c) => {
1966
+ try {
1967
+ const body = await c.req.json();
1968
+ const issueId = body?.issueId;
1969
+ if (!issueId) return c.json({ error: "issueId required" }, 400);
1970
+ ignoreIssue(issueId);
1971
+ return c.json({ ok: true });
1972
+ } catch (err) {
1973
+ return c.json({ error: err.message }, 500);
1974
+ }
1975
+ });
1976
+ app.delete("/api/advisor/ignore/:issueId", (c) => {
1977
+ try {
1978
+ const issueId = c.req.param("issueId");
1979
+ unignoreIssue(issueId);
1980
+ return c.json({ ok: true });
1981
+ } catch (err) {
1982
+ return c.json({ error: err.message }, 500);
1983
+ }
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
+ });
1834
1997
  app.post("/api/fix", async (c) => {
1835
1998
  try {
1836
1999
  const body = await c.req.json();
@@ -2114,7 +2277,7 @@ var DiskPredictor = class {
2114
2277
  };
2115
2278
 
2116
2279
  // src/server/routes/disk.ts
2117
- var RANGE_MAP2 = {
2280
+ var RANGE_MAP3 = {
2118
2281
  "24h": 24 * 60 * 60 * 1e3,
2119
2282
  "7d": 7 * 24 * 60 * 60 * 1e3,
2120
2283
  "30d": 30 * 24 * 60 * 60 * 1e3
@@ -2174,10 +2337,22 @@ function registerDiskRoutes(app, pool, store) {
2174
2337
  return c.json({ error: err.message }, 500);
2175
2338
  }
2176
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
+ });
2177
2352
  app.get("/api/disk/history", (c) => {
2178
2353
  try {
2179
2354
  const range = c.req.query("range") || "24h";
2180
- const rangeMs = RANGE_MAP2[range] || RANGE_MAP2["24h"];
2355
+ const rangeMs = RANGE_MAP3[range] || RANGE_MAP3["24h"];
2181
2356
  const now = Date.now();
2182
2357
  const data = store.query("db_size_bytes", now - rangeMs, now);
2183
2358
  return c.json(data);
@@ -2376,7 +2551,7 @@ var QueryStatsStore = class {
2376
2551
  };
2377
2552
 
2378
2553
  // src/server/routes/query-stats.ts
2379
- var RANGE_MAP3 = {
2554
+ var RANGE_MAP4 = {
2380
2555
  "1h": 60 * 60 * 1e3,
2381
2556
  "6h": 6 * 60 * 60 * 1e3,
2382
2557
  "24h": 24 * 60 * 60 * 1e3,
@@ -2388,7 +2563,7 @@ function registerQueryStatsRoutes(app, store) {
2388
2563
  const range = c.req.query("range") || "1h";
2389
2564
  const orderBy = c.req.query("orderBy") || "total_time";
2390
2565
  const limit = parseInt(c.req.query("limit") || "20", 10);
2391
- const rangeMs = RANGE_MAP3[range] || RANGE_MAP3["1h"];
2566
+ const rangeMs = RANGE_MAP4[range] || RANGE_MAP4["1h"];
2392
2567
  const now = Date.now();
2393
2568
  const data = store.getTopQueries(now - rangeMs, now, orderBy, limit);
2394
2569
  return c.json(data);
@@ -2400,7 +2575,7 @@ function registerQueryStatsRoutes(app, store) {
2400
2575
  try {
2401
2576
  const queryid = c.req.param("queryid");
2402
2577
  const range = c.req.query("range") || "1h";
2403
- const rangeMs = RANGE_MAP3[range] || RANGE_MAP3["1h"];
2578
+ const rangeMs = RANGE_MAP4[range] || RANGE_MAP4["1h"];
2404
2579
  const now = Date.now();
2405
2580
  const data = store.getTrend(queryid, now - rangeMs, now);
2406
2581
  return c.json(data);
@@ -2410,11 +2585,79 @@ function registerQueryStatsRoutes(app, store) {
2410
2585
  });
2411
2586
  }
2412
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
+
2413
2656
  // src/server/index.ts
2414
- import Database2 from "better-sqlite3";
2657
+ import Database3 from "better-sqlite3";
2415
2658
  import { WebSocketServer, WebSocket } from "ws";
2416
2659
  import http from "http";
2417
- var __dirname = path2.dirname(fileURLToPath(import.meta.url));
2660
+ var __dirname = path3.dirname(fileURLToPath(import.meta.url));
2418
2661
  async function startServer(opts) {
2419
2662
  const pool = new Pool({ connectionString: opts.connectionString });
2420
2663
  try {
@@ -2442,25 +2685,25 @@ async function startServer(opts) {
2442
2685
  await pool.end();
2443
2686
  process.exit(0);
2444
2687
  }
2445
- const dataDir = opts.dataDir || path2.join(os2.homedir(), ".pg-dash");
2446
- fs2.mkdirSync(dataDir, { recursive: true });
2447
- const metricsDbPath = path2.join(dataDir, "metrics.db");
2448
- const metricsDb = new Database2(metricsDbPath);
2688
+ const dataDir = opts.dataDir || path3.join(os3.homedir(), ".pg-dash");
2689
+ fs3.mkdirSync(dataDir, { recursive: true });
2690
+ const metricsDbPath = path3.join(dataDir, "metrics.db");
2691
+ const metricsDb = new Database3(metricsDbPath);
2449
2692
  metricsDb.pragma("journal_mode = WAL");
2450
2693
  const store = new TimeseriesStore(metricsDb, opts.retentionDays);
2451
2694
  const intervalMs = (opts.interval || 30) * 1e3;
2452
2695
  const collector = new Collector(pool, store, intervalMs);
2453
2696
  console.log(` Collecting metrics every ${intervalMs / 1e3}s...`);
2454
2697
  collector.start();
2455
- const schemaDbPath = path2.join(dataDir, "schema.db");
2456
- const schemaDb = new Database2(schemaDbPath);
2698
+ const schemaDbPath = path3.join(dataDir, "schema.db");
2699
+ const schemaDb = new Database3(schemaDbPath);
2457
2700
  schemaDb.pragma("journal_mode = WAL");
2458
2701
  const snapshotIntervalMs = (opts.snapshotInterval || 6) * 60 * 60 * 1e3;
2459
2702
  const schemaTracker = new SchemaTracker(schemaDb, pool, snapshotIntervalMs);
2460
2703
  schemaTracker.start();
2461
2704
  console.log(" Schema change tracking enabled");
2462
- const alertsDbPath = path2.join(dataDir, "alerts.db");
2463
- const alertsDb = new Database2(alertsDbPath);
2705
+ const alertsDbPath = path3.join(dataDir, "alerts.db");
2706
+ const alertsDb = new Database3(alertsDbPath);
2464
2707
  alertsDb.pragma("journal_mode = WAL");
2465
2708
  const alertManager = new AlertManager(alertsDb, opts.webhook);
2466
2709
  console.log(" Alert monitoring enabled");
@@ -2510,13 +2753,14 @@ async function startServer(opts) {
2510
2753
  registerOverviewRoutes(app, pool);
2511
2754
  registerMetricsRoutes(app, store, collector);
2512
2755
  registerActivityRoutes(app, pool);
2513
- registerAdvisorRoutes(app, pool, longQueryThreshold);
2756
+ registerAdvisorRoutes(app, pool, longQueryThreshold, store);
2514
2757
  registerSchemaRoutes(app, pool, schemaTracker);
2515
2758
  registerAlertsRoutes(app, alertManager);
2516
2759
  registerExplainRoutes(app, pool);
2517
2760
  registerDiskRoutes(app, pool, store);
2518
2761
  registerQueryStatsRoutes(app, queryStatsStore);
2519
- const uiPath = path2.resolve(__dirname, "ui");
2762
+ registerExportRoutes(app, pool, longQueryThreshold);
2763
+ const uiPath = path3.resolve(__dirname, "ui");
2520
2764
  const MIME_TYPES = {
2521
2765
  ".html": "text/html",
2522
2766
  ".js": "application/javascript",
@@ -2531,15 +2775,15 @@ async function startServer(opts) {
2531
2775
  };
2532
2776
  app.get("/*", async (c) => {
2533
2777
  const urlPath = c.req.path === "/" ? "/index.html" : c.req.path;
2534
- const filePath = path2.join(uiPath, urlPath);
2778
+ const filePath = path3.join(uiPath, urlPath);
2535
2779
  try {
2536
- const content = await fs2.promises.readFile(filePath);
2537
- const ext = path2.extname(filePath);
2780
+ const content = await fs3.promises.readFile(filePath);
2781
+ const ext = path3.extname(filePath);
2538
2782
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
2539
2783
  return new Response(content, { headers: { "content-type": contentType } });
2540
2784
  } catch {
2541
2785
  try {
2542
- const html = await fs2.promises.readFile(path2.join(uiPath, "index.html"));
2786
+ const html = await fs3.promises.readFile(path3.join(uiPath, "index.html"));
2543
2787
  return new Response(html, { headers: { "content-type": "text/html" } });
2544
2788
  } catch (err) {
2545
2789
  console.error("[static] Error reading index.html:", err.message);
@@ -2663,6 +2907,7 @@ async function startServer(opts) {
2663
2907
  try {
2664
2908
  const report = await getAdvisorReport(pool, longQueryThreshold);
2665
2909
  alertMetrics.health_score = report.score;
2910
+ store.insert("health_score", report.score);
2666
2911
  } catch (err) {
2667
2912
  console.error("[alerts] Error checking health score:", err.message);
2668
2913
  }
@@ -2738,8 +2983,8 @@ async function startServer(opts) {
2738
2983
  }
2739
2984
 
2740
2985
  // src/cli.ts
2741
- import fs3 from "fs";
2742
- import path3 from "path";
2986
+ import fs4 from "fs";
2987
+ import path4 from "path";
2743
2988
  import { fileURLToPath as fileURLToPath2 } from "url";
2744
2989
  process.on("uncaughtException", (err) => {
2745
2990
  console.error("Uncaught exception:", err);
@@ -2778,8 +3023,8 @@ var { values, positionals } = parseArgs({
2778
3023
  });
2779
3024
  if (values.version) {
2780
3025
  try {
2781
- const __dirname2 = path3.dirname(fileURLToPath2(import.meta.url));
2782
- const pkg = JSON.parse(fs3.readFileSync(path3.resolve(__dirname2, "../package.json"), "utf-8"));
3026
+ const __dirname2 = path4.dirname(fileURLToPath2(import.meta.url));
3027
+ const pkg = JSON.parse(fs4.readFileSync(path4.resolve(__dirname2, "../package.json"), "utf-8"));
2783
3028
  console.log(`pg-dash v${pkg.version}`);
2784
3029
  } catch {
2785
3030
  console.log("pg-dash v0.1.0");
@@ -2818,7 +3063,7 @@ Options:
2818
3063
  --query-stats-interval <min> Query stats snapshot interval in minutes (default: 5)
2819
3064
  --long-query-threshold <min> Long query threshold in minutes (default: 5)
2820
3065
  --threshold <score> Health score threshold for check command (default: 70)
2821
- -f, --format <fmt> Output format: text|json (default: text)
3066
+ -f, --format <fmt> Output format: text|json|md (default: text)
2822
3067
  -v, --version Show version
2823
3068
  -h, --help Show this help
2824
3069
 
@@ -2857,6 +3102,38 @@ if (subcommand === "check") {
2857
3102
  const report = await getAdvisorReport2(pool, lqt);
2858
3103
  if (format === "json") {
2859
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
+ }
2860
3137
  } else {
2861
3138
  console.log(`
2862
3139
  Health Score: ${report.score}/100 (Grade: ${report.grade})
@@ -2884,14 +3161,14 @@ if (subcommand === "check") {
2884
3161
  }
2885
3162
  } else if (subcommand === "schema-diff") {
2886
3163
  const connectionString = resolveConnectionString(1);
2887
- const dataDir = values["data-dir"] || path3.join((await import("os")).homedir(), ".pg-dash");
2888
- const schemaDbPath = path3.join(dataDir, "schema.db");
2889
- if (!fs3.existsSync(schemaDbPath)) {
3164
+ const dataDir = values["data-dir"] || path4.join((await import("os")).homedir(), ".pg-dash");
3165
+ const schemaDbPath = path4.join(dataDir, "schema.db");
3166
+ if (!fs4.existsSync(schemaDbPath)) {
2890
3167
  console.error("No schema tracking data found. Run pg-dash server first to collect schema snapshots.");
2891
3168
  process.exit(1);
2892
3169
  }
2893
- const Database3 = (await import("better-sqlite3")).default;
2894
- const db = new Database3(schemaDbPath, { readonly: true });
3170
+ const Database4 = (await import("better-sqlite3")).default;
3171
+ const db = new Database4(schemaDbPath, { readonly: true });
2895
3172
  const changes = db.prepare("SELECT * FROM schema_changes ORDER BY timestamp DESC LIMIT 50").all();
2896
3173
  db.close();
2897
3174
  if (changes.length === 0) {