postgresai 0.14.0 → 0.15.0-dev.10
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 +3 -1
- package/bin/postgres-ai.ts +712 -108
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +2755 -572
- package/instances.demo.yml +14 -0
- package/lib/checkup-api.ts +25 -6
- package/lib/checkup.ts +465 -8
- package/lib/config.ts +7 -0
- package/lib/init.ts +196 -4
- package/lib/issues.ts +72 -72
- package/lib/mcp-server.ts +90 -0
- package/lib/metrics-loader.ts +6 -1
- package/lib/reports.ts +373 -0
- package/lib/storage.ts +291 -0
- package/lib/supabase.ts +8 -1
- package/lib/util.ts +7 -1
- package/package.json +4 -4
- package/scripts/embed-checkup-dictionary.ts +9 -0
- package/scripts/embed-metrics.ts +5 -0
- package/scripts/generate-release-notes.ts +283 -48
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +139 -0
- package/test/checkup.test.ts +1316 -2
- package/test/compose-cmd.test.ts +120 -0
- package/test/config-consistency.test.ts +321 -5
- package/test/init.integration.test.ts +27 -28
- package/test/init.test.ts +534 -6
- package/test/issues.cli.test.ts +230 -1
- package/test/mcp-server.test.ts +516 -0
- package/test/monitoring.test.ts +339 -0
- package/test/permission-check-sql.test.ts +116 -0
- package/test/reports.cli.test.ts +793 -0
- package/test/reports.test.ts +977 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/storage.test.ts +761 -0
- package/test/test-utils.ts +51 -2
- package/test/upgrade.test.ts +422 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Demo PostgreSQL instance for `pgai mon local-install --demo`
|
|
2
|
+
# This file is copied to instances.yml during demo mode setup.
|
|
3
|
+
|
|
4
|
+
- name: target_database
|
|
5
|
+
conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database
|
|
6
|
+
preset_metrics: full
|
|
7
|
+
custom_metrics:
|
|
8
|
+
is_enabled: true
|
|
9
|
+
group: default
|
|
10
|
+
custom_tags:
|
|
11
|
+
env: demo
|
|
12
|
+
cluster: local
|
|
13
|
+
node_name: node-01
|
|
14
|
+
sink_type: ~sink_type~ # sed token substituted by generate-pgwatch-sources.sh; values: postgres, prometheus
|
package/lib/checkup-api.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as http from "http";
|
|
1
2
|
import * as https from "https";
|
|
2
3
|
import { URL } from "url";
|
|
3
4
|
import { normalizeBaseUrl } from "./util";
|
|
@@ -27,7 +28,7 @@ function isRetryableError(err: unknown): boolean {
|
|
|
27
28
|
// Retry on server errors (5xx), not on client errors (4xx)
|
|
28
29
|
return err.statusCode >= 500 && err.statusCode < 600;
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
+
|
|
31
32
|
// Check for Node.js error codes (works on Error and Error-like objects)
|
|
32
33
|
if (typeof err === "object" && err !== null && "code" in err) {
|
|
33
34
|
const code = String((err as { code: unknown }).code);
|
|
@@ -35,7 +36,7 @@ function isRetryableError(err: unknown): boolean {
|
|
|
35
36
|
return true;
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
+
|
|
39
40
|
if (err instanceof Error) {
|
|
40
41
|
const msg = err.message.toLowerCase();
|
|
41
42
|
// Retry on network-related errors based on message content
|
|
@@ -49,7 +50,7 @@ function isRetryableError(err: unknown): boolean {
|
|
|
49
50
|
msg.includes("network")
|
|
50
51
|
);
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
|
|
53
54
|
return false;
|
|
54
55
|
}
|
|
55
56
|
|
|
@@ -226,7 +227,25 @@ async function postRpc<T>(params: {
|
|
|
226
227
|
resolve(value);
|
|
227
228
|
};
|
|
228
229
|
|
|
229
|
-
|
|
230
|
+
// Transport is picked from the URL protocol so the CLI can talk to a
|
|
231
|
+
// local-dev PostgREST over plain HTTP. Production URLs are always HTTPS;
|
|
232
|
+
// to guard against typos (e.g. a missing 's' in 'https://') silently
|
|
233
|
+
// leaking the API key in cleartext, refuse HTTP to non-loopback hosts
|
|
234
|
+
// unless the operator explicitly opts in via CHECKUP_ALLOW_HTTP=1.
|
|
235
|
+
if (url.protocol === "http:") {
|
|
236
|
+
// WHATWG URL keeps IPv6 literals bracketed in .hostname
|
|
237
|
+
// (e.g. `[::1]`), so strip the brackets before matching the allowlist.
|
|
238
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
239
|
+
const isLoopback = ["localhost", "127.0.0.1", "::1"].includes(hostname);
|
|
240
|
+
if (!isLoopback && process.env.CHECKUP_ALLOW_HTTP !== "1") {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Refusing to send API key over plaintext HTTP to '${url.host}'. ` +
|
|
243
|
+
`Use https://, a loopback hostname, or set CHECKUP_ALLOW_HTTP=1.`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const transport = url.protocol === "http:" ? http : https;
|
|
248
|
+
const req = transport.request(
|
|
230
249
|
url,
|
|
231
250
|
{
|
|
232
251
|
method: "POST",
|
|
@@ -277,7 +296,7 @@ async function postRpc<T>(params: {
|
|
|
277
296
|
req.destroy(); // Backup: ensure request is terminated
|
|
278
297
|
settledReject(new Error(`RPC ${rpcName} timed out after ${timeoutMs}ms (no response)`));
|
|
279
298
|
}, timeoutMs);
|
|
280
|
-
|
|
299
|
+
|
|
281
300
|
req.on("error", (err: Error) => {
|
|
282
301
|
// Handle abort as timeout (may already be rejected by timeout handler)
|
|
283
302
|
if (err.name === "AbortError" || (err as any).code === "ABORT_ERR") {
|
|
@@ -295,7 +314,7 @@ async function postRpc<T>(params: {
|
|
|
295
314
|
settledReject(err);
|
|
296
315
|
}
|
|
297
316
|
});
|
|
298
|
-
|
|
317
|
+
|
|
299
318
|
req.write(body);
|
|
300
319
|
req.end();
|
|
301
320
|
});
|
package/lib/checkup.ts
CHANGED
|
@@ -243,6 +243,50 @@ export interface RedundantIndex {
|
|
|
243
243
|
redundant_to_parse_error?: string;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/**
|
|
247
|
+
* I/O statistics by backend type (I001) - matches I001.schema.json backendIOStats
|
|
248
|
+
*/
|
|
249
|
+
export interface BackendIOStats {
|
|
250
|
+
backend_type: string;
|
|
251
|
+
reads: number;
|
|
252
|
+
/** Read MiB. The historical `_mb` suffix is retained for schema compatibility. */
|
|
253
|
+
read_bytes_mb: number;
|
|
254
|
+
read_time_ms: number;
|
|
255
|
+
writes: number;
|
|
256
|
+
/** Written MiB. The historical `_mb` suffix is retained for schema compatibility. */
|
|
257
|
+
write_bytes_mb: number;
|
|
258
|
+
write_time_ms: number;
|
|
259
|
+
writebacks: number;
|
|
260
|
+
/** Writeback MiB. The historical `_mb` suffix is retained for schema compatibility. */
|
|
261
|
+
writeback_bytes_mb: number;
|
|
262
|
+
writeback_time_ms: number;
|
|
263
|
+
fsyncs: number;
|
|
264
|
+
fsync_time_ms: number;
|
|
265
|
+
/** Relation extension operations reported by pg_stat_io for PostgreSQL 16+. */
|
|
266
|
+
extends?: number;
|
|
267
|
+
/** Extended MiB; PG16 derives extends * op_bytes, PG18+ uses native extend_bytes. */
|
|
268
|
+
extend_bytes_mb?: number;
|
|
269
|
+
hits: number;
|
|
270
|
+
evictions: number;
|
|
271
|
+
reuses: number;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* I/O statistics analysis summary (I001)
|
|
276
|
+
*/
|
|
277
|
+
export interface IOAnalysis {
|
|
278
|
+
total_read_mb: number;
|
|
279
|
+
total_write_mb: number;
|
|
280
|
+
/** read_time_ms + write_time_ms across backends. Excludes writeback and fsync time. */
|
|
281
|
+
total_io_time_ms: number;
|
|
282
|
+
/** Buffer hit ratio: hits / (hits + reads) * 100. */
|
|
283
|
+
read_hit_ratio_pct: number;
|
|
284
|
+
/** Average read latency, or null when there are no reads. */
|
|
285
|
+
avg_read_time_ms: number | null;
|
|
286
|
+
/** Average write latency, or null when there are no writes. */
|
|
287
|
+
avg_write_time_ms: number | null;
|
|
288
|
+
}
|
|
289
|
+
|
|
246
290
|
/**
|
|
247
291
|
* Node result for reports
|
|
248
292
|
*/
|
|
@@ -284,7 +328,7 @@ export function parseVersionNum(versionNum: string): { major: string; minor: str
|
|
|
284
328
|
} catch (err) {
|
|
285
329
|
// parseInt shouldn't throw, but handle edge cases defensively
|
|
286
330
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
287
|
-
console.
|
|
331
|
+
console.error(`[parseVersionNum] Warning: Failed to parse "${versionNum}": ${errorMsg}`);
|
|
288
332
|
return { major: "", minor: "" };
|
|
289
333
|
}
|
|
290
334
|
}
|
|
@@ -729,7 +773,7 @@ export async function getStatsReset(client: Client, pgMajorVersion: number = 16)
|
|
|
729
773
|
} catch (err) {
|
|
730
774
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
731
775
|
postmasterStartupError = `Failed to query postmaster start time: ${errorMsg}`;
|
|
732
|
-
console.
|
|
776
|
+
console.error(`[getStatsReset] Warning: ${postmasterStartupError}`);
|
|
733
777
|
}
|
|
734
778
|
|
|
735
779
|
const statsResult: StatsReset = {
|
|
@@ -811,7 +855,7 @@ export async function getRedundantIndexes(client: Client, pgMajorVersion: number
|
|
|
811
855
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
812
856
|
const indexName = String(transformed.index_name || "unknown");
|
|
813
857
|
parseError = `Failed to parse redundant_to_json: ${errorMsg}`;
|
|
814
|
-
console.
|
|
858
|
+
console.error(`[H004] Warning: ${parseError} for index "${indexName}"`);
|
|
815
859
|
}
|
|
816
860
|
|
|
817
861
|
const result: RedundantIndex = {
|
|
@@ -905,7 +949,7 @@ function resolveBuildTs(): string | null {
|
|
|
905
949
|
// package.json not found is expected in some environments (e.g., bundled) - debug only
|
|
906
950
|
if (process.env.DEBUG) {
|
|
907
951
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
908
|
-
console.
|
|
952
|
+
console.error(`[resolveBuildTs] Could not stat package.json, using current time: ${errorMsg}`);
|
|
909
953
|
}
|
|
910
954
|
return new Date().toISOString();
|
|
911
955
|
}
|
|
@@ -1103,7 +1147,7 @@ async function generateD004(client: Client, nodeName: string): Promise<Report> {
|
|
|
1103
1147
|
}
|
|
1104
1148
|
} catch (err) {
|
|
1105
1149
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1106
|
-
console.
|
|
1150
|
+
console.error(`[D004] Error querying pg_stat_statements: ${errorMsg}`);
|
|
1107
1151
|
pgssError = errorMsg;
|
|
1108
1152
|
}
|
|
1109
1153
|
|
|
@@ -1156,7 +1200,7 @@ async function generateD004(client: Client, nodeName: string): Promise<Report> {
|
|
|
1156
1200
|
}
|
|
1157
1201
|
} catch (err) {
|
|
1158
1202
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1159
|
-
console.
|
|
1203
|
+
console.error(`[D004] Error querying pg_stat_kcache: ${errorMsg}`);
|
|
1160
1204
|
kcacheError = errorMsg;
|
|
1161
1205
|
}
|
|
1162
1206
|
|
|
@@ -1242,6 +1286,236 @@ async function generateF001(client: Client, nodeName: string): Promise<Report> {
|
|
|
1242
1286
|
return report;
|
|
1243
1287
|
}
|
|
1244
1288
|
|
|
1289
|
+
/**
|
|
1290
|
+
* Generate F004 report - Autovacuum: heap bloat (estimated)
|
|
1291
|
+
*
|
|
1292
|
+
* Estimates table bloat based on statistical analysis of table pages vs expected pages.
|
|
1293
|
+
* Uses pg_stats for column statistics to estimate row sizes.
|
|
1294
|
+
* SQL loaded from config/pgwatch-prometheus/metrics.yml (pg_table_bloat metric).
|
|
1295
|
+
*/
|
|
1296
|
+
async function generateF004(client: Client, nodeName: string): Promise<Report> {
|
|
1297
|
+
const report = createBaseReport("F004", "Autovacuum: heap bloat (estimated)", nodeName);
|
|
1298
|
+
const postgresVersion = await getPostgresVersion(client);
|
|
1299
|
+
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10);
|
|
1300
|
+
|
|
1301
|
+
interface BloatedTable {
|
|
1302
|
+
schema_name: string;
|
|
1303
|
+
table_name: string;
|
|
1304
|
+
real_size: number;
|
|
1305
|
+
extra_size: number;
|
|
1306
|
+
extra_pct: number;
|
|
1307
|
+
bloat_size: number;
|
|
1308
|
+
bloat_pct: number;
|
|
1309
|
+
fillfactor: number;
|
|
1310
|
+
last_vacuum: string | null;
|
|
1311
|
+
last_vacuum_epoch: number;
|
|
1312
|
+
real_size_pretty: string;
|
|
1313
|
+
extra_size_pretty: string;
|
|
1314
|
+
bloat_size_pretty: string;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
let bloatedTables: BloatedTable[] = [];
|
|
1318
|
+
|
|
1319
|
+
try {
|
|
1320
|
+
// Get bloat data
|
|
1321
|
+
const sql = getMetricSql(METRIC_NAMES.F004, pgMajorVersion);
|
|
1322
|
+
const bloatResult = await client.query(sql);
|
|
1323
|
+
|
|
1324
|
+
// Get vacuum stats for all tables
|
|
1325
|
+
const vacuumStatsResult = await client.query(`
|
|
1326
|
+
SELECT schemaname, relname, last_vacuum, last_autovacuum
|
|
1327
|
+
FROM pg_stat_user_tables
|
|
1328
|
+
`);
|
|
1329
|
+
const vacuumStats = new Map<string, { last_vacuum: string | null; last_vacuum_epoch: number }>();
|
|
1330
|
+
for (const row of vacuumStatsResult.rows) {
|
|
1331
|
+
const key = `${row.schemaname}.${row.relname}`;
|
|
1332
|
+
// Use last_autovacuum if last_vacuum is null, otherwise prefer last_vacuum
|
|
1333
|
+
const vacuumTime = row.last_vacuum || row.last_autovacuum;
|
|
1334
|
+
vacuumStats.set(key, {
|
|
1335
|
+
last_vacuum: vacuumTime ? new Date(vacuumTime).toISOString() : null,
|
|
1336
|
+
last_vacuum_epoch: vacuumTime ? Math.floor(new Date(vacuumTime).getTime() / 1000) : 0,
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
bloatedTables = bloatResult.rows.map((row) => {
|
|
1341
|
+
const t = transformMetricRow(row);
|
|
1342
|
+
const schemaName = String(t.schemaname || "");
|
|
1343
|
+
const tableName = String(t.tblname || "");
|
|
1344
|
+
const realSizeBytes = Math.round((parseFloat(String(t.real_size_mib)) || 0) * 1024 * 1024);
|
|
1345
|
+
const extraSize = parseInt(String(t.extra_size || 0), 10);
|
|
1346
|
+
const bloatSize = parseInt(String(t.bloat_size || 0), 10);
|
|
1347
|
+
|
|
1348
|
+
const vacuumInfo = vacuumStats.get(`${schemaName}.${tableName}`) || {
|
|
1349
|
+
last_vacuum: null,
|
|
1350
|
+
last_vacuum_epoch: 0,
|
|
1351
|
+
};
|
|
1352
|
+
|
|
1353
|
+
return {
|
|
1354
|
+
schema_name: schemaName,
|
|
1355
|
+
table_name: tableName,
|
|
1356
|
+
real_size: realSizeBytes,
|
|
1357
|
+
extra_size: extraSize,
|
|
1358
|
+
extra_pct: parseFloat(String(t.extra_pct)) || 0,
|
|
1359
|
+
bloat_size: bloatSize,
|
|
1360
|
+
bloat_pct: parseFloat(String(t.bloat_pct)) || 0,
|
|
1361
|
+
fillfactor: parseInt(String(t.fillfactor || 100), 10),
|
|
1362
|
+
last_vacuum: vacuumInfo.last_vacuum,
|
|
1363
|
+
last_vacuum_epoch: vacuumInfo.last_vacuum_epoch,
|
|
1364
|
+
real_size_pretty: formatBytes(realSizeBytes),
|
|
1365
|
+
extra_size_pretty: formatBytes(extraSize),
|
|
1366
|
+
bloat_size_pretty: formatBytes(bloatSize),
|
|
1367
|
+
};
|
|
1368
|
+
});
|
|
1369
|
+
} catch (err) {
|
|
1370
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1371
|
+
console.error(`[F004] Error estimating table bloat: ${errorMsg}`);
|
|
1372
|
+
if (errorMsg.includes("postgres_ai.")) {
|
|
1373
|
+
console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Get database info
|
|
1378
|
+
const { datname: dbName, size_bytes: dbSizeBytes } = await getCurrentDatabaseInfo(client, pgMajorVersion);
|
|
1379
|
+
|
|
1380
|
+
// Calculate totals
|
|
1381
|
+
const totalCount = bloatedTables.length;
|
|
1382
|
+
const totalBloatSizeBytes = bloatedTables.reduce((sum, t) => sum + t.bloat_size, 0);
|
|
1383
|
+
|
|
1384
|
+
const dbEntry = {
|
|
1385
|
+
bloated_tables: bloatedTables,
|
|
1386
|
+
total_count: totalCount,
|
|
1387
|
+
total_bloat_size_bytes: totalBloatSizeBytes,
|
|
1388
|
+
total_bloat_size_pretty: formatBytes(totalBloatSizeBytes),
|
|
1389
|
+
database_size_bytes: dbSizeBytes,
|
|
1390
|
+
database_size_pretty: formatBytes(dbSizeBytes),
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1393
|
+
report.results[nodeName] = {
|
|
1394
|
+
data: { [dbName]: dbEntry },
|
|
1395
|
+
postgres_version: postgresVersion,
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
return report;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* Generate F005 report - Autovacuum: index bloat (estimated)
|
|
1403
|
+
*
|
|
1404
|
+
* Estimates B-tree index bloat based on statistical analysis of index pages vs expected pages.
|
|
1405
|
+
* SQL loaded from config/pgwatch-prometheus/metrics.yml (pg_btree_bloat metric).
|
|
1406
|
+
*/
|
|
1407
|
+
async function generateF005(client: Client, nodeName: string): Promise<Report> {
|
|
1408
|
+
const report = createBaseReport("F005", "Autovacuum: index bloat (estimated)", nodeName);
|
|
1409
|
+
const postgresVersion = await getPostgresVersion(client);
|
|
1410
|
+
const pgMajorVersion = parseInt(postgresVersion.server_major_ver, 10);
|
|
1411
|
+
|
|
1412
|
+
interface BloatedIndex {
|
|
1413
|
+
schema_name: string;
|
|
1414
|
+
table_name: string;
|
|
1415
|
+
index_name: string;
|
|
1416
|
+
real_size: number;
|
|
1417
|
+
table_size: number;
|
|
1418
|
+
extra_size: number;
|
|
1419
|
+
extra_pct: number;
|
|
1420
|
+
bloat_size: number;
|
|
1421
|
+
bloat_pct: number;
|
|
1422
|
+
fillfactor: number;
|
|
1423
|
+
last_vacuum: string | null;
|
|
1424
|
+
last_vacuum_epoch: number;
|
|
1425
|
+
real_size_pretty: string;
|
|
1426
|
+
table_size_pretty: string;
|
|
1427
|
+
extra_size_pretty: string;
|
|
1428
|
+
bloat_size_pretty: string;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
let bloatedIndexes: BloatedIndex[] = [];
|
|
1432
|
+
|
|
1433
|
+
try {
|
|
1434
|
+
// Get bloat data
|
|
1435
|
+
const sql = getMetricSql(METRIC_NAMES.F005, pgMajorVersion);
|
|
1436
|
+
const bloatResult = await client.query(sql);
|
|
1437
|
+
|
|
1438
|
+
// Get vacuum stats for all tables (indexes inherit vacuum time from their table)
|
|
1439
|
+
const vacuumStatsResult = await client.query(`
|
|
1440
|
+
SELECT schemaname, relname, last_vacuum, last_autovacuum
|
|
1441
|
+
FROM pg_stat_user_tables
|
|
1442
|
+
`);
|
|
1443
|
+
const vacuumStats = new Map<string, { last_vacuum: string | null; last_vacuum_epoch: number }>();
|
|
1444
|
+
for (const row of vacuumStatsResult.rows) {
|
|
1445
|
+
const key = `${row.schemaname}.${row.relname}`;
|
|
1446
|
+
const vacuumTime = row.last_vacuum || row.last_autovacuum;
|
|
1447
|
+
vacuumStats.set(key, {
|
|
1448
|
+
last_vacuum: vacuumTime ? new Date(vacuumTime).toISOString() : null,
|
|
1449
|
+
last_vacuum_epoch: vacuumTime ? Math.floor(new Date(vacuumTime).getTime() / 1000) : 0,
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
bloatedIndexes = bloatResult.rows.map((row) => {
|
|
1454
|
+
const t = transformMetricRow(row);
|
|
1455
|
+
const schemaName = String(t.schemaname || "");
|
|
1456
|
+
const tableName = String(t.tblname || "");
|
|
1457
|
+
const indexName = String(t.idxname || "");
|
|
1458
|
+
const realSizeBytes = Math.round((parseFloat(String(t.real_size_mib)) || 0) * 1024 * 1024);
|
|
1459
|
+
const tableSizeBytes = Math.round((parseFloat(String(t.table_size_mib)) || 0) * 1024 * 1024);
|
|
1460
|
+
const extraSize = parseInt(String(t.extra_size || 0), 10);
|
|
1461
|
+
const bloatSize = parseInt(String(t.bloat_size || 0), 10);
|
|
1462
|
+
|
|
1463
|
+
const vacuumInfo = vacuumStats.get(`${schemaName}.${tableName}`) || {
|
|
1464
|
+
last_vacuum: null,
|
|
1465
|
+
last_vacuum_epoch: 0,
|
|
1466
|
+
};
|
|
1467
|
+
|
|
1468
|
+
return {
|
|
1469
|
+
schema_name: schemaName,
|
|
1470
|
+
table_name: tableName,
|
|
1471
|
+
index_name: indexName,
|
|
1472
|
+
real_size: realSizeBytes,
|
|
1473
|
+
table_size: tableSizeBytes,
|
|
1474
|
+
extra_size: extraSize,
|
|
1475
|
+
extra_pct: parseFloat(String(t.extra_pct)) || 0,
|
|
1476
|
+
bloat_size: bloatSize,
|
|
1477
|
+
bloat_pct: parseFloat(String(t.bloat_pct)) || 0,
|
|
1478
|
+
fillfactor: parseInt(String(t.fillfactor || 90), 10),
|
|
1479
|
+
last_vacuum: vacuumInfo.last_vacuum,
|
|
1480
|
+
last_vacuum_epoch: vacuumInfo.last_vacuum_epoch,
|
|
1481
|
+
real_size_pretty: formatBytes(realSizeBytes),
|
|
1482
|
+
table_size_pretty: formatBytes(tableSizeBytes),
|
|
1483
|
+
extra_size_pretty: formatBytes(extraSize),
|
|
1484
|
+
bloat_size_pretty: formatBytes(bloatSize),
|
|
1485
|
+
};
|
|
1486
|
+
});
|
|
1487
|
+
} catch (err) {
|
|
1488
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1489
|
+
console.error(`[F005] Error estimating index bloat: ${errorMsg}`);
|
|
1490
|
+
if (errorMsg.includes("postgres_ai.")) {
|
|
1491
|
+
console.error(` Hint: Run "postgresai prepare-db <connection>" to create required objects.`);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// Get database info
|
|
1496
|
+
const { datname: dbName, size_bytes: dbSizeBytes } = await getCurrentDatabaseInfo(client, pgMajorVersion);
|
|
1497
|
+
|
|
1498
|
+
// Calculate totals
|
|
1499
|
+
const totalCount = bloatedIndexes.length;
|
|
1500
|
+
const totalBloatSizeBytes = bloatedIndexes.reduce((sum, idx) => sum + idx.bloat_size, 0);
|
|
1501
|
+
|
|
1502
|
+
const dbEntry = {
|
|
1503
|
+
bloated_indexes: bloatedIndexes,
|
|
1504
|
+
total_count: totalCount,
|
|
1505
|
+
total_bloat_size_bytes: totalBloatSizeBytes,
|
|
1506
|
+
total_bloat_size_pretty: formatBytes(totalBloatSizeBytes),
|
|
1507
|
+
database_size_bytes: dbSizeBytes,
|
|
1508
|
+
database_size_pretty: formatBytes(dbSizeBytes),
|
|
1509
|
+
};
|
|
1510
|
+
|
|
1511
|
+
report.results[nodeName] = {
|
|
1512
|
+
data: { [dbName]: dbEntry },
|
|
1513
|
+
postgres_version: postgresVersion,
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
return report;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1245
1519
|
/**
|
|
1246
1520
|
* Generate G001 report - Memory-related settings
|
|
1247
1521
|
*/
|
|
@@ -1340,7 +1614,7 @@ async function generateG001(client: Client, nodeName: string): Promise<Report> {
|
|
|
1340
1614
|
}
|
|
1341
1615
|
} catch (err) {
|
|
1342
1616
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1343
|
-
console.
|
|
1617
|
+
console.error(`[G001] Error calculating memory usage: ${errorMsg}`);
|
|
1344
1618
|
memoryError = errorMsg;
|
|
1345
1619
|
}
|
|
1346
1620
|
|
|
@@ -1418,7 +1692,7 @@ async function generateG003(client: Client, nodeName: string): Promise<Report> {
|
|
|
1418
1692
|
}
|
|
1419
1693
|
} catch (err) {
|
|
1420
1694
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1421
|
-
console.
|
|
1695
|
+
console.error(`[G003] Error querying deadlock stats: ${errorMsg}`);
|
|
1422
1696
|
deadlockError = errorMsg;
|
|
1423
1697
|
}
|
|
1424
1698
|
|
|
@@ -1434,6 +1708,186 @@ async function generateG003(client: Client, nodeName: string): Promise<Report> {
|
|
|
1434
1708
|
return report;
|
|
1435
1709
|
}
|
|
1436
1710
|
|
|
1711
|
+
/**
|
|
1712
|
+
* Get I/O statistics from pg_stat_io (PostgreSQL 16+).
|
|
1713
|
+
* Uses 'pg_stat_io' metric from metrics.yml.
|
|
1714
|
+
*
|
|
1715
|
+
* @param client - Connected PostgreSQL client
|
|
1716
|
+
* @param pgMajorVersion - PostgreSQL major version; defaults to 0 so omitted versions return unavailable
|
|
1717
|
+
* @param metricSqlOverride - Optional SQL override; empty or placeholder SQL returns [] without querying
|
|
1718
|
+
* @returns Array of I/O stats by backend type, or empty array if unavailable
|
|
1719
|
+
*/
|
|
1720
|
+
export async function getIOStatistics(
|
|
1721
|
+
client: Client,
|
|
1722
|
+
pgMajorVersion: number = 0,
|
|
1723
|
+
metricSqlOverride?: string
|
|
1724
|
+
): Promise<BackendIOStats[]> {
|
|
1725
|
+
// pg_stat_io requires PostgreSQL 16+
|
|
1726
|
+
if (pgMajorVersion < 16) {
|
|
1727
|
+
return [];
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
try {
|
|
1731
|
+
const sql = metricSqlOverride ?? getMetricSql(METRIC_NAMES.I001, pgMajorVersion);
|
|
1732
|
+
// Skip if metric returns empty/placeholder SQL
|
|
1733
|
+
if (!sql || sql.trim().startsWith(";")) {
|
|
1734
|
+
return [];
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const result = await client.query(sql);
|
|
1738
|
+
return result.rows.map((row) => {
|
|
1739
|
+
const transformed = transformMetricRow(row);
|
|
1740
|
+
return {
|
|
1741
|
+
backend_type: String(transformed.backend_type || "unknown"),
|
|
1742
|
+
reads: parseInt(String(transformed.reads || 0), 10),
|
|
1743
|
+
read_bytes_mb: parseInt(String(transformed.read_bytes_mb || 0), 10),
|
|
1744
|
+
read_time_ms: parseInt(String(transformed.read_time_ms || 0), 10),
|
|
1745
|
+
writes: parseInt(String(transformed.writes || 0), 10),
|
|
1746
|
+
write_bytes_mb: parseInt(String(transformed.write_bytes_mb || 0), 10),
|
|
1747
|
+
write_time_ms: parseInt(String(transformed.write_time_ms || 0), 10),
|
|
1748
|
+
writebacks: parseInt(String(transformed.writebacks || 0), 10),
|
|
1749
|
+
writeback_bytes_mb: parseInt(String(transformed.writeback_bytes_mb || 0), 10),
|
|
1750
|
+
writeback_time_ms: parseInt(String(transformed.writeback_time_ms || 0), 10),
|
|
1751
|
+
fsyncs: parseInt(String(transformed.fsyncs || 0), 10),
|
|
1752
|
+
fsync_time_ms: parseInt(String(transformed.fsync_time_ms || 0), 10),
|
|
1753
|
+
extends: parseInt(String(transformed.extends || 0), 10),
|
|
1754
|
+
extend_bytes_mb: parseInt(String(transformed.extend_bytes_mb || 0), 10),
|
|
1755
|
+
hits: parseInt(String(transformed.hits || 0), 10),
|
|
1756
|
+
evictions: parseInt(String(transformed.evictions || 0), 10),
|
|
1757
|
+
reuses: parseInt(String(transformed.reuses || 0), 10),
|
|
1758
|
+
};
|
|
1759
|
+
});
|
|
1760
|
+
} catch (err) {
|
|
1761
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1762
|
+
console.log(`[I001] Error fetching I/O statistics: ${errorMsg}`);
|
|
1763
|
+
return [];
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/**
|
|
1768
|
+
* Generate I001 report - I/O statistics (pg_stat_io)
|
|
1769
|
+
*
|
|
1770
|
+
* This report collects I/O statistics from pg_stat_io (PostgreSQL 16+),
|
|
1771
|
+
* providing insights into read/write operations by backend type.
|
|
1772
|
+
*
|
|
1773
|
+
* @param client - Connected PostgreSQL client
|
|
1774
|
+
* @param nodeName - Node name for the report payload
|
|
1775
|
+
* @returns I001 report payload
|
|
1776
|
+
*/
|
|
1777
|
+
async function generateI001(client: Client, nodeName: string): Promise<Report> {
|
|
1778
|
+
const report = createBaseReport("I001", "I/O statistics (pg_stat_io)", nodeName);
|
|
1779
|
+
const postgresVersion = await getPostgresVersion(client);
|
|
1780
|
+
const parsedPgMajorVersion = parseInt(postgresVersion.server_major_ver, 10);
|
|
1781
|
+
const pgMajorVersion = Number.isFinite(parsedPgMajorVersion) ? parsedPgMajorVersion : 0;
|
|
1782
|
+
|
|
1783
|
+
// pg_stat_io requires PostgreSQL 16+
|
|
1784
|
+
if (pgMajorVersion < 16) {
|
|
1785
|
+
report.results[nodeName] = {
|
|
1786
|
+
data: {
|
|
1787
|
+
available: false,
|
|
1788
|
+
min_version_required: "16",
|
|
1789
|
+
by_backend_type: [],
|
|
1790
|
+
analysis: {
|
|
1791
|
+
total_read_mb: 0,
|
|
1792
|
+
total_write_mb: 0,
|
|
1793
|
+
total_io_time_ms: 0,
|
|
1794
|
+
read_hit_ratio_pct: 0,
|
|
1795
|
+
avg_read_time_ms: null,
|
|
1796
|
+
avg_write_time_ms: null,
|
|
1797
|
+
},
|
|
1798
|
+
stats_reset_s: null,
|
|
1799
|
+
},
|
|
1800
|
+
postgres_version: postgresVersion,
|
|
1801
|
+
};
|
|
1802
|
+
return report;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
const ioStats = await getIOStatistics(client, pgMajorVersion);
|
|
1806
|
+
|
|
1807
|
+
// Sort by backend_type, putting 'total' first if present
|
|
1808
|
+
ioStats.sort((a, b) => {
|
|
1809
|
+
if (a.backend_type === "total") return -1;
|
|
1810
|
+
if (b.backend_type === "total") return 1;
|
|
1811
|
+
return a.backend_type.localeCompare(b.backend_type);
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
// Find 'total' row for analysis, or sum all rows if not present
|
|
1815
|
+
let totalStats = ioStats.find((s) => s.backend_type === "total");
|
|
1816
|
+
if (!totalStats && ioStats.length > 0) {
|
|
1817
|
+
totalStats = {
|
|
1818
|
+
backend_type: "total",
|
|
1819
|
+
reads: ioStats.reduce((sum, s) => sum + s.reads, 0),
|
|
1820
|
+
read_bytes_mb: ioStats.reduce((sum, s) => sum + s.read_bytes_mb, 0),
|
|
1821
|
+
read_time_ms: ioStats.reduce((sum, s) => sum + s.read_time_ms, 0),
|
|
1822
|
+
writes: ioStats.reduce((sum, s) => sum + s.writes, 0),
|
|
1823
|
+
write_bytes_mb: ioStats.reduce((sum, s) => sum + s.write_bytes_mb, 0),
|
|
1824
|
+
write_time_ms: ioStats.reduce((sum, s) => sum + s.write_time_ms, 0),
|
|
1825
|
+
writebacks: ioStats.reduce((sum, s) => sum + s.writebacks, 0),
|
|
1826
|
+
writeback_bytes_mb: ioStats.reduce((sum, s) => sum + s.writeback_bytes_mb, 0),
|
|
1827
|
+
writeback_time_ms: ioStats.reduce((sum, s) => sum + s.writeback_time_ms, 0),
|
|
1828
|
+
fsyncs: ioStats.reduce((sum, s) => sum + s.fsyncs, 0),
|
|
1829
|
+
fsync_time_ms: ioStats.reduce((sum, s) => sum + s.fsync_time_ms, 0),
|
|
1830
|
+
extends: ioStats.reduce((sum, s) => sum + (s.extends || 0), 0),
|
|
1831
|
+
extend_bytes_mb: ioStats.reduce((sum, s) => sum + (s.extend_bytes_mb || 0), 0),
|
|
1832
|
+
hits: ioStats.reduce((sum, s) => sum + s.hits, 0),
|
|
1833
|
+
evictions: ioStats.reduce((sum, s) => sum + s.evictions, 0),
|
|
1834
|
+
reuses: ioStats.reduce((sum, s) => sum + s.reuses, 0),
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// Calculate analysis
|
|
1839
|
+
const totalReadMb = totalStats?.read_bytes_mb || 0;
|
|
1840
|
+
const totalWriteMb = totalStats?.write_bytes_mb || 0;
|
|
1841
|
+
const totalReadTime = totalStats?.read_time_ms || 0;
|
|
1842
|
+
const totalWriteTime = totalStats?.write_time_ms || 0;
|
|
1843
|
+
const totalIoTimeMs = totalReadTime + totalWriteTime;
|
|
1844
|
+
const totalReads = totalStats?.reads || 0;
|
|
1845
|
+
const totalWrites = totalStats?.writes || 0;
|
|
1846
|
+
const totalHits = totalStats?.hits || 0;
|
|
1847
|
+
|
|
1848
|
+
// Hit ratio: hits / (hits + reads) * 100
|
|
1849
|
+
const totalRequests = totalHits + totalReads;
|
|
1850
|
+
const readHitRatioPct = totalRequests > 0 ? Math.round((totalHits / totalRequests) * 10000) / 100 : 0;
|
|
1851
|
+
|
|
1852
|
+
// Average times
|
|
1853
|
+
const avgReadTimeMs = totalReads > 0 ? Math.round((totalReadTime / totalReads) * 1000) / 1000 : null;
|
|
1854
|
+
const avgWriteTimeMs = totalWrites > 0 ? Math.round((totalWriteTime / totalWrites) * 1000) / 1000 : null;
|
|
1855
|
+
|
|
1856
|
+
// Direct-connect checkup queries stats_reset separately instead of reading it from pgwatch metrics.
|
|
1857
|
+
let statsResetS: number | null = null;
|
|
1858
|
+
try {
|
|
1859
|
+
const resetResult = await client.query(`
|
|
1860
|
+
select max(extract(epoch from now() - stats_reset)::int) as stats_reset_s
|
|
1861
|
+
from pg_stat_io
|
|
1862
|
+
`);
|
|
1863
|
+
if (resetResult.rows.length > 0 && resetResult.rows[0].stats_reset_s !== null) {
|
|
1864
|
+
const parsedStatsResetS = parseInt(resetResult.rows[0].stats_reset_s, 10);
|
|
1865
|
+
statsResetS = Number.isFinite(parsedStatsResetS) ? parsedStatsResetS : null;
|
|
1866
|
+
}
|
|
1867
|
+
} catch (err) {
|
|
1868
|
+
// Ignore errors getting stats_reset - not critical
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
report.results[nodeName] = {
|
|
1872
|
+
data: {
|
|
1873
|
+
available: ioStats.length > 0,
|
|
1874
|
+
by_backend_type: ioStats,
|
|
1875
|
+
analysis: {
|
|
1876
|
+
total_read_mb: totalReadMb,
|
|
1877
|
+
total_write_mb: totalWriteMb,
|
|
1878
|
+
total_io_time_ms: totalIoTimeMs,
|
|
1879
|
+
read_hit_ratio_pct: readHitRatioPct,
|
|
1880
|
+
avg_read_time_ms: avgReadTimeMs,
|
|
1881
|
+
avg_write_time_ms: avgWriteTimeMs,
|
|
1882
|
+
},
|
|
1883
|
+
stats_reset_s: statsResetS,
|
|
1884
|
+
},
|
|
1885
|
+
postgres_version: postgresVersion,
|
|
1886
|
+
};
|
|
1887
|
+
|
|
1888
|
+
return report;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1437
1891
|
/**
|
|
1438
1892
|
* Available report generators
|
|
1439
1893
|
*/
|
|
@@ -1446,11 +1900,14 @@ export const REPORT_GENERATORS: Record<string, (client: Client, nodeName: string
|
|
|
1446
1900
|
D001: generateD001,
|
|
1447
1901
|
D004: generateD004,
|
|
1448
1902
|
F001: generateF001,
|
|
1903
|
+
F004: generateF004,
|
|
1904
|
+
F005: generateF005,
|
|
1449
1905
|
G001: generateG001,
|
|
1450
1906
|
G003: generateG003,
|
|
1451
1907
|
H001: generateH001,
|
|
1452
1908
|
H002: generateH002,
|
|
1453
1909
|
H004: generateH004,
|
|
1910
|
+
I001: generateI001,
|
|
1454
1911
|
};
|
|
1455
1912
|
|
|
1456
1913
|
/**
|
package/lib/config.ts
CHANGED
|
@@ -8,8 +8,11 @@ import * as os from "os";
|
|
|
8
8
|
export interface Config {
|
|
9
9
|
apiKey: string | null;
|
|
10
10
|
baseUrl: string | null;
|
|
11
|
+
storageBaseUrl: string | null;
|
|
11
12
|
orgId: number | null;
|
|
12
13
|
defaultProject: string | null;
|
|
14
|
+
/** Docker Compose project name for monitoring stack */
|
|
15
|
+
projectName: string | null;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
/**
|
|
@@ -46,8 +49,10 @@ export function readConfig(): Config {
|
|
|
46
49
|
const config: Config = {
|
|
47
50
|
apiKey: null,
|
|
48
51
|
baseUrl: null,
|
|
52
|
+
storageBaseUrl: null,
|
|
49
53
|
orgId: null,
|
|
50
54
|
defaultProject: null,
|
|
55
|
+
projectName: null,
|
|
51
56
|
};
|
|
52
57
|
|
|
53
58
|
// Try user-level config first
|
|
@@ -58,8 +63,10 @@ export function readConfig(): Config {
|
|
|
58
63
|
const parsed = JSON.parse(content);
|
|
59
64
|
config.apiKey = parsed.apiKey ?? null;
|
|
60
65
|
config.baseUrl = parsed.baseUrl ?? null;
|
|
66
|
+
config.storageBaseUrl = parsed.storageBaseUrl ?? null;
|
|
61
67
|
config.orgId = parsed.orgId ?? null;
|
|
62
68
|
config.defaultProject = parsed.defaultProject ?? null;
|
|
69
|
+
config.projectName = parsed.projectName ?? null;
|
|
63
70
|
return config;
|
|
64
71
|
} catch (err) {
|
|
65
72
|
const message = err instanceof Error ? err.message : String(err);
|