@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 +332 -55
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +104 -19
- 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-F2MaHZFy.css +0 -1
- package/dist/ui/assets/index-Lt6O9uL6.js +0 -33
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
else
|
|
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
|
|
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(
|
|
686
|
+
const score = computeAdvisorScore(activeIssues);
|
|
622
687
|
return {
|
|
623
688
|
score,
|
|
624
689
|
grade: gradeFromScore(score),
|
|
625
|
-
issues,
|
|
626
|
-
breakdown: computeBreakdown(
|
|
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:
|
|
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
|
|
667
|
-
import
|
|
668
|
-
import
|
|
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
|
-
|
|
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
|
|
785
|
-
import
|
|
786
|
-
import
|
|
787
|
-
import
|
|
788
|
-
var DEFAULT_DIR =
|
|
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
|
|
891
|
+
if (dbOrDir instanceof Database2) {
|
|
796
892
|
this.db = dbOrDir;
|
|
797
893
|
} else {
|
|
798
894
|
const dir = dbOrDir || DEFAULT_DIR;
|
|
799
|
-
|
|
800
|
-
const dbPath =
|
|
801
|
-
this.db = new
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
2657
|
+
import Database3 from "better-sqlite3";
|
|
2415
2658
|
import { WebSocketServer, WebSocket } from "ws";
|
|
2416
2659
|
import http from "http";
|
|
2417
|
-
var __dirname =
|
|
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 ||
|
|
2446
|
-
|
|
2447
|
-
const metricsDbPath =
|
|
2448
|
-
const metricsDb = new
|
|
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 =
|
|
2456
|
-
const schemaDb = new
|
|
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 =
|
|
2463
|
-
const alertsDb = new
|
|
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
|
-
|
|
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 =
|
|
2778
|
+
const filePath = path3.join(uiPath, urlPath);
|
|
2535
2779
|
try {
|
|
2536
|
-
const content = await
|
|
2537
|
-
const ext =
|
|
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
|
|
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
|
|
2742
|
-
import
|
|
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 =
|
|
2782
|
-
const pkg = JSON.parse(
|
|
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"] ||
|
|
2888
|
-
const schemaDbPath =
|
|
2889
|
-
if (!
|
|
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
|
|
2894
|
-
const db = new
|
|
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) {
|