@indiekitai/pg-dash 0.2.0 → 0.3.1

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
@@ -47,7 +47,7 @@ function computeBreakdown(issues) {
47
47
  }
48
48
  return result;
49
49
  }
50
- async function getAdvisorReport(pool) {
50
+ async function getAdvisorReport(pool, longQueryThreshold = 5) {
51
51
  const client = await pool.connect();
52
52
  const issues = [];
53
53
  try {
@@ -281,9 +281,9 @@ SHOW shared_buffers;`,
281
281
  extract(epoch from now() - state_change)::int AS idle_seconds
282
282
  FROM pg_stat_activity
283
283
  WHERE state IN ('idle', 'idle in transaction')
284
- AND now() - state_change > interval '10 minutes'
284
+ AND now() - state_change > $1 * interval '1 minute'
285
285
  AND pid != pg_backend_pid()
286
- `);
286
+ `, [longQueryThreshold]);
287
287
  for (const row of r.rows) {
288
288
  const isIdleTx = row.state === "idle in transaction";
289
289
  issues.push({
@@ -777,42 +777,214 @@ async function getActivity(pool) {
777
777
  }
778
778
  }
779
779
 
780
- // src/server/queries/slow-queries.ts
781
- async function getSlowQueries(pool) {
782
- const client = await pool.connect();
783
- try {
784
- const extCheck = await client.query(
785
- "SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'"
786
- );
787
- if (extCheck.rows.length === 0) {
788
- return [];
780
+ // src/server/index.ts
781
+ init_advisor();
782
+
783
+ // 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");
789
+ var DEFAULT_RETENTION_DAYS = 7;
790
+ var TimeseriesStore = class {
791
+ db;
792
+ insertStmt;
793
+ retentionMs;
794
+ constructor(dbOrDir, retentionDays = DEFAULT_RETENTION_DAYS) {
795
+ if (dbOrDir instanceof Database) {
796
+ this.db = dbOrDir;
797
+ } else {
798
+ 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);
789
802
  }
790
- const r = await client.query(`
791
- SELECT
792
- queryid::text,
793
- query,
794
- calls::int,
795
- total_exec_time AS total_time,
796
- mean_exec_time AS mean_time,
797
- rows::int,
798
- round(total_exec_time::numeric / 1000, 2)::text || 's' AS total_time_pretty,
799
- round(mean_exec_time::numeric, 2)::text || 'ms' AS mean_time_pretty
800
- FROM pg_stat_statements
801
- WHERE query NOT LIKE '%pg_stat%'
802
- AND query NOT LIKE '%pg_catalog%'
803
- ORDER BY total_exec_time DESC
804
- LIMIT 50
803
+ this.retentionMs = retentionDays * 24 * 60 * 60 * 1e3;
804
+ this.db.pragma("journal_mode = WAL");
805
+ this.db.exec(`
806
+ CREATE TABLE IF NOT EXISTS metrics (
807
+ timestamp INTEGER NOT NULL,
808
+ metric TEXT NOT NULL,
809
+ value REAL NOT NULL
810
+ );
811
+ CREATE INDEX IF NOT EXISTS idx_metrics_metric_ts ON metrics(metric, timestamp);
805
812
  `);
806
- return r.rows;
807
- } catch {
808
- return [];
809
- } finally {
810
- client.release();
813
+ this.insertStmt = this.db.prepare(
814
+ "INSERT INTO metrics (timestamp, metric, value) VALUES (?, ?, ?)"
815
+ );
811
816
  }
812
- }
817
+ insert(metric, value, timestamp) {
818
+ this.insertStmt.run(timestamp ?? Date.now(), metric, value);
819
+ }
820
+ insertMany(points) {
821
+ const tx = this.db.transaction((pts) => {
822
+ for (const p of pts) {
823
+ this.insertStmt.run(p.timestamp, p.metric, p.value);
824
+ }
825
+ });
826
+ tx(points);
827
+ }
828
+ query(metric, startMs, endMs) {
829
+ const end = endMs ?? Date.now();
830
+ return this.db.prepare(
831
+ "SELECT timestamp, value FROM metrics WHERE metric = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp"
832
+ ).all(metric, startMs, end);
833
+ }
834
+ latest(metrics) {
835
+ const result = {};
836
+ if (metrics && metrics.length > 0) {
837
+ const placeholders = metrics.map(() => "?").join(",");
838
+ const rows = this.db.prepare(
839
+ `SELECT m.metric, m.timestamp, m.value FROM metrics m INNER JOIN (SELECT metric, MAX(timestamp) as max_ts FROM metrics WHERE metric IN (${placeholders}) GROUP BY metric) g ON m.metric = g.metric AND m.timestamp = g.max_ts`
840
+ ).all(...metrics);
841
+ for (const r of rows) result[r.metric] = { timestamp: r.timestamp, value: r.value };
842
+ } else {
843
+ const rows = this.db.prepare(
844
+ "SELECT m.metric, m.timestamp, m.value FROM metrics m INNER JOIN (SELECT metric, MAX(timestamp) as max_ts FROM metrics GROUP BY metric) g ON m.metric = g.metric AND m.timestamp = g.max_ts"
845
+ ).all();
846
+ for (const r of rows) result[r.metric] = { timestamp: r.timestamp, value: r.value };
847
+ }
848
+ return result;
849
+ }
850
+ prune() {
851
+ const cutoff = Date.now() - this.retentionMs;
852
+ const info = this.db.prepare("DELETE FROM metrics WHERE timestamp < ?").run(cutoff);
853
+ return info.changes;
854
+ }
855
+ close() {
856
+ this.db.close();
857
+ }
858
+ };
813
859
 
814
- // src/server/index.ts
815
- init_advisor();
860
+ // src/server/collector.ts
861
+ import { EventEmitter } from "events";
862
+ var Collector = class extends EventEmitter {
863
+ constructor(pool, store, intervalMs = 3e4) {
864
+ super();
865
+ this.pool = pool;
866
+ this.store = store;
867
+ this.intervalMs = intervalMs;
868
+ }
869
+ timer = null;
870
+ pruneTimer = null;
871
+ prev = null;
872
+ lastSnapshot = {};
873
+ start() {
874
+ this.collect().catch((err) => console.error("[collector] Initial collection failed:", err));
875
+ this.timer = setInterval(() => {
876
+ this.collect().catch((err) => console.error("[collector] Collection failed:", err));
877
+ }, this.intervalMs);
878
+ this.pruneTimer = setInterval(() => this.store.prune(), 60 * 60 * 1e3);
879
+ }
880
+ stop() {
881
+ if (this.timer) {
882
+ clearInterval(this.timer);
883
+ this.timer = null;
884
+ }
885
+ if (this.pruneTimer) {
886
+ clearInterval(this.pruneTimer);
887
+ this.pruneTimer = null;
888
+ }
889
+ }
890
+ getLastSnapshot() {
891
+ return { ...this.lastSnapshot };
892
+ }
893
+ async collect() {
894
+ const now = Date.now();
895
+ const snapshot = {};
896
+ try {
897
+ const client = await this.pool.connect();
898
+ try {
899
+ const connRes = await client.query(`
900
+ SELECT
901
+ count(*) FILTER (WHERE state = 'active')::int AS active,
902
+ count(*) FILTER (WHERE state = 'idle')::int AS idle,
903
+ count(*)::int AS total
904
+ FROM pg_stat_activity
905
+ `);
906
+ const conn = connRes.rows[0];
907
+ snapshot.connections_active = conn.active;
908
+ snapshot.connections_idle = conn.idle;
909
+ snapshot.connections_total = conn.total;
910
+ const dbRes = await client.query(`
911
+ SELECT
912
+ xact_commit, xact_rollback, deadlocks, temp_bytes,
913
+ tup_inserted, tup_updated, tup_deleted,
914
+ CASE WHEN (blks_hit + blks_read) = 0 THEN 1
915
+ ELSE blks_hit::float / (blks_hit + blks_read) END AS cache_ratio,
916
+ pg_database_size(current_database()) AS db_size
917
+ FROM pg_stat_database WHERE datname = current_database()
918
+ `);
919
+ const db = dbRes.rows[0];
920
+ if (db) {
921
+ snapshot.cache_hit_ratio = parseFloat(db.cache_ratio);
922
+ snapshot.db_size_bytes = parseInt(db.db_size);
923
+ const cur = {
924
+ timestamp: now,
925
+ xact_commit: parseInt(db.xact_commit),
926
+ xact_rollback: parseInt(db.xact_rollback),
927
+ deadlocks: parseInt(db.deadlocks),
928
+ temp_bytes: parseInt(db.temp_bytes),
929
+ tup_inserted: parseInt(db.tup_inserted),
930
+ tup_updated: parseInt(db.tup_updated),
931
+ tup_deleted: parseInt(db.tup_deleted)
932
+ };
933
+ if (this.prev) {
934
+ const dtSec = (now - this.prev.timestamp) / 1e3;
935
+ if (dtSec > 0) {
936
+ snapshot.tps_commit = Math.max(0, (cur.xact_commit - this.prev.xact_commit) / dtSec);
937
+ snapshot.tps_rollback = Math.max(0, (cur.xact_rollback - this.prev.xact_rollback) / dtSec);
938
+ snapshot.deadlocks = Math.max(0, cur.deadlocks - this.prev.deadlocks);
939
+ snapshot.temp_bytes = Math.max(0, cur.temp_bytes - this.prev.temp_bytes);
940
+ snapshot.tuple_inserted = Math.max(0, (cur.tup_inserted - this.prev.tup_inserted) / dtSec);
941
+ snapshot.tuple_updated = Math.max(0, (cur.tup_updated - this.prev.tup_updated) / dtSec);
942
+ snapshot.tuple_deleted = Math.max(0, (cur.tup_deleted - this.prev.tup_deleted) / dtSec);
943
+ }
944
+ }
945
+ this.prev = cur;
946
+ }
947
+ try {
948
+ const tsRes = await client.query(`SELECT spcname, pg_tablespace_size(oid) AS size FROM pg_tablespace`);
949
+ let totalTablespaceSize = 0;
950
+ for (const row of tsRes.rows) {
951
+ totalTablespaceSize += parseInt(row.size);
952
+ }
953
+ if (totalTablespaceSize > 0) {
954
+ snapshot.disk_used_bytes = totalTablespaceSize;
955
+ }
956
+ } catch {
957
+ }
958
+ try {
959
+ const repRes = await client.query(`
960
+ SELECT CASE WHEN pg_is_in_recovery()
961
+ THEN pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn())
962
+ ELSE 0 END AS lag_bytes
963
+ `);
964
+ snapshot.replication_lag_bytes = parseInt(repRes.rows[0]?.lag_bytes ?? "0");
965
+ } catch {
966
+ snapshot.replication_lag_bytes = 0;
967
+ }
968
+ } finally {
969
+ client.release();
970
+ }
971
+ } catch (err) {
972
+ console.error("[collector] Error collecting metrics:", err.message);
973
+ return snapshot;
974
+ }
975
+ const points = Object.entries(snapshot).map(([metric, value]) => ({
976
+ timestamp: now,
977
+ metric,
978
+ value
979
+ }));
980
+ if (points.length > 0) {
981
+ this.store.insertMany(points);
982
+ }
983
+ this.lastSnapshot = snapshot;
984
+ this.emit("collected", snapshot);
985
+ return snapshot;
986
+ }
987
+ };
816
988
 
