postgresai 0.15.0-dev.1 → 0.15.0-dev.11

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.
@@ -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
@@ -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
- const req = https.request(
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
  });
@@ -57,17 +57,6 @@ export function getCheckupEntry(code: string): CheckupDictionaryEntry | null {
57
57
  return dictionaryByCode.get(code.toUpperCase()) ?? null;
58
58
  }
59
59
 
60
- /**
61
- * Get the title for a checkup code.
62
- *
63
- * @param code - The check code (e.g., "A001", "H002")
64
- * @returns The title or the code itself if not found
65
- */
66
- export function getCheckupTitle(code: string): string {
67
- const entry = getCheckupEntry(code);
68
- return entry?.title ?? code;
69
- }
70
-
71
60
  /**
72
61
  * Check if a code exists in the dictionary.
73
62
  *
package/lib/checkup.ts CHANGED
@@ -2,41 +2,41 @@
2
2
  * Express Checkup Module
3
3
  * ======================
4
4
  * Generates JSON health check reports directly from PostgreSQL without Prometheus.
5
- *
5
+ *
6
6
  * ARCHITECTURAL DECISIONS
7
7
  * -----------------------
8
- *
8
+ *
9
9
  * 1. SINGLE SOURCE OF TRUTH FOR SQL QUERIES
10
- * Complex metrics (index health, settings, db_stats) are loaded from
10
+ * Complex metrics (index health, settings, db_stats) are loaded from
11
11
  * config/pgwatch-prometheus/metrics.yml via getMetricSql() from metrics-loader.ts.
12
- *
12
+ *
13
13
  * Simple queries (version, database list, connection states, uptime) use
14
14
  * inline SQL as they're trivial and CLI-specific.
15
- *
15
+ *
16
16
  * 2. JSON SCHEMA COMPLIANCE
17
17
  * All generated reports MUST comply with JSON schemas in reporter/schemas/.
18
18
  * These schemas define the expected format for both:
19
19
  * - Full-fledged monitoring reporter output
20
20
  * - Express checkup output
21
- *
21
+ *
22
22
  * Before adding or modifying a report, verify the corresponding schema exists
23
23
  * and ensure the output matches. Run schema validation tests to confirm.
24
- *
24
+ *
25
25
  * 3. ERROR HANDLING STRATEGY
26
26
  * Functions follow two patterns based on criticality:
27
- *
27
+ *
28
28
  * PROPAGATING (throws on error):
29
29
  * - Core data functions: getPostgresVersion, getSettings, getAlteredSettings,
30
30
  * getDatabaseSizes, getInvalidIndexes, getUnusedIndexes, getRedundantIndexes
31
31
  * - If these fail, the entire report should fail (data is required)
32
32
  * - Callers should handle errors at the report generation level
33
- *
33
+ *
34
34
  * GRACEFUL DEGRADATION (catches errors, includes error in output):
35
35
  * - Optional/supplementary queries: pg_stat_statements, pg_stat_kcache checks,
36
36
  * memory calculations, postmaster startup time
37
37
  * - These are nice-to-have; missing data shouldn't fail the whole report
38
38
  * - Errors are logged and included in report output for visibility
39
- *
39
+ *
40
40
  * ADDING NEW REPORTS
41
41
  * ------------------
42
42
  * 1. Add/verify the metric exists in config/pgwatch-prometheus/metrics.yml
@@ -51,7 +51,7 @@ import * as fs from "fs";
51
51
  import * as path from "path";
52
52
  import * as pkg from "../package.json";
53
53
  import { getMetricSql, transformMetricRow, METRIC_NAMES } from "./metrics-loader";
54
- import { getCheckupTitle, buildCheckInfoMap } from "./checkup-dictionary";
54
+ import { buildCheckInfoMap } from "./checkup-dictionary";
55
55
 
56
56
  // Time constants
57
57
  const SECONDS_PER_DAY = 86400;
@@ -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.log(`[parseVersionNum] Warning: Failed to parse "${versionNum}": ${errorMsg}`);
331
+ console.error(`[parseVersionNum] Warning: Failed to parse "${versionNum}": ${errorMsg}`);
288
332
  return { major: "", minor: "" };
289
333
  }
290
334
  }
@@ -292,7 +336,7 @@ export function parseVersionNum(versionNum: string): { major: string; minor: str
292
336
  /**
293
337
  * Format bytes to human readable string using binary units (1024-based).
294
338
  * Uses IEC standard: KiB, MiB, GiB, etc.
295
- *
339
+ *
296
340
  * Note: PostgreSQL's pg_size_pretty() uses kB/MB/GB with 1024 base (technically
297
341
  * incorrect SI usage), but we follow IEC binary units per project style guide.
298
342
  */
