@indiekitai/pg-dash 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/cli.js +156 -55
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +83 -21
- package/dist/mcp.js.map +1 -1
- package/dist/ui/assets/index-BI4_c1SD.js +33 -0
- package/dist/ui/index.html +1 -1
- package/package.json +5 -2
- package/dist/ui/assets/index-Lt6O9uL6.js +0 -33
package/README.md
CHANGED
|
@@ -126,7 +126,7 @@ Options:
|
|
|
126
126
|
-i, --interval <sec> Collection interval (default: 30)
|
|
127
127
|
--threshold <score> Score threshold for check command (default: 70)
|
|
128
128
|
-f, --format <fmt> Output format: text|json (default: text)
|
|
129
|
-
--query-stats-interval <
|
|
129
|
+
--query-stats-interval <min> Query stats snapshot interval in minutes (default: 5)
|
|
130
130
|
--slack-webhook <url> Slack webhook URL for alert notifications
|
|
131
131
|
--discord-webhook <url> Discord webhook URL for alert notifications
|
|
132
132
|
-v, --version Show version
|
package/README.zh-CN.md
CHANGED
|
@@ -126,7 +126,7 @@ Options:
|
|
|
126
126
|
-i, --interval <sec> 采集间隔(默认:30)
|
|
127
127
|
--threshold <score> check 命令的分数阈值(默认:70)
|
|
128
128
|
-f, --format <fmt> 输出格式:text|json(默认:text)
|
|
129
|
-
--query-stats-interval <
|
|
129
|
+
--query-stats-interval <min> 查询统计快照间隔,单位分钟(默认:5)
|
|
130
130
|
--slack-webhook <url> Slack webhook URL,用于告警通知
|
|
131
131
|
--discord-webhook <url> Discord webhook URL,用于告警通知
|
|
132
132
|
-v, --version 显示版本
|
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(`
|
|
@@ -281,9 +305,9 @@ SHOW shared_buffers;`,
|
|
|
281
305
|
extract(epoch from now() - state_change)::int AS idle_seconds
|
|
282
306
|
FROM pg_stat_activity
|
|
283
307
|
WHERE state IN ('idle', 'idle in transaction')
|
|
284
|
-
AND now() - state_change > interval '
|
|
308
|
+
AND now() - state_change > $1 * interval '1 minute'
|
|
285
309
|
AND pid != pg_backend_pid()
|
|
286
|
-
|
|
310
|
+
`, [longQueryThreshold]);
|
|
287
311
|
for (const row of r.rows) {
|
|
288
312
|
const isIdleTx = row.state === "idle in transaction";
|
|
289
313
|
issues.push({
|
|
@@ -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,51 @@ 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);
|
|
620
659
|
}
|
|
621
|
-
const
|
|
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 score = computeAdvisorScore(activeIssues);
|
|
622
665
|
return {
|
|
623
666
|
score,
|
|
624
667
|
grade: gradeFromScore(score),
|
|
625
|
-
issues,
|
|
626
|
-
breakdown: computeBreakdown(
|
|
668
|
+
issues: activeIssues,
|
|
669
|
+
breakdown: computeBreakdown(activeIssues),
|
|
670
|
+
skipped,
|
|
671
|
+
ignoredCount
|
|
627
672
|
};
|
|
628
673
|
} finally {
|
|
629
674
|
client.release();
|
|
630
675
|
}
|
|
631
676
|
}
|
|
677
|
+
function getIgnoredDb() {
|
|
678
|
+
if (_ignoredDb) return _ignoredDb;
|
|
679
|
+
const dataDir = process.env.PG_DASH_DATA_DIR || path.join(os.homedir(), ".pg-dash");
|
|
680
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
681
|
+
const dbPath = path.join(dataDir, "alerts.db");
|
|
682
|
+
_ignoredDb = new Database(dbPath);
|
|
683
|
+
_ignoredDb.pragma("journal_mode = WAL");
|
|
684
|
+
_ignoredDb.exec("CREATE TABLE IF NOT EXISTS ignored_issues (issue_id TEXT PRIMARY KEY, ignored_at INTEGER)");
|
|
685
|
+
return _ignoredDb;
|
|
686
|
+
}
|
|
687
|
+
function getIgnoredIssues() {
|
|
688
|
+
try {
|
|
689
|
+
const db = getIgnoredDb();
|
|
690
|
+
return db.prepare("SELECT issue_id FROM ignored_issues").all().map((r) => r.issue_id);
|
|
691
|
+
} catch {
|
|
692
|
+
return [];
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function ignoreIssue(issueId) {
|
|
696
|
+
const db = getIgnoredDb();
|
|
697
|
+
db.prepare("INSERT OR REPLACE INTO ignored_issues (issue_id, ignored_at) VALUES (?, ?)").run(issueId, Date.now());
|
|
698
|
+
}
|
|
699
|
+
function unignoreIssue(issueId) {
|
|
700
|
+
const db = getIgnoredDb();
|
|
701
|
+
db.prepare("DELETE FROM ignored_issues WHERE issue_id = ?").run(issueId);
|
|
702
|
+
}
|
|
632
703
|
function isSafeFix(sql) {
|
|
633
704
|
const trimmed = sql.trim();
|
|
634
705
|
if (!trimmed) return false;
|
|
@@ -650,11 +721,13 @@ function isSafeFix(sql) {
|
|
|
650
721
|
];
|
|
651
722
|
return ALLOWED_PREFIXES.some((p) => upper.startsWith(p));
|
|
652
723
|
}
|
|
653
|
-
var SEVERITY_WEIGHT;
|
|
724
|
+
var SEVERITY_WEIGHT, MAX_DEDUCTION, _ignoredDb;
|
|
654
725
|
var init_advisor = __esm({
|
|
655
726
|
"src/server/advisor.ts"() {
|
|
656
727
|
"use strict";
|
|
657
|
-
SEVERITY_WEIGHT = { critical:
|
|
728
|
+
SEVERITY_WEIGHT = { critical: 15, warning: 5, info: 1 };
|
|
729
|
+
MAX_DEDUCTION = { critical: 60, warning: 30, info: 10 };
|
|
730
|
+
_ignoredDb = null;
|
|
658
731
|
}
|
|
659
732
|
});
|
|
660
733
|
|
|
@@ -663,9 +736,9 @@ import { parseArgs } from "util";
|
|
|
663
736
|
|
|
664
737
|
// src/server/index.ts
|
|
665
738
|
import { Hono } from "hono";
|
|
666
|
-
import
|
|
667
|
-
import
|
|
668
|
-
import
|
|
739
|
+
import path3 from "path";
|
|
740
|
+
import fs3 from "fs";
|
|
741
|
+
import os3 from "os";
|
|
669
742
|
import { fileURLToPath } from "url";
|
|
670
743
|
import { Pool } from "pg";
|
|
671
744
|
|
|
@@ -675,7 +748,7 @@ async function getOverview(pool) {
|
|
|
675
748
|
try {
|
|
676
749
|
const version = await client.query("SHOW server_version");
|
|
677
750
|
const uptime = await client.query(
|
|
678
|
-
|
|
751
|
+
`SELECT to_char(now() - pg_postmaster_start_time(), 'DD "d" HH24 "h" MI "m"') AS uptime`
|
|
679
752
|
);
|
|
680
753
|
const dbSize = await client.query(
|
|
681
754
|
"SELECT pg_size_pretty(pg_database_size(current_database())) AS size"
|
|
@@ -781,24 +854,24 @@ async function getActivity(pool) {
|
|
|
781
854
|
init_advisor();
|
|
782
855
|
|
|
783
856
|
// src/server/timeseries.ts
|
|
784
|
-
import
|
|
785
|
-
import
|
|
786
|
-
import
|
|
787
|
-
import
|
|
788
|
-
var DEFAULT_DIR =
|
|
857
|
+
import Database2 from "better-sqlite3";
|
|
858
|
+
import path2 from "path";
|
|
859
|
+
import os2 from "os";
|
|
860
|
+
import fs2 from "fs";
|
|
861
|
+
var DEFAULT_DIR = path2.join(os2.homedir(), ".pg-dash");
|
|
789
862
|
var DEFAULT_RETENTION_DAYS = 7;
|
|
790
863
|
var TimeseriesStore = class {
|
|
791
864
|
db;
|
|
792
865
|
insertStmt;
|
|
793
866
|
retentionMs;
|
|
794
867
|
constructor(dbOrDir, retentionDays = DEFAULT_RETENTION_DAYS) {
|
|
795
|
-
if (dbOrDir instanceof
|
|
868
|
+
if (dbOrDir instanceof Database2) {
|
|
796
869
|
this.db = dbOrDir;
|
|
797
870
|
} else {
|
|
798
871
|
const dir = dbOrDir || DEFAULT_DIR;
|
|
799
|
-
|
|
800
|
-
const dbPath =
|
|
801
|
-
this.db = new
|
|
872
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
873
|
+
const dbPath = path2.join(dir, "metrics.db");
|
|
874
|
+
this.db = new Database2(dbPath);
|
|
802
875
|
}
|
|
803
876
|
this.retentionMs = retentionDays * 24 * 60 * 60 * 1e3;
|
|
804
877
|
this.db.pragma("journal_mode = WAL");
|
|
@@ -858,8 +931,10 @@ var TimeseriesStore = class {
|
|
|
858
931
|
};
|
|
859
932
|
|
|
860
933
|
// src/server/collector.ts
|
|
861
|
-
|
|
934
|
+
import { EventEmitter } from "events";
|
|
935
|
+
var Collector = class extends EventEmitter {
|
|
862
936
|
constructor(pool, store, intervalMs = 3e4) {
|
|
937
|
+
super();
|
|
863
938
|
this.pool = pool;
|
|
864
939
|
this.store = store;
|
|
865
940
|
this.intervalMs = intervalMs;
|
|
@@ -979,6 +1054,7 @@ var Collector = class {
|
|
|
979
1054
|
this.store.insertMany(points);
|
|
980
1055
|
}
|
|
981
1056
|
this.lastSnapshot = snapshot;
|
|
1057
|
+
this.emit("collected", snapshot);
|
|
982
1058
|
return snapshot;
|
|
983
1059
|
}
|
|
984
1060
|
};
|
|
@@ -1828,6 +1904,33 @@ function registerAdvisorRoutes(app, pool, longQueryThreshold) {
|
|
|
1828
1904
|
return c.json({ error: err.message }, 500);
|
|
1829
1905
|
}
|
|
1830
1906
|
});
|
|
1907
|
+
app.get("/api/advisor/ignored", (c) => {
|
|
1908
|
+
try {
|
|
1909
|
+
return c.json(getIgnoredIssues());
|
|
1910
|
+
} catch (err) {
|
|
1911
|
+
return c.json({ error: err.message }, 500);
|
|
1912
|
+
}
|
|
1913
|
+
});
|
|
1914
|
+
app.post("/api/advisor/ignore", async (c) => {
|
|
1915
|
+
try {
|
|
1916
|
+
const body = await c.req.json();
|
|
1917
|
+
const issueId = body?.issueId;
|
|
1918
|
+
if (!issueId) return c.json({ error: "issueId required" }, 400);
|
|
1919
|
+
ignoreIssue(issueId);
|
|
1920
|
+
return c.json({ ok: true });
|
|
1921
|
+
} catch (err) {
|
|
1922
|
+
return c.json({ error: err.message }, 500);
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
app.delete("/api/advisor/ignore/:issueId", (c) => {
|
|
1926
|
+
try {
|
|
1927
|
+
const issueId = c.req.param("issueId");
|
|
1928
|
+
unignoreIssue(issueId);
|
|
1929
|
+
return c.json({ ok: true });
|
|
1930
|
+
} catch (err) {
|
|
1931
|
+
return c.json({ error: err.message }, 500);
|
|
1932
|
+
}
|
|
1933
|
+
});
|
|
1831
1934
|
app.post("/api/fix", async (c) => {
|
|
1832
1935
|
try {
|
|
1833
1936
|
const body = await c.req.json();
|
|
@@ -2017,6 +2120,7 @@ function registerExplainRoutes(app, pool) {
|
|
|
2017
2120
|
const query = body?.query?.trim();
|
|
2018
2121
|
if (!query) return c.json({ error: "Missing query" }, 400);
|
|
2019
2122
|
if (DDL_PATTERN.test(query)) return c.json({ error: "DDL statements are not allowed" }, 400);
|
|
2123
|
+
if (!/^\s*SELECT\b/i.test(query)) return c.json({ error: "Only SELECT queries can be explained for safety. DELETE/UPDATE/INSERT are blocked." }, 400);
|
|
2020
2124
|
const client = await pool.connect();
|
|
2021
2125
|
try {
|
|
2022
2126
|
await client.query("SET statement_timeout = '30s'");
|
|
@@ -2407,10 +2511,10 @@ function registerQueryStatsRoutes(app, store) {
|
|
|
2407
2511
|
}
|
|
2408
2512
|
|
|
2409
2513
|
// src/server/index.ts
|
|
2410
|
-
import
|
|
2514
|
+
import Database3 from "better-sqlite3";
|
|
2411
2515
|
import { WebSocketServer, WebSocket } from "ws";
|
|
2412
2516
|
import http from "http";
|
|
2413
|
-
var __dirname =
|
|
2517
|
+
var __dirname = path3.dirname(fileURLToPath(import.meta.url));
|
|
2414
2518
|
async function startServer(opts) {
|
|
2415
2519
|
const pool = new Pool({ connectionString: opts.connectionString });
|
|
2416
2520
|
try {
|
|
@@ -2438,25 +2542,25 @@ async function startServer(opts) {
|
|
|
2438
2542
|
await pool.end();
|
|
2439
2543
|
process.exit(0);
|
|
2440
2544
|
}
|
|
2441
|
-
const dataDir = opts.dataDir ||
|
|
2442
|
-
|
|
2443
|
-
const metricsDbPath =
|
|
2444
|
-
const metricsDb = new
|
|
2545
|
+
const dataDir = opts.dataDir || path3.join(os3.homedir(), ".pg-dash");
|
|
2546
|
+
fs3.mkdirSync(dataDir, { recursive: true });
|
|
2547
|
+
const metricsDbPath = path3.join(dataDir, "metrics.db");
|
|
2548
|
+
const metricsDb = new Database3(metricsDbPath);
|
|
2445
2549
|
metricsDb.pragma("journal_mode = WAL");
|
|
2446
2550
|
const store = new TimeseriesStore(metricsDb, opts.retentionDays);
|
|
2447
2551
|
const intervalMs = (opts.interval || 30) * 1e3;
|
|
2448
2552
|
const collector = new Collector(pool, store, intervalMs);
|
|
2449
2553
|
console.log(` Collecting metrics every ${intervalMs / 1e3}s...`);
|
|
2450
2554
|
collector.start();
|
|
2451
|
-
const schemaDbPath =
|
|
2452
|
-
const schemaDb = new
|
|
2555
|
+
const schemaDbPath = path3.join(dataDir, "schema.db");
|
|
2556
|
+
const schemaDb = new Database3(schemaDbPath);
|
|
2453
2557
|
schemaDb.pragma("journal_mode = WAL");
|
|
2454
2558
|
const snapshotIntervalMs = (opts.snapshotInterval || 6) * 60 * 60 * 1e3;
|
|
2455
2559
|
const schemaTracker = new SchemaTracker(schemaDb, pool, snapshotIntervalMs);
|
|
2456
2560
|
schemaTracker.start();
|
|
2457
2561
|
console.log(" Schema change tracking enabled");
|
|
2458
|
-
const alertsDbPath =
|
|
2459
|
-
const alertsDb = new
|
|
2562
|
+
const alertsDbPath = path3.join(dataDir, "alerts.db");
|
|
2563
|
+
const alertsDb = new Database3(alertsDbPath);
|
|
2460
2564
|
alertsDb.pragma("journal_mode = WAL");
|
|
2461
2565
|
const alertManager = new AlertManager(alertsDb, opts.webhook);
|
|
2462
2566
|
console.log(" Alert monitoring enabled");
|
|
@@ -2512,7 +2616,7 @@ async function startServer(opts) {
|
|
|
2512
2616
|
registerExplainRoutes(app, pool);
|
|
2513
2617
|
registerDiskRoutes(app, pool, store);
|
|
2514
2618
|
registerQueryStatsRoutes(app, queryStatsStore);
|
|
2515
|
-
const uiPath =
|
|
2619
|
+
const uiPath = path3.resolve(__dirname, "ui");
|
|
2516
2620
|
const MIME_TYPES = {
|
|
2517
2621
|
".html": "text/html",
|
|
2518
2622
|
".js": "application/javascript",
|
|
@@ -2527,15 +2631,15 @@ async function startServer(opts) {
|
|
|
2527
2631
|
};
|
|
2528
2632
|
app.get("/*", async (c) => {
|
|
2529
2633
|
const urlPath = c.req.path === "/" ? "/index.html" : c.req.path;
|
|
2530
|
-
const filePath =
|
|
2634
|
+
const filePath = path3.join(uiPath, urlPath);
|
|
2531
2635
|
try {
|
|
2532
|
-
const content = await
|
|
2533
|
-
const ext =
|
|
2636
|
+
const content = await fs3.promises.readFile(filePath);
|
|
2637
|
+
const ext = path3.extname(filePath);
|
|
2534
2638
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
2535
2639
|
return new Response(content, { headers: { "content-type": contentType } });
|
|
2536
2640
|
} catch {
|
|
2537
2641
|
try {
|
|
2538
|
-
const html = await
|
|
2642
|
+
const html = await fs3.promises.readFile(path3.join(uiPath, "index.html"));
|
|
2539
2643
|
return new Response(html, { headers: { "content-type": "text/html" } });
|
|
2540
2644
|
} catch (err) {
|
|
2541
2645
|
console.error("[static] Error reading index.html:", err.message);
|
|
@@ -2599,9 +2703,7 @@ async function startServer(opts) {
|
|
|
2599
2703
|
ws.on("error", () => clients.delete(ws));
|
|
2600
2704
|
});
|
|
2601
2705
|
let collectCycleCount = 0;
|
|
2602
|
-
|
|
2603
|
-
collector.collect = async () => {
|
|
2604
|
-
const snapshot = await origCollect();
|
|
2706
|
+
collector.on("collected", async (snapshot) => {
|
|
2605
2707
|
if (clients.size > 0 && Object.keys(snapshot).length > 0) {
|
|
2606
2708
|
const metricsMsg = JSON.stringify({ type: "metrics", data: snapshot });
|
|
2607
2709
|
let activityData = [];
|
|
@@ -2700,8 +2802,7 @@ async function startServer(opts) {
|
|
|
2700
2802
|
console.error("[alerts] Error checking alerts:", err.message);
|
|
2701
2803
|
}
|
|
2702
2804
|
}
|
|
2703
|
-
|
|
2704
|
-
};
|
|
2805
|
+
});
|
|
2705
2806
|
const bindAddr = opts.bind || "127.0.0.1";
|
|
2706
2807
|
server.listen(opts.port, bindAddr, async () => {
|
|
2707
2808
|
console.log(`
|
|
@@ -2737,8 +2838,8 @@ async function startServer(opts) {
|
|
|
2737
2838
|
}
|
|
2738
2839
|
|
|
2739
2840
|
// src/cli.ts
|
|
2740
|
-
import
|
|
2741
|
-
import
|
|
2841
|
+
import fs4 from "fs";
|
|
2842
|
+
import path4 from "path";
|
|
2742
2843
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2743
2844
|
process.on("uncaughtException", (err) => {
|
|
2744
2845
|
console.error("Uncaught exception:", err);
|
|
@@ -2777,8 +2878,8 @@ var { values, positionals } = parseArgs({
|
|
|
2777
2878
|
});
|
|
2778
2879
|
if (values.version) {
|
|
2779
2880
|
try {
|
|
2780
|
-
const __dirname2 =
|
|
2781
|
-
const pkg = JSON.parse(
|
|
2881
|
+
const __dirname2 = path4.dirname(fileURLToPath2(import.meta.url));
|
|
2882
|
+
const pkg = JSON.parse(fs4.readFileSync(path4.resolve(__dirname2, "../package.json"), "utf-8"));
|
|
2782
2883
|
console.log(`pg-dash v${pkg.version}`);
|
|
2783
2884
|
} catch {
|
|
2784
2885
|
console.log("pg-dash v0.1.0");
|
|
@@ -2883,14 +2984,14 @@ if (subcommand === "check") {
|
|
|
2883
2984
|
}
|
|
2884
2985
|
} else if (subcommand === "schema-diff") {
|
|
2885
2986
|
const connectionString = resolveConnectionString(1);
|
|
2886
|
-
const dataDir = values["data-dir"] ||
|
|
2887
|
-
const schemaDbPath =
|
|
2888
|
-
if (!
|
|
2987
|
+
const dataDir = values["data-dir"] || path4.join((await import("os")).homedir(), ".pg-dash");
|
|
2988
|
+
const schemaDbPath = path4.join(dataDir, "schema.db");
|
|
2989
|
+
if (!fs4.existsSync(schemaDbPath)) {
|
|
2889
2990
|
console.error("No schema tracking data found. Run pg-dash server first to collect schema snapshots.");
|
|
2890
2991
|
process.exit(1);
|
|
2891
2992
|
}
|
|
2892
|
-
const
|
|
2893
|
-
const db = new
|
|
2993
|
+
const Database4 = (await import("better-sqlite3")).default;
|
|
2994
|
+
const db = new Database4(schemaDbPath, { readonly: true });
|
|
2894
2995
|
const changes = db.prepare("SELECT * FROM schema_changes ORDER BY timestamp DESC LIMIT 50").all();
|
|
2895
2996
|
db.close();
|
|
2896
2997
|
if (changes.length === 0) {
|