817
989
  // src/server/queries/schema.ts
818
990
  async function getSchemaTables(pool) {
@@ -1024,203 +1196,20 @@ async function getSchemaEnums(pool) {
1024
1196
  }
1025
1197
  }
1026
1198
 
1027
- // src/server/timeseries.ts
1028
- import Database from "better-sqlite3";
1029
- import path from "path";
1030
- import os from "os";
1031
- import fs from "fs";
1032
- var DEFAULT_DIR = path.join(os.homedir(), ".pg-dash");
1033
- var DEFAULT_RETENTION_DAYS = 7;
1034
- var TimeseriesStore = class {
1035
- db;
1036
- insertStmt;
1037
- retentionMs;
1038
- constructor(dataDir, retentionDays = DEFAULT_RETENTION_DAYS) {
1039
- const dir = dataDir || DEFAULT_DIR;
1040
- fs.mkdirSync(dir, { recursive: true });
1041
- const dbPath = path.join(dir, "metrics.db");
1042
- this.db = new Database(dbPath);
1043
- this.retentionMs = retentionDays * 24 * 60 * 60 * 1e3;
1044
- this.db.pragma("journal_mode = WAL");
1045
- this.db.exec(`
1046
- CREATE TABLE IF NOT EXISTS metrics (
1047
- timestamp INTEGER NOT NULL,
1048
- metric TEXT NOT NULL,
1049
- value REAL NOT NULL
1050
- );
1051
- CREATE INDEX IF NOT EXISTS idx_metrics_metric_ts ON metrics(metric, timestamp);
1052
- `);
1053
- this.insertStmt = this.db.prepare(
1054
- "INSERT INTO metrics (timestamp, metric, value) VALUES (?, ?, ?)"
1055
- );
1199
+ // src/server/schema-diff.ts
1200
+ function diffSnapshots(oldSnap, newSnap) {
1201
+ const changes = [];
1202
+ const oldTableMap = new Map(oldSnap.tables.map((t) => [`${t.schema}.${t.name}`, t]));
1203
+ const newTableMap = new Map(newSnap.tables.map((t) => [`${t.schema}.${t.name}`, t]));
1204
+ for (const [key, t] of newTableMap) {
1205
+ if (!oldTableMap.has(key)) {
1206
+ changes.push({ change_type: "added", object_type: "table", table_name: key, detail: `Table ${key} added` });
1207
+ }
1056
1208
  }
1057
- insert(metric, value, timestamp) {
1058
- this.insertStmt.run(timestamp ?? Date.now(), metric, value);
1059
- }
1060
- insertMany(points) {
1061
- const tx = this.db.transaction((pts) => {
1062
- for (const p of pts) {
1063
- this.insertStmt.run(p.timestamp, p.metric, p.value);
1064
- }
1065
- });
1066
- tx(points);
1067
- }
1068
- query(metric, startMs, endMs) {
1069
- const end = endMs ?? Date.now();
1070
- return this.db.prepare(
1071
- "SELECT timestamp, value FROM metrics WHERE metric = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp"
1072
- ).all(metric, startMs, end);
1073
- }
1074
- latest(metrics) {
1075
- const result = {};
1076
- if (metrics && metrics.length > 0) {
1077
- const placeholders = metrics.map(() => "?").join(",");
1078
- const rows = this.db.prepare(
1079
- `SELECT m.metric, m.timestamp, m.value FROM metrics m INNER JOIN (SELECT metric, MAX(timestamp) as max_ts FROM metrics WHERE metric IN (${placeholders}) GROUP BY metric) g ON m.metric = g.metric AND m.timestamp = g.max_ts`
1080
- ).all(...metrics);
1081
- for (const r of rows) result[r.metric] = { timestamp: r.timestamp, value: r.value };
1082
- } else {
1083
- const rows = this.db.prepare(
1084
- "SELECT m.metric, m.timestamp, m.value FROM metrics m INNER JOIN (SELECT metric, MAX(timestamp) as max_ts FROM metrics GROUP BY metric) g ON m.metric = g.metric AND m.timestamp = g.max_ts"
1085
- ).all();
1086
- for (const r of rows) result[r.metric] = { timestamp: r.timestamp, value: r.value };
1087
- }
1088
- return result;
1089
- }
1090
- prune() {
1091
- const cutoff = Date.now() - this.retentionMs;
1092
- const info = this.db.prepare("DELETE FROM metrics WHERE timestamp < ?").run(cutoff);
1093
- return info.changes;
1094
- }
1095
- close() {
1096
- this.db.close();
1097
- }
1098
- };
1099
-
1100
- // src/server/collector.ts
1101
- var Collector = class {
1102
- constructor(pool, store, intervalMs = 3e4) {
1103
- this.pool = pool;
1104
- this.store = store;
1105
- this.intervalMs = intervalMs;
1106
- }
1107
- timer = null;
1108
- prev = null;
1109
- lastSnapshot = {};
1110
- start() {
1111
- this.collect().catch((err) => console.error("[collector] Initial collection failed:", err));
1112
- this.timer = setInterval(() => {
1113
- this.collect().catch((err) => console.error("[collector] Collection failed:", err));
1114
- }, this.intervalMs);
1115
- setInterval(() => this.store.prune(), 60 * 60 * 1e3);
1116
- }
1117
- stop() {
1118
- if (this.timer) {
1119
- clearInterval(this.timer);
1120
- this.timer = null;
1121
- }
1122
- }
1123
- getLastSnapshot() {
1124
- return { ...this.lastSnapshot };
1125
- }
1126
- async collect() {
1127
- const now = Date.now();
1128
- const snapshot = {};
1129
- try {
1130
- const client = await this.pool.connect();
1131
- try {
1132
- const connRes = await client.query(`
1133
- SELECT
1134
- count(*) FILTER (WHERE state = 'active')::int AS active,
1135
- count(*) FILTER (WHERE state = 'idle')::int AS idle,
1136
- count(*)::int AS total
1137
- FROM pg_stat_activity
1138
- `);
1139
- const conn = connRes.rows[0];
1140
- snapshot.connections_active = conn.active;
1141
- snapshot.connections_idle = conn.idle;
1142
- snapshot.connections_total = conn.total;
1143
- const dbRes = await client.query(`
1144
- SELECT
1145
- xact_commit, xact_rollback, deadlocks, temp_bytes,
1146
- tup_inserted, tup_updated, tup_deleted,
1147
- CASE WHEN (blks_hit + blks_read) = 0 THEN 1
1148
- ELSE blks_hit::float / (blks_hit + blks_read) END AS cache_ratio,
1149
- pg_database_size(current_database()) AS db_size
1150
- FROM pg_stat_database WHERE datname = current_database()
1151
- `);
1152
- const db = dbRes.rows[0];
1153
- if (db) {
1154
- snapshot.cache_hit_ratio = parseFloat(db.cache_ratio);
1155
- snapshot.db_size_bytes = parseInt(db.db_size);
1156
- const cur = {
1157
- timestamp: now,
1158
- xact_commit: parseInt(db.xact_commit),
1159
- xact_rollback: parseInt(db.xact_rollback),
1160
- deadlocks: parseInt(db.deadlocks),
1161
- temp_bytes: parseInt(db.temp_bytes),
1162
- tup_inserted: parseInt(db.tup_inserted),
1163
- tup_updated: parseInt(db.tup_updated),
1164
- tup_deleted: parseInt(db.tup_deleted)
1165
- };
1166
- if (this.prev) {
1167
- const dtSec = (now - this.prev.timestamp) / 1e3;
1168
- if (dtSec > 0) {
1169
- snapshot.tps_commit = Math.max(0, (cur.xact_commit - this.prev.xact_commit) / dtSec);
1170
- snapshot.tps_rollback = Math.max(0, (cur.xact_rollback - this.prev.xact_rollback) / dtSec);
1171
- snapshot.deadlocks = Math.max(0, cur.deadlocks - this.prev.deadlocks);
1172
- snapshot.temp_bytes = Math.max(0, cur.temp_bytes - this.prev.temp_bytes);
1173
- snapshot.tuple_inserted = Math.max(0, (cur.tup_inserted - this.prev.tup_inserted) / dtSec);
1174
- snapshot.tuple_updated = Math.max(0, (cur.tup_updated - this.prev.tup_updated) / dtSec);
1175
- snapshot.tuple_deleted = Math.max(0, (cur.tup_deleted - this.prev.tup_deleted) / dtSec);
1176
- }
1177
- }
1178
- this.prev = cur;
1179
- }
1180
- try {
1181
- const repRes = await client.query(`
1182
- SELECT CASE WHEN pg_is_in_recovery()
1183
- THEN pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn())
1184
- ELSE 0 END AS lag_bytes
1185
- `);
1186
- snapshot.replication_lag_bytes = parseInt(repRes.rows[0]?.lag_bytes ?? "0");
1187
- } catch {
1188
- snapshot.replication_lag_bytes = 0;
1189
- }
1190
- } finally {
1191
- client.release();
1192
- }
1193
- } catch (err) {
1194
- console.error("[collector] Error collecting metrics:", err.message);
1195
- return snapshot;
1196
- }
1197
- const points = Object.entries(snapshot).map(([metric, value]) => ({
1198
- timestamp: now,
1199
- metric,
1200
- value
1201
- }));
1202
- if (points.length > 0) {
1203
- this.store.insertMany(points);
1204
- }
1205
- this.lastSnapshot = snapshot;
1206
- return snapshot;
1207
- }
1208
- };
1209
-
1210
- // src/server/schema-diff.ts
1211
- function diffSnapshots(oldSnap, newSnap) {
1212
- const changes = [];
1213
- const oldTableMap = new Map(oldSnap.tables.map((t) => [`${t.schema}.${t.name}`, t]));
1214
- const newTableMap = new Map(newSnap.tables.map((t) => [`${t.schema}.${t.name}`, t]));
1215
- for (const [key, t] of newTableMap) {
1216
- if (!oldTableMap.has(key)) {
1217
- changes.push({ change_type: "added", object_type: "table", table_name: key, detail: `Table ${key} added` });
1218
- }
1219
- }
1220
- for (const [key] of oldTableMap) {
1221
- if (!newTableMap.has(key)) {
1222
- changes.push({ change_type: "removed", object_type: "table", table_name: key, detail: `Table ${key} removed` });
1223
- }
1209
+ for (const [key] of oldTableMap) {
1210
+ if (!newTableMap.has(key)) {
1211
+ changes.push({ change_type: "removed", object_type: "table", table_name: key, detail: `Table ${key} removed` });
1212
+ }
1224
1213
  }
1225
1214
  for (const [key, newTable] of newTableMap) {
1226
1215
  const oldTable = oldTableMap.get(key);
@@ -1426,6 +1415,92 @@ var SchemaTracker = class {
1426
1415
  }
1427
1416
  };
1428
1417
 
1418
+ // src/server/notifiers.ts
1419
+ var SEVERITY_COLORS = {
1420
+ critical: { hex: "#e74c3c", decimal: 15158332, emoji: "\u{1F534}" },
1421
+ warning: { hex: "#f39c12", decimal: 15965202, emoji: "\u{1F7E1}" },
1422
+ info: { hex: "#3498db", decimal: 3447003, emoji: "\u{1F535}" }
1423
+ };
1424
+ function detectWebhookType(url) {
1425
+ if (url.includes("hooks.slack.com")) return "slack";
1426
+ if (url.includes("discord.com/api/webhooks") || url.includes("discordapp.com")) return "discord";
1427
+ return "generic";
1428
+ }
1429
+ function formatSlackMessage(alert, rule) {
1430
+ const colors = SEVERITY_COLORS[rule.severity] || SEVERITY_COLORS.info;
1431
+ return {
1432
+ attachments: [
1433
+ {
1434
+ color: colors.hex,
1435
+ blocks: [
1436
+ {
1437
+ type: "section",
1438
+ text: {
1439
+ type: "mrkdwn",
1440
+ text: `${colors.emoji} *pg-dash Alert: ${rule.name}*`
1441
+ }
1442
+ },
1443
+ {
1444
+ type: "section",
1445
+ fields: [
1446
+ { type: "mrkdwn", text: `*Metric:*
1447
+ ${rule.metric}` },
1448
+ { type: "mrkdwn", text: `*Current Value:*
1449
+ ${alert.value}` },
1450
+ { type: "mrkdwn", text: `*Threshold:*
1451
+ ${rule.operator} ${rule.threshold}` },
1452
+ { type: "mrkdwn", text: `*Severity:*
1453
+ ${rule.severity}` },
1454
+ { type: "mrkdwn", text: `*Timestamp:*
1455
+ ${new Date(alert.timestamp).toISOString()}` }
1456
+ ]
1457
+ }
1458
+ ]
1459
+ }
1460
+ ]
1461
+ };
1462
+ }
1463
+ function formatDiscordMessage(alert, rule) {
1464
+ const colors = SEVERITY_COLORS[rule.severity] || SEVERITY_COLORS.info;
1465
+ return {
1466
+ embeds: [
1467
+ {
1468
+ title: `${colors.emoji} pg-dash Alert: ${rule.name}`,
1469
+ color: colors.decimal,
1470
+ fields: [
1471
+ { name: "Metric", value: rule.metric, inline: true },
1472
+ { name: "Current Value", value: String(alert.value), inline: true },
1473
+ { name: "Threshold", value: `${rule.operator} ${rule.threshold}`, inline: true },
1474
+ { name: "Severity", value: rule.severity, inline: true },
1475
+ { name: "Timestamp", value: new Date(alert.timestamp).toISOString(), inline: false }
1476
+ ],
1477
+ footer: { text: "pg-dash \xB7 PostgreSQL Monitoring" }
1478
+ }
1479
+ ]
1480
+ };
1481
+ }
1482
+ function formatGenericWebhook(alert, rule) {
1483
+ return {
1484
+ severity: rule.severity,
1485
+ rule: rule.name,
1486
+ metric: rule.metric,
1487
+ value: alert.value,
1488
+ message: alert.message,
1489
+ timestamp: alert.timestamp
1490
+ };
1491
+ }
1492
+ function formatWebhookPayload(alert, rule, webhookUrl) {
1493
+ const type = detectWebhookType(webhookUrl);
1494
+ switch (type) {
1495
+ case "slack":
1496
+ return formatSlackMessage(alert, rule);
1497
+ case "discord":
1498
+ return formatDiscordMessage(alert, rule);
1499
+ default:
1500
+ return formatGenericWebhook(alert, rule);
1501
+ }
1502
+ }
1503
+
1429
1504
  // src/server/alerts.ts
1430
1505
  var DEFAULT_RULES = [
1431
1506
  { name: "Connection utilization > 80%", metric: "connection_util", operator: "gt", threshold: 80, severity: "warning", enabled: 1, cooldown_minutes: 60 },
@@ -1434,7 +1509,9 @@ var DEFAULT_RULES = [
1434
1509
  { name: "Cache hit ratio < 95%", metric: "cache_hit_pct", operator: "lt", threshold: 95, severity: "critical", enabled: 1, cooldown_minutes: 30 },
1435
1510
  { name: "Long-running query > 5 min", metric: "long_query_count", operator: "gt", threshold: 0, severity: "warning", enabled: 1, cooldown_minutes: 15 },
1436
1511
  { name: "Idle in transaction > 10 min", metric: "idle_in_tx_count", operator: "gt", threshold: 0, severity: "warning", enabled: 1, cooldown_minutes: 15 },
1437
- { name: "Health score below D", metric: "health_score", operator: "lt", threshold: 50, severity: "warning", enabled: 1, cooldown_minutes: 120 }
1512
+ { name: "Health score below D", metric: "health_score", operator: "lt", threshold: 50, severity: "warning", enabled: 1, cooldown_minutes: 120 },
1513
+ { name: "Database size growth > 10% in 24h", metric: "db_growth_pct_24h", operator: "gt", threshold: 10, severity: "warning", enabled: 1, cooldown_minutes: 60 },
1514
+ { name: "Predicted disk full within 7 days", metric: "days_until_full", operator: "lt", threshold: 7, severity: "critical", enabled: 1, cooldown_minutes: 360 }
1438
1515
  ];
1439
1516
  var AlertManager = class {
1440
1517
  db;
@@ -1563,20 +1640,55 @@ var AlertManager = class {
1563
1640
  return false;
1564
1641
  }
1565
1642
  }
1643
+ getWebhookUrl() {
1644
+ return this.webhookUrl;
1645
+ }
1646
+ getWebhookType() {
1647
+ if (!this.webhookUrl) return null;
1648
+ return detectWebhookType(this.webhookUrl);
1649
+ }
1650
+ async sendTestWebhook() {
1651
+ if (!this.webhookUrl) return { ok: false, type: "none", error: "No webhook URL configured" };
1652
+ const type = detectWebhookType(this.webhookUrl);
1653
+ const testRule = {
1654
+ id: 0,
1655
+ name: "Test Alert",
1656
+ metric: "test_metric",
1657
+ operator: "gt",
1658
+ threshold: 80,
1659
+ severity: "info",
1660
+ enabled: 1,
1661
+ cooldown_minutes: 60
1662
+ };
1663
+ const testEntry = {
1664
+ id: 0,
1665
+ rule_id: 0,
1666
+ timestamp: Date.now(),
1667
+ value: 85,
1668
+ message: "Test Alert: test_metric = 85 (threshold: gt 80)",
1669
+ notified: 0
1670
+ };
1671
+ try {
1672
+ const payload = formatWebhookPayload(testEntry, testRule, this.webhookUrl);
1673
+ const res = await fetch(this.webhookUrl, {
1674
+ method: "POST",
1675
+ headers: { "Content-Type": "application/json" },
1676
+ body: JSON.stringify(payload)
1677
+ });
1678
+ if (!res.ok) return { ok: false, type, error: `HTTP ${res.status}` };
1679
+ return { ok: true, type };
1680
+ } catch (err) {
1681
+ return { ok: false, type, error: err.message };
1682
+ }
1683
+ }
1566
1684
  async sendWebhook(rule, entry) {
1567
1685
  if (!this.webhookUrl) return;
1568
1686
  try {
1687
+ const payload = formatWebhookPayload(entry, rule, this.webhookUrl);
1569
1688
  await fetch(this.webhookUrl, {
1570
1689
  method: "POST",
1571
1690
  headers: { "Content-Type": "application/json" },
1572
- body: JSON.stringify({
1573
- severity: rule.severity,
1574
- rule: rule.name,
1575
- metric: rule.metric,
1576
- value: entry.value,
1577
- message: entry.message,
1578
- timestamp: entry.timestamp
1579
- })
1691
+ body: JSON.stringify(payload)
1580
1692
  });
1581
1693
  this.db.prepare("UPDATE alert_history SET notified = 1 WHERE id = ?").run(entry.id);
1582
1694
  } catch (err) {
@@ -1585,11 +1697,32 @@ var AlertManager = class {
1585
1697
  }
1586
1698
  };
1587
1699
 
1588
- // src/server/index.ts
1589
- import Database2 from "better-sqlite3";
1590
- import { WebSocketServer, WebSocket } from "ws";
1591
- import http from "http";
1592
- var __dirname = path2.dirname(fileURLToPath(import.meta.url));
1700
+ // src/server/routes/overview.ts
1701
+ function registerOverviewRoutes(app, pool) {
1702
+ app.get("/api/overview", async (c) => {
1703
+ try {
1704
+ return c.json(await getOverview(pool));
1705
+ } catch (err) {
1706
+ return c.json({ error: err.message }, 500);
1707
+ }
1708
+ });
1709
+ app.get("/api/databases", async (c) => {
1710
+ try {
1711
+ return c.json(await getDatabases(pool));
1712
+ } catch (err) {
1713
+ return c.json({ error: err.message }, 500);
1714
+ }
1715
+ });
1716
+ app.get("/api/tables", async (c) => {
1717
+ try {
1718
+ return c.json(await getTables(pool));
1719
+ } catch (err) {
1720
+ return c.json({ error: err.message }, 500);
1721
+ }
1722
+ });
1723
+ }
1724
+
1725
+ // src/server/routes/metrics.ts
1593
1726
  var RANGE_MAP = {
1594
1727
  "5m": 5 * 60 * 1e3,
1595
1728
  "15m": 15 * 60 * 1e3,
@@ -1598,103 +1731,18 @@ var RANGE_MAP = {
1598
1731
  "24h": 24 * 60 * 60 * 1e3,
1599
1732
  "7d": 7 * 24 * 60 * 60 * 1e3
1600
1733
  };
1601
- async function startServer(opts) {
1602
- const pool = new Pool({ connectionString: opts.connectionString });
1603
- try {
1604
- const client = await pool.connect();
1605
- client.release();
1606
- } catch (err) {
1607
- console.error(`Failed to connect to PostgreSQL: ${err.message}`);
1608
- process.exit(1);
1609
- }
1610
- if (opts.json) {
1734
+ function registerMetricsRoutes(app, store, collector) {
1735
+ app.get("/api/metrics", (c) => {
1611
1736
  try {
1612
- const [overview, advisor, databases, tables] = await Promise.all([
1613
- getOverview(pool),
1614
- getAdvisorReport(pool),
1615
- getDatabases(pool),
1616
- getTables(pool)
1617
- ]);
1618
- console.log(JSON.stringify({ overview, advisor, databases, tables }, null, 2));
1737
+ const metric = c.req.query("metric");
1738
+ const range = c.req.query("range") || "1h";
1739
+ if (!metric) return c.json({ error: "metric param required" }, 400);
1740
+ const rangeMs = RANGE_MAP[range] || RANGE_MAP["1h"];
1741
+ const now = Date.now();
1742
+ const data = store.query(metric, now - rangeMs, now);
1743
+ return c.json(data);
1619
1744
  } catch (err) {
1620
- console.error(JSON.stringify({ error: err.message }));
1621
- process.exit(1);
1622
- }
1623
- await pool.end();
1624
- process.exit(0);
1625
- }
1626
- const dataDir = opts.dataDir || path2.join(os2.homedir(), ".pg-dash");
1627
- fs2.mkdirSync(dataDir, { recursive: true });
1628
- const store = new TimeseriesStore(opts.dataDir, opts.retentionDays);
1629
- const intervalMs = (opts.interval || 30) * 1e3;
1630
- const longQueryThreshold = opts.longQueryThreshold || 5;
1631
- const collector = new Collector(pool, store, intervalMs);
1632
- console.log(` Collecting metrics every ${intervalMs / 1e3}s...`);
1633
- collector.start();
1634
- const schemaDbPath = path2.join(dataDir, "schema.db");
1635
- const schemaDb = new Database2(schemaDbPath);
1636
- schemaDb.pragma("journal_mode = WAL");
1637
- const snapshotIntervalMs = (opts.snapshotInterval || 6) * 60 * 60 * 1e3;
1638
- const schemaTracker = new SchemaTracker(schemaDb, pool, snapshotIntervalMs);
1639
- schemaTracker.start();
1640
- console.log(" Schema change tracking enabled");
1641
- const alertsDbPath = path2.join(dataDir, "alerts.db");
1642
- const alertsDb = new Database2(alertsDbPath);
1643
- alertsDb.pragma("journal_mode = WAL");
1644
- const alertManager = new AlertManager(alertsDb, opts.webhook);
1645
- console.log(" Alert monitoring enabled");
1646
- const app = new Hono();
1647
- if (opts.auth || opts.token) {
1648
- app.use("*", async (c, next) => {
1649
- const authHeader = c.req.header("authorization") || "";
1650
- if (opts.token) {
1651
- if (authHeader === `Bearer ${opts.token}`) return next();
1652
- }
1653
- if (opts.auth) {
1654
- const [user, pass] = opts.auth.split(":");
1655
- const expected = "Basic " + Buffer.from(`${user}:${pass}`).toString("base64");
1656
- if (authHeader === expected) return next();
1657
- }
1658
- const url = new URL(c.req.url, "http://localhost");
1659
- if (opts.token && url.searchParams.get("token") === opts.token) return next();
1660
- if (opts.auth) {
1661
- c.header("WWW-Authenticate", 'Basic realm="pg-dash"');
1662
- }
1663
- return c.text("Unauthorized", 401);
1664
- });
1665
- }
1666
- app.get("/api/overview", async (c) => {
1667
- try {
1668
- return c.json(await getOverview(pool));
1669
- } catch (err) {
1670
- return c.json({ error: err.message }, 500);
1671
- }
1672
- });
1673
- app.get("/api/databases", async (c) => {
1674
- try {
1675
- return c.json(await getDatabases(pool));
1676
- } catch (err) {
1677
- return c.json({ error: err.message }, 500);
1678
- }
1679
- });
1680
- app.get("/api/tables", async (c) => {
1681
- try {
1682
- return c.json(await getTables(pool));
1683
- } catch (err) {
1684
- return c.json({ error: err.message }, 500);
1685
- }
1686
- });
1687
- app.get("/api/metrics", (c) => {
1688
- try {
1689
- const metric = c.req.query("metric");
1690
- const range = c.req.query("range") || "1h";
1691
- if (!metric) return c.json({ error: "metric param required" }, 400);
1692
- const rangeMs = RANGE_MAP[range] || RANGE_MAP["1h"];
1693
- const now = Date.now();
1694
- const data = store.query(metric, now - rangeMs, now);
1695
- return c.json(data);
1696
- } catch (err) {
1697
- return c.json({ error: err.message }, 500);
1745
+ return c.json({ error: err.message }, 500);
1698
1746
  }
1699
1747
  });
1700
1748
  app.get("/api/metrics/latest", (_c) => {
@@ -1705,6 +1753,44 @@ async function startServer(opts) {
1705
1753
  return _c.json({ error: err.message }, 500);
1706
1754
  }
1707
1755
  });
1756
+ }
1757
+
1758
+ // src/server/queries/slow-queries.ts
1759
+ async function getSlowQueries(pool) {
1760
+ const client = await pool.connect();
1761
+ try {
1762
+ const extCheck = await client.query(
1763
+ "SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'"
1764
+ );
1765
+ if (extCheck.rows.length === 0) {
1766
+ return [];
1767
+ }
1768
+ const r = await client.query(`
1769
+ SELECT
1770
+ queryid::text,
1771
+ query,
1772
+ calls::int,
1773
+ total_exec_time AS total_time,
1774
+ mean_exec_time AS mean_time,
1775
+ rows::int,
1776
+ round(total_exec_time::numeric / 1000, 2)::text || 's' AS total_time_pretty,
1777
+ round(mean_exec_time::numeric, 2)::text || 'ms' AS mean_time_pretty
1778
+ FROM pg_stat_statements
1779
+ WHERE query NOT LIKE '%pg_stat%'
1780
+ AND query NOT LIKE '%pg_catalog%'
1781
+ ORDER BY total_exec_time DESC
1782
+ LIMIT 50
1783
+ `);
1784
+ return r.rows;
1785
+ } catch {
1786
+ return [];
1787
+ } finally {
1788
+ client.release();
1789
+ }
1790
+ }
1791
+
1792
+ // src/server/routes/activity.ts
1793
+ function registerActivityRoutes(app, pool) {
1708
1794
  app.get("/api/activity", async (c) => {
1709
1795
  try {
1710
1796
  return c.json(await getActivity(pool));
@@ -1733,9 +1819,14 @@ async function startServer(opts) {
1733
1819
  return c.json({ error: err.message }, 500);
1734
1820
  }
1735
1821
  });
1822
+ }
1823
+
1824
+ // src/server/routes/advisor.ts
1825
+ init_advisor();
1826
+ function registerAdvisorRoutes(app, pool, longQueryThreshold) {
1736
1827
  app.get("/api/advisor", async (c) => {
1737
1828
  try {
1738
- return c.json(await getAdvisorReport(pool));
1829
+ return c.json(await getAdvisorReport(pool, longQueryThreshold));
1739
1830
  } catch (err) {
1740
1831
  return c.json({ error: err.message }, 500);
1741
1832
  }
@@ -1759,6 +1850,10 @@ async function startServer(opts) {
1759
1850
  return c.json({ error: err.message }, 500);
1760
1851
  }
1761
1852
  });
1853
+ }
1854
+
1855
+ // src/server/routes/schema.ts
1856
+ function registerSchemaRoutes(app, pool, schemaTracker) {
1762
1857
  app.get("/api/schema/tables", async (c) => {
1763
1858
  try {
1764
1859
  return c.json(await getSchemaTables(pool));
@@ -1847,6 +1942,10 @@ async function startServer(opts) {
1847
1942
  return c.json({ error: err.message }, 500);
1848
1943
  }
1849
1944
  });
1945
+ }
1946
+
1947
+ // src/server/routes/alerts.ts
1948
+ function registerAlertsRoutes(app, alertManager) {
1850
1949
  app.get("/api/alerts/rules", (c) => {
1851
1950
  try {
1852
1951
  return c.json(alertManager.getRules());
@@ -1884,6 +1983,24 @@ async function startServer(opts) {
1884
1983
  return c.json({ error: err.message }, 500);
1885
1984
  }
1886
1985
  });
1986
+ app.get("/api/alerts/webhook-info", (c) => {
1987
+ try {
1988
+ const url = alertManager.getWebhookUrl();
1989
+ const type = alertManager.getWebhookType();
1990
+ const masked = url ? url.replace(/\/[^/]{8,}$/, "/****") : null;
1991
+ return c.json({ url: masked, type: type || "none", configured: !!url });
1992
+ } catch (err) {
1993
+ return c.json({ error: err.message }, 500);
1994
+ }
1995
+ });
1996
+ app.post("/api/alerts/test-webhook", async (c) => {
1997
+ try {
1998
+ const result = await alertManager.sendTestWebhook();
1999
+ return c.json(result, result.ok ? 200 : 400);
2000
+ } catch (err) {
2001
+ return c.json({ error: err.message }, 500);
2002
+ }
2003
+ });
1887
2004
  app.get("/api/alerts/history", (c) => {
1888
2005
  try {
1889
2006
  const limit = parseInt(c.req.query("limit") || "50");
@@ -1892,6 +2009,513 @@ async function startServer(opts) {
1892
2009
  return c.json({ error: err.message }, 500);
1893
2010
  }
1894
2011
  });
2012
+ }
2013
+
2014
+ // src/server/routes/explain.ts
2015
+ var DDL_PATTERN = /\b(CREATE|DROP|ALTER|TRUNCATE|GRANT|REVOKE)\b/i;
2016
+ function registerExplainRoutes(app, pool) {
2017
+ app.post("/api/explain", async (c) => {
2018
+ try {
2019
+ const body = await c.req.json();
2020
+ const query = body?.query?.trim();
2021
+ if (!query) return c.json({ error: "Missing query" }, 400);
2022
+ if (DDL_PATTERN.test(query)) return c.json({ error: "DDL statements are not allowed" }, 400);
2023
+ 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);
2024
+ const client = await pool.connect();
2025
+ try {
2026
+ await client.query("SET statement_timeout = '30s'");
2027
+ await client.query("BEGIN");
2028
+ try {
2029
+ const r = await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query}`);
2030
+ await client.query("ROLLBACK");
2031
+ await client.query("RESET statement_timeout");
2032
+ return c.json({ plan: r.rows[0]["QUERY PLAN"] });
2033
+ } catch (err) {
2034
+ await client.query("ROLLBACK").catch(() => {
2035
+ });
2036
+ await client.query("RESET statement_timeout").catch(() => {
2037
+ });
2038
+ return c.json({ error: err.message }, 400);
2039
+ }
2040
+ } finally {
2041
+ client.release();
2042
+ }
2043
+ } catch (err) {
2044
+ return c.json({ error: err.message }, 500);
2045
+ }
2046
+ });
2047
+ }
2048
+
2049
+ // src/server/disk-prediction.ts
2050
+ function linearRegression(points) {
2051
+ const n = points.length;
2052
+ if (n < 2) return { slope: 0, intercept: 0, r2: 0 };
2053
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
2054
+ for (const p of points) {
2055
+ sumX += p.x;
2056
+ sumY += p.y;
2057
+ sumXY += p.x * p.y;
2058
+ sumX2 += p.x * p.x;
2059
+ sumY2 += p.y * p.y;
2060
+ }
2061
+ const denom = n * sumX2 - sumX * sumX;
2062
+ if (denom === 0) return { slope: 0, intercept: sumY / n, r2: 0 };
2063
+ const slope = (n * sumXY - sumX * sumY) / denom;
2064
+ const intercept = (sumY - slope * sumX) / n;
2065
+ const meanY = sumY / n;
2066
+ let ssTot = 0, ssRes = 0;
2067
+ for (const p of points) {
2068
+ ssTot += (p.y - meanY) ** 2;
2069
+ ssRes += (p.y - (slope * p.x + intercept)) ** 2;
2070
+ }
2071
+ const r2 = ssTot === 0 ? 1 : Math.max(0, 1 - ssRes / ssTot);
2072
+ return { slope, intercept, r2 };
2073
+ }
2074
+ var DiskPredictor = class {
2075
+ /**
2076
+ * Predict disk growth based on historical metric data.
2077
+ * @param store TimeseriesStore instance
2078
+ * @param metric Metric name (e.g. "db_size_bytes")
2079
+ * @param daysAhead How many days to project ahead
2080
+ * @param maxDiskBytes Optional max disk capacity for "days until full" calc
2081
+ */
2082
+ predict(store, metric, daysAhead, maxDiskBytes) {
2083
+ const now = Date.now();
2084
+ const data = store.query(metric, now - 30 * 24 * 60 * 60 * 1e3, now);
2085
+ if (data.length < 2) return null;
2086
+ const timeSpanMs = data[data.length - 1].timestamp - data[0].timestamp;
2087
+ if (timeSpanMs < 24 * 60 * 60 * 1e3) return null;
2088
+ const currentBytes = data[data.length - 1].value;
2089
+ const t0 = data[0].timestamp;
2090
+ const points = data.map((d) => ({
2091
+ x: (d.timestamp - t0) / (24 * 60 * 60 * 1e3),
2092
+ // days
2093
+ y: d.value
2094
+ }));
2095
+ const { slope, r2 } = linearRegression(points);
2096
+ const growthRatePerDay = slope;
2097
+ let predictedFullDate = null;
2098
+ let daysUntilFull = null;
2099
+ if (maxDiskBytes && growthRatePerDay > 0) {
2100
+ const remainingBytes = maxDiskBytes - currentBytes;
2101
+ daysUntilFull = remainingBytes / growthRatePerDay;
2102
+ if (daysUntilFull > 0 && daysUntilFull < 365 * 10) {
2103
+ predictedFullDate = new Date(now + daysUntilFull * 24 * 60 * 60 * 1e3);
2104
+ }
2105
+ }
2106
+ return {
2107
+ currentBytes,
2108
+ growthRatePerDay,
2109
+ predictedFullDate,
2110
+ daysUntilFull: daysUntilFull !== null && daysUntilFull > 0 ? daysUntilFull : null,
2111
+ confidence: r2
2112
+ };
2113
+ }
2114
+ };
2115
+
2116
+ // src/server/routes/disk.ts
2117
+ var RANGE_MAP2 = {
2118
+ "24h": 24 * 60 * 60 * 1e3,
2119
+ "7d": 7 * 24 * 60 * 60 * 1e3,
2120
+ "30d": 30 * 24 * 60 * 60 * 1e3
2121
+ };
2122
+ function registerDiskRoutes(app, pool, store) {
2123
+ const predictor = new DiskPredictor();
2124
+ app.get("/api/disk/usage", async (c) => {
2125
+ try {
2126
+ const client = await pool.connect();
2127
+ try {
2128
+ const dbRes = await client.query(`
2129
+ SELECT pg_database_size(current_database()) AS db_size,
2130
+ (SELECT setting FROM pg_settings WHERE name = 'data_directory') AS data_dir
2131
+ `);
2132
+ const { db_size, data_dir } = dbRes.rows[0];
2133
+ const tsRes = await client.query(`SELECT spcname, pg_tablespace_size(oid) AS size FROM pg_tablespace`);
2134
+ const tablespaces = tsRes.rows.map((r) => ({
2135
+ name: r.spcname,
2136
+ size: parseInt(r.size)
2137
+ }));
2138
+ const tableRes = await client.query(`
2139
+ SELECT schemaname, relname,
2140
+ pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) as total_size,
2141
+ pg_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) as table_size,
2142
+ pg_indexes_size(quote_ident(schemaname) || '.' || quote_ident(relname)) as index_size
2143
+ FROM pg_stat_user_tables
2144
+ ORDER BY pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) DESC
2145
+ LIMIT 20
2146
+ `);
2147
+ const tables = tableRes.rows.map((r) => ({
2148
+ schema: r.schemaname,
2149
+ name: r.relname,
2150
+ totalSize: parseInt(r.total_size),
2151
+ tableSize: parseInt(r.table_size),
2152
+ indexSize: parseInt(r.index_size)
2153
+ }));
2154
+ return c.json({
2155
+ dbSize: parseInt(db_size),
2156
+ dataDir: data_dir,
2157
+ tablespaces,
2158
+ tables
2159
+ });
2160
+ } finally {
2161
+ client.release();
2162
+ }
2163
+ } catch (err) {
2164
+ return c.json({ error: err.message }, 500);
2165
+ }
2166
+ });
2167
+ app.get("/api/disk/prediction", (c) => {
2168
+ try {
2169
+ const days = parseInt(c.req.query("days") || "30");
2170
+ const maxDisk = c.req.query("maxDisk") ? parseInt(c.req.query("maxDisk")) : void 0;
2171
+ const prediction = predictor.predict(store, "db_size_bytes", days, maxDisk);
2172
+ return c.json({ prediction });
2173
+ } catch (err) {
2174
+ return c.json({ error: err.message }, 500);
2175
+ }
2176
+ });
2177
+ app.get("/api/disk/history", (c) => {
2178
+ try {
2179
+ const range = c.req.query("range") || "24h";
2180
+ const rangeMs = RANGE_MAP2[range] || RANGE_MAP2["24h"];
2181
+ const now = Date.now();
2182
+ const data = store.query("db_size_bytes", now - rangeMs, now);
2183
+ return c.json(data);
2184
+ } catch (err) {
2185
+ return c.json({ error: err.message }, 500);
2186
+ }
2187
+ });
2188
+ }
2189
+
2190
+ // src/server/query-stats.ts
2191
+ var DEFAULT_RETENTION_DAYS2 = 7;
2192
+ var QueryStatsStore = class {
2193
+ db;
2194
+ insertStmt;
2195
+ retentionMs;
2196
+ prev = /* @__PURE__ */ new Map();
2197
+ timer = null;
2198
+ pruneTimer = null;
2199
+ constructor(db, retentionDays = DEFAULT_RETENTION_DAYS2) {
2200
+ this.db = db;
2201
+ this.retentionMs = retentionDays * 24 * 60 * 60 * 1e3;
2202
+ this.db.exec(`
2203
+ CREATE TABLE IF NOT EXISTS query_stats (
2204
+ timestamp INTEGER NOT NULL,
2205
+ queryid TEXT NOT NULL,
2206
+ query TEXT,
2207
+ calls INTEGER,
2208
+ total_exec_time REAL,
2209
+ mean_exec_time REAL,
2210
+ min_exec_time REAL,
2211
+ max_exec_time REAL,
2212
+ rows INTEGER,
2213
+ shared_blks_hit INTEGER,
2214
+ shared_blks_read INTEGER
2215
+ );
2216
+ CREATE INDEX IF NOT EXISTS idx_qs_queryid_ts ON query_stats(queryid, timestamp);
2217
+ `);
2218
+ this.insertStmt = this.db.prepare(
2219
+ `INSERT INTO query_stats (timestamp, queryid, query, calls, total_exec_time, mean_exec_time, min_exec_time, max_exec_time, rows, shared_blks_hit, shared_blks_read)
2220
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2221
+ );
2222
+ }
2223
+ startPeriodicSnapshot(pool, intervalMs = 5 * 60 * 1e3) {
2224
+ this.snapshot(pool).catch(
2225
+ (err) => console.error("[query-stats] Initial snapshot failed:", err.message)
2226
+ );
2227
+ this.timer = setInterval(() => {
2228
+ this.snapshot(pool).catch(
2229
+ (err) => console.error("[query-stats] Snapshot failed:", err.message)
2230
+ );
2231
+ }, intervalMs);
2232
+ this.pruneTimer = setInterval(() => this.prune(), 60 * 60 * 1e3);
2233
+ }
2234
+ stop() {
2235
+ if (this.timer) {
2236
+ clearInterval(this.timer);
2237
+ this.timer = null;
2238
+ }
2239
+ if (this.pruneTimer) {
2240
+ clearInterval(this.pruneTimer);
2241
+ this.pruneTimer = null;
2242
+ }
2243
+ }
2244
+ async snapshot(pool) {
2245
+ const client = await pool.connect();
2246
+ try {
2247
+ const extCheck = await client.query(
2248
+ "SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'"
2249
+ );
2250
+ if (extCheck.rows.length === 0) return 0;
2251
+ const r = await client.query(`
2252
+ SELECT
2253
+ queryid::text,
2254
+ query,
2255
+ calls::int,
2256
+ total_exec_time,
2257
+ mean_exec_time,
2258
+ min_exec_time,
2259
+ max_exec_time,
2260
+ rows::int,
2261
+ shared_blks_hit::int,
2262
+ shared_blks_read::int
2263
+ FROM pg_stat_statements
2264
+ WHERE query NOT LIKE '%pg_stat%'
2265
+ AND query NOT LIKE '%pg_catalog%'
2266
+ AND queryid IS NOT NULL
2267
+ `);
2268
+ const now = Date.now();
2269
+ const hasPrev = this.prev.size > 0;
2270
+ let count = 0;
2271
+ const tx = this.db.transaction((rows) => {
2272
+ for (const row of rows) {
2273
+ const prev = this.prev.get(row.queryid);
2274
+ if (hasPrev && prev) {
2275
+ const deltaCalls = Math.max(0, row.calls - prev.calls);
2276
+ if (deltaCalls === 0) continue;
2277
+ const deltaTime = Math.max(0, row.total_exec_time - prev.total_exec_time);
2278
+ const deltaRows = Math.max(0, row.rows - prev.rows);
2279
+ const deltaHit = Math.max(0, row.shared_blks_hit - prev.shared_blks_hit);
2280
+ const deltaRead = Math.max(0, row.shared_blks_read - prev.shared_blks_read);
2281
+ const meanTime = deltaCalls > 0 ? deltaTime / deltaCalls : 0;
2282
+ this.insertStmt.run(
2283
+ now,
2284
+ row.queryid,
2285
+ row.query,
2286
+ deltaCalls,
2287
+ deltaTime,
2288
+ meanTime,
2289
+ row.min_exec_time,
2290
+ row.max_exec_time,
2291
+ deltaRows,
2292
+ deltaHit,
2293
+ deltaRead
2294
+ );
2295
+ count++;
2296
+ } else if (!hasPrev) {
2297
+ this.insertStmt.run(
2298
+ now,
2299
+ row.queryid,
2300
+ row.query,
2301
+ row.calls,
2302
+ row.total_exec_time,
2303
+ row.mean_exec_time,
2304
+ row.min_exec_time,
2305
+ row.max_exec_time,
2306
+ row.rows,
2307
+ row.shared_blks_hit,
2308
+ row.shared_blks_read
2309
+ );
2310
+ count++;
2311
+ }
2312
+ }
2313
+ });
2314
+ tx(r.rows);
2315
+ this.prev.clear();
2316
+ for (const row of r.rows) {
2317
+ this.prev.set(row.queryid, row);
2318
+ }
2319
+ return count;
2320
+ } catch (err) {
2321
+ console.error("[query-stats] Error snapshotting:", err.message);
2322
+ return 0;
2323
+ } finally {
2324
+ client.release();
2325
+ }
2326
+ }
2327
+ /** Insert a row directly (for testing) */
2328
+ insertRow(row) {
2329
+ this.insertStmt.run(
2330
+ row.timestamp,
2331
+ row.queryid,
2332
+ row.query,
2333
+ row.calls,
2334
+ row.total_exec_time,
2335
+ row.mean_exec_time,
2336
+ row.min_exec_time,
2337
+ row.max_exec_time,
2338
+ row.rows,
2339
+ row.shared_blks_hit,
2340
+ row.shared_blks_read
2341
+ );
2342
+ }
2343
+ getTrend(queryid, startMs, endMs) {
2344
+ const end = endMs ?? Date.now();
2345
+ return this.db.prepare(
2346
+ `SELECT timestamp, queryid, query, calls, total_exec_time, mean_exec_time,
2347
+ min_exec_time, max_exec_time, rows, shared_blks_hit, shared_blks_read
2348
+ FROM query_stats
2349
+ WHERE queryid = ? AND timestamp >= ? AND timestamp <= ?
2350
+ ORDER BY timestamp`
2351
+ ).all(queryid, startMs, end);
2352
+ }
2353
+ getTopQueries(startMs, endMs, orderBy = "total_time", limit = 20) {
2354
+ const orderCol = orderBy === "total_time" ? "SUM(total_exec_time)" : orderBy === "calls" ? "SUM(calls)" : "AVG(mean_exec_time)";
2355
+ return this.db.prepare(
2356
+ `SELECT queryid,
2357
+ MAX(query) as query,
2358
+ SUM(calls) as total_calls,
2359
+ SUM(total_exec_time) as total_exec_time,
2360
+ AVG(mean_exec_time) as mean_exec_time,
2361
+ SUM(rows) as total_rows
2362
+ FROM query_stats
2363
+ WHERE timestamp >= ? AND timestamp <= ?
2364
+ GROUP BY queryid
2365
+ ORDER BY ${orderCol} DESC
2366
+ LIMIT ?`
2367
+ ).all(startMs, endMs, limit);
2368
+ }
2369
+ prune(retentionMs) {
2370
+ const cutoff = Date.now() - (retentionMs ?? this.retentionMs);
2371
+ const info = this.db.prepare("DELETE FROM query_stats WHERE timestamp < ?").run(cutoff);
2372
+ return info.changes;
2373
+ }
2374
+ close() {
2375
+ }
2376
+ };
2377
+
2378
+ // src/server/routes/query-stats.ts
2379
+ var RANGE_MAP3 = {
2380
+ "1h": 60 * 60 * 1e3,
2381
+ "6h": 6 * 60 * 60 * 1e3,
2382
+ "24h": 24 * 60 * 60 * 1e3,
2383
+ "7d": 7 * 24 * 60 * 60 * 1e3
2384
+ };
2385
+ function registerQueryStatsRoutes(app, store) {
2386
+ app.get("/api/query-stats/top", (c) => {
2387
+ try {
2388
+ const range = c.req.query("range") || "1h";
2389
+ const orderBy = c.req.query("orderBy") || "total_time";
2390
+ const limit = parseInt(c.req.query("limit") || "20", 10);
2391
+ const rangeMs = RANGE_MAP3[range] || RANGE_MAP3["1h"];
2392
+ const now = Date.now();
2393
+ const data = store.getTopQueries(now - rangeMs, now, orderBy, limit);
2394
+ return c.json(data);
2395
+ } catch (err) {
2396
+ return c.json({ error: err.message }, 500);
2397
+ }
2398
+ });
2399
+ app.get("/api/query-stats/trend/:queryid", (c) => {
2400
+ try {
2401
+ const queryid = c.req.param("queryid");
2402
+ const range = c.req.query("range") || "1h";
2403
+ const rangeMs = RANGE_MAP3[range] || RANGE_MAP3["1h"];
2404
+ const now = Date.now();
2405
+ const data = store.getTrend(queryid, now - rangeMs, now);
2406
+ return c.json(data);
2407
+ } catch (err) {
2408
+ return c.json({ error: err.message }, 500);
2409
+ }
2410
+ });
2411
+ }
2412
+
2413
+ // src/server/index.ts
2414
+ import Database2 from "better-sqlite3";
2415
+ import { WebSocketServer, WebSocket } from "ws";
2416
+ import http from "http";
2417
+ var __dirname = path2.dirname(fileURLToPath(import.meta.url));
2418
+ async function startServer(opts) {
2419
+ const pool = new Pool({ connectionString: opts.connectionString });
2420
+ try {
2421
+ const client = await pool.connect();
2422
+ client.release();
2423
+ } catch (err) {
2424
+ console.error(`Failed to connect to PostgreSQL: ${err.message}`);
2425
+ process.exit(1);
2426
+ }
2427
+ const longQueryThreshold = opts.longQueryThreshold || 5;
2428
+ const diskPredictor = new DiskPredictor();
2429
+ if (opts.json) {
2430
+ try {
2431
+ const [overview, advisor, databases, tables] = await Promise.all([
2432
+ getOverview(pool),
2433
+ getAdvisorReport(pool, longQueryThreshold),
2434
+ getDatabases(pool),
2435
+ getTables(pool)
2436
+ ]);
2437
+ console.log(JSON.stringify({ overview, advisor, databases, tables }, null, 2));
2438
+ } catch (err) {
2439
+ console.error(JSON.stringify({ error: err.message }));
2440
+ process.exit(1);
2441
+ }
2442
+ await pool.end();
2443
+ process.exit(0);
2444
+ }
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);
2449
+ metricsDb.pragma("journal_mode = WAL");
2450
+ const store = new TimeseriesStore(metricsDb, opts.retentionDays);
2451
+ const intervalMs = (opts.interval || 30) * 1e3;
2452
+ const collector = new Collector(pool, store, intervalMs);
2453
+ console.log(` Collecting metrics every ${intervalMs / 1e3}s...`);
2454
+ collector.start();
2455
+ const schemaDbPath = path2.join(dataDir, "schema.db");
2456
+ const schemaDb = new Database2(schemaDbPath);
2457
+ schemaDb.pragma("journal_mode = WAL");
2458
+ const snapshotIntervalMs = (opts.snapshotInterval || 6) * 60 * 60 * 1e3;
2459
+ const schemaTracker = new SchemaTracker(schemaDb, pool, snapshotIntervalMs);
2460
+ schemaTracker.start();
2461
+ console.log(" Schema change tracking enabled");
2462
+ const alertsDbPath = path2.join(dataDir, "alerts.db");
2463
+ const alertsDb = new Database2(alertsDbPath);
2464
+ alertsDb.pragma("journal_mode = WAL");
2465
+ const alertManager = new AlertManager(alertsDb, opts.webhook);
2466
+ console.log(" Alert monitoring enabled");
2467
+ const queryStatsStore = new QueryStatsStore(metricsDb, opts.retentionDays);
2468
+ const querySnapshotIntervalMs = (opts.queryStatsInterval || 5) * 60 * 1e3;
2469
+ queryStatsStore.startPeriodicSnapshot(pool, querySnapshotIntervalMs);
2470
+ console.log(` Query stats snapshots every ${querySnapshotIntervalMs / 6e4}m`);
2471
+ const app = new Hono();
2472
+ if (opts.token) {
2473
+ app.post("/api/auth", async (c) => {
2474
+ try {
2475
+ const body = await c.req.json();
2476
+ if (body?.token === opts.token) {
2477
+ c.header("Set-Cookie", `pg-dash-token=${opts.token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`);
2478
+ return c.json({ ok: true });
2479
+ }
2480
+ return c.json({ error: "Invalid token" }, 401);
2481
+ } catch {
2482
+ return c.json({ error: "Invalid request" }, 400);
2483
+ }
2484
+ });
2485
+ }
2486
+ if (opts.auth || opts.token) {
2487
+ app.use("*", async (c, next) => {
2488
+ const authHeader = c.req.header("authorization") || "";
2489
+ if (opts.token) {
2490
+ if (authHeader === `Bearer ${opts.token}`) return next();
2491
+ }
2492
+ if (opts.auth) {
2493
+ const [user, pass] = opts.auth.split(":");
2494
+ const expected = "Basic " + Buffer.from(`${user}:${pass}`).toString("base64");
2495
+ if (authHeader === expected) return next();
2496
+ }
2497
+ const url = new URL(c.req.url, "http://localhost");
2498
+ if (opts.token && url.searchParams.get("token") === opts.token) return next();
2499
+ if (opts.token) {
2500
+ const cookies = c.req.header("cookie") || "";
2501
+ const match = cookies.match(/(?:^|;\s*)pg-dash-token=([^;]*)/);
2502
+ if (match && match[1] === opts.token) return next();
2503
+ }
2504
+ if (opts.auth) {
2505
+ c.header("WWW-Authenticate", 'Basic realm="pg-dash"');
2506
+ }
2507
+ return c.text("Unauthorized", 401);
2508
+ });
2509
+ }
2510
+ registerOverviewRoutes(app, pool);
2511
+ registerMetricsRoutes(app, store, collector);
2512
+ registerActivityRoutes(app, pool);
2513
+ registerAdvisorRoutes(app, pool, longQueryThreshold);
2514
+ registerSchemaRoutes(app, pool, schemaTracker);
2515
+ registerAlertsRoutes(app, alertManager);
2516
+ registerExplainRoutes(app, pool);
2517
+ registerDiskRoutes(app, pool, store);
2518
+ registerQueryStatsRoutes(app, queryStatsStore);
1895
2519
  const uiPath = path2.resolve(__dirname, "ui");
1896
2520
  const MIME_TYPES = {
1897
2521
  ".html": "text/html",
@@ -1960,6 +2584,11 @@ async function startServer(opts) {
1960
2584
  const expected = "Basic " + Buffer.from(`${user}:${pass}`).toString("base64");
1961
2585
  if (authHeader === expected) return cb(true);
1962
2586
  }
2587
+ if (opts.token) {
2588
+ const cookies = info.req.headers["cookie"] || "";
2589
+ const match = cookies.match(/(?:^|;\s*)pg-dash-token=([^;]*)/);
2590
+ if (match && match[1] === opts.token) return cb(true);
2591
+ }
1963
2592
  cb(false, 401, "Unauthorized");
1964
2593
  } : void 0
1965
2594
  });
@@ -1974,9 +2603,7 @@ async function startServer(opts) {
1974
2603
  ws.on("error", () => clients.delete(ws));
1975
2604
  });
1976
2605
  let collectCycleCount = 0;
1977
- const origCollect = collector.collect.bind(collector);
1978
- collector.collect = async () => {
1979
- const snapshot = await origCollect();
2606
+ collector.on("collected", async (snapshot) => {
1980
2607
  if (clients.size > 0 && Object.keys(snapshot).length > 0) {
1981
2608
  const metricsMsg = JSON.stringify({ type: "metrics", data: snapshot });
1982
2609
  let activityData = [];
@@ -2012,7 +2639,7 @@ async function startServer(opts) {
2012
2639
  try {
2013
2640
  const client = await pool.connect();
2014
2641
  try {
2015
- const r = await client.query(`SELECT count(*)::int AS c FROM pg_stat_activity WHERE state = 'active' AND now() - query_start > interval '${longQueryThreshold} minutes' AND pid != pg_backend_pid()`);
2642
+ const r = await client.query(`SELECT count(*)::int AS c FROM pg_stat_activity WHERE state = 'active' AND now() - query_start > $1 * interval '1 minute' AND pid != pg_backend_pid()`, [longQueryThreshold]);
2016
2643
  alertMetrics.long_query_count = r.rows[0]?.c || 0;
2017
2644
  } finally {
2018
2645
  client.release();
@@ -2023,7 +2650,7 @@ async function startServer(opts) {
2023
2650
  try {
2024
2651
  const client = await pool.connect();
2025
2652
  try {
2026
- const r = await client.query("SELECT count(*)::int AS c FROM pg_stat_activity WHERE state = 'idle in transaction' AND now() - state_change > interval '10 minutes'");
2653
+ const r = await client.query(`SELECT count(*)::int AS c FROM pg_stat_activity WHERE state = 'idle in transaction' AND now() - state_change > $1 * interval '1 minute'`, [longQueryThreshold]);
2027
2654
  alertMetrics.idle_in_tx_count = r.rows[0]?.c || 0;
2028
2655
  } finally {
2029
2656
  client.release();
@@ -2034,11 +2661,33 @@ async function startServer(opts) {
2034
2661
  collectCycleCount++;
2035
2662
  if (collectCycleCount % 10 === 0) {
2036
2663
  try {
2037
- const report = await getAdvisorReport(pool);
2664
+ const report = await getAdvisorReport(pool, longQueryThreshold);
2038
2665
  alertMetrics.health_score = report.score;
2039
2666
  } catch (err) {
2040
2667
  console.error("[alerts] Error checking health score:", err.message);
2041
2668
  }
2669
+ try {
2670
+ if (snapshot.db_size_bytes !== void 0) {
2671
+ const dayAgo = Date.now() - 24 * 60 * 60 * 1e3;
2672
+ const oldData = store.query("db_size_bytes", dayAgo, dayAgo + 5 * 60 * 1e3);
2673
+ if (oldData.length > 0) {
2674
+ const oldVal = oldData[0].value;
2675
+ if (oldVal > 0) {
2676
+ alertMetrics.db_growth_pct_24h = (snapshot.db_size_bytes - oldVal) / oldVal * 100;
2677
+ }
2678
+ }
2679
+ }
2680
+ } catch (err) {
2681
+ console.error("[alerts] Error computing db_growth_pct_24h:", err.message);
2682
+ }
2683
+ try {
2684
+ const pred = diskPredictor.predict(store, "db_size_bytes", 30);
2685
+ if (pred?.daysUntilFull !== null && pred?.daysUntilFull !== void 0) {
2686
+ alertMetrics.days_until_full = pred.daysUntilFull;
2687
+ }
2688
+ } catch (err) {
2689
+ console.error("[alerts] Error computing days_until_full:", err.message);
2690
+ }
2042
2691
  }
2043
2692
  const fired = alertManager.checkAlerts(alertMetrics);
2044
2693
  if (fired.length > 0 && clients.size > 0) {
@@ -2053,8 +2702,7 @@ async function startServer(opts) {
2053
2702
  console.error("[alerts] Error checking alerts:", err.message);
2054
2703
  }
2055
2704
  }
2056
- return snapshot;
2057
- };
2705
+ });
2058
2706
  const bindAddr = opts.bind || "127.0.0.1";
2059
2707
  server.listen(opts.port, bindAddr, async () => {
2060
2708
  console.log(`
@@ -2073,9 +2721,10 @@ async function startServer(opts) {
2073
2721
  console.log("\n Shutting down gracefully...");
2074
2722
  collector.stop();
2075
2723
  schemaTracker.stop();
2724
+ queryStatsStore.stop();
2076
2725
  wss.close();
2077
2726
  server.close();
2078
- store.close();
2727
+ metricsDb.close();
2079
2728
  schemaDb.close();
2080
2729
  alertsDb.close();
2081
2730
  await pool.end();
@@ -2106,6 +2755,8 @@ var { values, positionals } = parseArgs({
2106
2755
  auth: { type: "string" },
2107
2756
  token: { type: "string" },
2108
2757
  webhook: { type: "string" },
2758
+ "slack-webhook": { type: "string" },
2759
+ "discord-webhook": { type: "string" },
2109
2760
  "no-open": { type: "boolean", default: false },
2110
2761
  json: { type: "boolean", default: false },
2111
2762
  host: { type: "string" },
@@ -2117,6 +2768,7 @@ var { values, positionals } = parseArgs({
2117
2768
  interval: { type: "string", short: "i" },
2118
2769
  "retention-days": { type: "string" },
2119
2770
  "snapshot-interval": { type: "string" },
2771
+ "query-stats-interval": { type: "string" },
2120
2772
  "long-query-threshold": { type: "string" },
2121
2773
  help: { type: "boolean", short: "h" },
2122
2774
  version: { type: "boolean", short: "v" },
@@ -2150,6 +2802,8 @@ Options:
2150
2802
  --auth <user:pass> Basic auth credentials (user:password)
2151
2803
  --token <token> Bearer token for authentication
2152
2804
  --webhook <url> Webhook URL for alert notifications
2805
+ --slack-webhook <url> Slack webhook URL (convenience alias)
2806
+ --discord-webhook <url> Discord webhook URL (convenience alias)
2153
2807
  --no-open Don't auto-open browser (default: opens)
2154
2808
  --json Dump health check as JSON and exit
2155
2809
  --host <host> PostgreSQL host
@@ -2161,6 +2815,7 @@ Options:
2161
2815
  -i, --interval <sec> Collection interval in seconds (default: 30)
2162
2816
  --retention-days <N> Metrics retention in days (default: 7)
2163
2817
  --snapshot-interval <h> Schema snapshot interval in hours (default: 6)
2818
+ --query-stats-interval <min> Query stats snapshot interval in minutes (default: 5)
2164
2819
  --long-query-threshold <min> Long query threshold in minutes (default: 5)
2165
2820
  --threshold <score> Health score threshold for check command (default: 70)
2166
2821
  -f, --format <fmt> Output format: text|json (default: text)
@@ -2198,7 +2853,8 @@ if (subcommand === "check") {
2198
2853
  const { getAdvisorReport: getAdvisorReport2 } = await Promise.resolve().then(() => (init_advisor(), advisor_exports));
2199
2854
  const pool = new Pool2({ connectionString });
2200
2855
  try {
2201
- const report = await getAdvisorReport2(pool);
2856
+ const lqt = parseInt(values["long-query-threshold"] || process.env.PG_DASH_LONG_QUERY_THRESHOLD || "5", 10);
2857
+ const report = await getAdvisorReport2(pool, lqt);
2202
2858
  if (format === "json") {
2203
2859
  console.log(JSON.stringify(report, null, 2));
2204
2860
  } else {
@@ -2259,10 +2915,11 @@ if (subcommand === "check") {
2259
2915
  const interval = values.interval ? parseInt(values.interval, 10) : void 0;
2260
2916
  const retentionDays = parseInt(values["retention-days"] || process.env.PG_DASH_RETENTION_DAYS || "7", 10);
2261
2917
  const snapshotInterval = parseInt(values["snapshot-interval"] || process.env.PG_DASH_SNAPSHOT_INTERVAL || "6", 10);
2918
+ const queryStatsInterval = parseInt(values["query-stats-interval"] || process.env.PG_DASH_QUERY_STATS_INTERVAL || "5", 10);
2262
2919
  const longQueryThreshold = parseInt(values["long-query-threshold"] || process.env.PG_DASH_LONG_QUERY_THRESHOLD || "5", 10);
2263
2920
  const auth = values.auth || void 0;
2264
2921
  const token = values.token || void 0;
2265
- const webhook = values.webhook || void 0;
2922
+ const webhook = values["slack-webhook"] || values["discord-webhook"] || values.webhook || void 0;
2266
2923
  if (bind === "0.0.0.0" && !auth && !token) {
2267
2924
  console.warn("\n \u26A0\uFE0F WARNING: Dashboard is exposed without authentication. Use --auth or --token.\n");
2268
2925
  }
@@ -2276,6 +2933,7 @@ if (subcommand === "check") {
2276
2933
  interval,
2277
2934
  retentionDays,
2278
2935
  snapshotInterval,
2936
+ queryStatsInterval,
2279
2937
  longQueryThreshold,
2280
2938
  auth,
2281
2939
  token,