@@ -343,7 +387,7 @@ function formatSettingPrettyValue(
343
387
  /**
344
388
  * Get PostgreSQL version information.
345
389
  * Uses simple inline SQL (trivial query, CLI-specific).
346
- *
390
+ *
347
391
  * @throws {Error} If database query fails (propagating - critical data)
348
392
  */
349
393
  export async function getPostgresVersion(client: Client): Promise<PostgresVersion> {
@@ -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.log(`[getStatsReset] Warning: ${postmasterStartupError}`);
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.log(`[H004] Warning: ${parseError} for index "${indexName}"`);
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.log(`[resolveBuildTs] Could not stat package.json, using current time: ${errorMsg}`);
952
+ console.error(`[resolveBuildTs] Could not stat package.json, using current time: ${errorMsg}`);
909
953
  }
910
954
  return new Date().toISOString();
911
955
  }
@@ -1040,7 +1084,7 @@ export const generateH004 = (client: Client, nodeName = "node-01") =>
1040
1084
 
1041
1085
  /**
1042
1086
  * Generate D004 report - pg_stat_statements and pg_stat_kcache settings.
1043
- *
1087
+ *
1044
1088
  * Uses graceful degradation: extension queries are wrapped in try-catch
1045
1089
  * because extensions may not be installed. Errors are included in the
1046
1090
  * report output rather than failing the entire report.
@@ -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.log(`[D004] Error querying pg_stat_statements: ${errorMsg}`);
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.log(`[D004] Error querying pg_stat_kcache: ${errorMsg}`);
1203
+ console.error(`[D004] Error querying pg_stat_kcache: ${errorMsg}`);
1160
1204
  kcacheError = errorMsg;
1161
1205
  }
1162
1206
 
@@ -1324,7 +1368,10 @@ async function generateF004(client: Client, nodeName: string): Promise<Report> {
1324
1368
  });
1325
1369
  } catch (err) {
1326
1370
  const errorMsg = err instanceof Error ? err.message : String(err);
1327
- console.log(`[F004] Error estimating table bloat: ${errorMsg}`);
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
+ }
1328
1375
  }
1329
1376
 
1330
1377
  // Get database info
@@ -1439,7 +1486,10 @@ async function generateF005(client: Client, nodeName: string): Promise<Report> {
1439
1486
  });
1440
1487
  } catch (err) {
1441
1488
  const errorMsg = err instanceof Error ? err.message : String(err);
1442
- console.log(`[F005] Error estimating index bloat: ${errorMsg}`);
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
+ }
1443
1493
  }
1444
1494
 
1445
1495
  // Get database info
@@ -1564,7 +1614,7 @@ async function generateG001(client: Client, nodeName: string): Promise<Report> {
1564
1614
  }
1565
1615
  } catch (err) {
1566
1616
  const errorMsg = err instanceof Error ? err.message : String(err);
1567
- console.log(`[G001] Error calculating memory usage: ${errorMsg}`);
1617
+ console.error(`[G001] Error calculating memory usage: ${errorMsg}`);
1568
1618
  memoryError = errorMsg;
1569
1619
  }
1570
1620
 
@@ -1642,7 +1692,7 @@ async function generateG003(client: Client, nodeName: string): Promise<Report> {
1642
1692
  }
1643
1693
  } catch (err) {
1644
1694
  const errorMsg = err instanceof Error ? err.message : String(err);
1645
- console.log(`[G003] Error querying deadlock stats: ${errorMsg}`);
1695
+ console.error(`[G003] Error querying deadlock stats: ${errorMsg}`);
1646
1696
  deadlockError = errorMsg;
1647
1697
  }
1648
1698
 
@@ -1658,6 +1708,186 @@ async function generateG003(client: Client, nodeName: string): Promise<Report> {
1658
1708
  return report;
1659
1709
  }
1660
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
+
1661
1891
  /**
1662
1892
  * Available report generators
1663
1893
  */
@@ -1677,6 +1907,7 @@ export const REPORT_GENERATORS: Record<string, (client: Client, nodeName: string
1677
1907
  H001: generateH001,
1678
1908
  H002: generateH002,
1679
1909
  H004: generateH004,
1910
+ I001: generateI001,
1680
1911
  };
1681
1912
 
1682
1913
  /**
package/lib/config.ts CHANGED
@@ -8,6 +8,7 @@ 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;
13
14
  /** Docker Compose project name for monitoring stack */
@@ -48,6 +49,7 @@ export function readConfig(): Config {
48
49
  const config: Config = {
49
50
  apiKey: null,
50
51
  baseUrl: null,
52
+ storageBaseUrl: null,
51
53
  orgId: null,
52
54
  defaultProject: null,
53
55
  projectName: null,
@@ -61,6 +63,7 @@ export function readConfig(): Config {
61
63
  const parsed = JSON.parse(content);
62
64
  config.apiKey = parsed.apiKey ?? null;
63
65
  config.baseUrl = parsed.baseUrl ?? null;
66
+ config.storageBaseUrl = parsed.storageBaseUrl ?? null;
64
67
  config.orgId = parsed.orgId ?? null;
65
68
  config.defaultProject = parsed.defaultProject ?? null;
66
69
  config.projectName = parsed.projectName ?? null;