pgserve 2.2.4 → 2.4.0

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.
@@ -1,401 +0,0 @@
1
- /**
2
- * Stats Dashboard - Real-time CLI monitoring display for pgserve
3
- *
4
- * htop-style in-place terminal updates showing:
5
- * - Server info (endpoint, mode, uptime)
6
- * - Connections (active, total, with progress bar)
7
- * - Cluster workers (if in cluster mode)
8
- * - PostgreSQL (databases, internal port)
9
- * - PG Internals (backends, cache hit ratio)
10
- * - Memory usage
11
- *
12
- * Supports TTY and non-TTY environments.
13
- */
14
-
15
- // ANSI escape codes
16
- const ANSI = {
17
- CLEAR_LINE: '\x1B[2K',
18
- MOVE_UP: (n) => `\x1B[${n}A`,
19
- HIDE_CURSOR: '\x1B[?25l',
20
- SHOW_CURSOR: '\x1B[?25h',
21
-
22
- // Colors
23
- GREEN: '\x1B[32m',
24
- YELLOW: '\x1B[33m',
25
- CYAN: '\x1B[36m',
26
- RED: '\x1B[31m',
27
- MAGENTA: '\x1B[35m',
28
- DIM: '\x1B[2m',
29
- RESET: '\x1B[0m',
30
- BOLD: '\x1B[1m',
31
- INVERSE: '\x1B[7m'
32
- };
33
-
34
- // Color thresholds for progress bars and values (percentage of max)
35
- const THRESHOLD_WARN = 0.6; // Yellow at 60%
36
- const THRESHOLD_CRITICAL = 0.8; // Red at 80%
37
-
38
- export class StatsDashboard {
39
- /**
40
- * @param {Object} options
41
- * @param {number} [options.refreshInterval=2000] - Dashboard refresh interval in ms
42
- * @param {() => Promise<StatsSnapshot>} [options.statsProvider] - Async function returning stats object
43
- * @param {() => void} [options.onStop] - Callback when dashboard stops
44
- */
45
- constructor(options = {}) {
46
- // Respects NO_COLOR env var (https://no-color.org/ standard)
47
- this.enabled = process.stdout.isTTY && !process.env.NO_COLOR;
48
- // Default 2s refresh for real-time feel (trade-off: higher CPU vs fresher data)
49
- this.refreshInterval = options.refreshInterval || 2000;
50
- this.statsProvider = options.statsProvider;
51
- this.timer = null;
52
- this.displayLines = 0;
53
- this.lastStats = null;
54
- this.startTime = Date.now();
55
- this.onStop = options.onStop; // Optional callback when dashboard stops
56
- }
57
-
58
- /**
59
- * Start the dashboard refresh loop
60
- */
61
- start() {
62
- if (!this.enabled) {
63
- console.log('[Stats Dashboard disabled - non-TTY environment]');
64
- return;
65
- }
66
-
67
- // Hide cursor during dashboard display
68
- process.stdout.write(ANSI.HIDE_CURSOR);
69
-
70
- // Initial render
71
- this.render();
72
-
73
- // Start refresh timer
74
- this.timer = setInterval(() => this.render(), this.refreshInterval);
75
-
76
- // Handle terminal resize
77
- process.stdout.on('resize', () => this.render());
78
-
79
- // Restore cursor on process exit (non-signal exits)
80
- // Note: SIGINT/SIGTERM are handled by the main process for graceful shutdown
81
- process.on('exit', () => {
82
- if (this.enabled) {
83
- process.stdout.write(ANSI.SHOW_CURSOR);
84
- }
85
- });
86
- }
87
-
88
- /**
89
- * Stop the dashboard
90
- */
91
- stop() {
92
- if (this.timer) {
93
- clearInterval(this.timer);
94
- this.timer = null;
95
- }
96
- if (this.enabled) {
97
- process.stdout.write(ANSI.SHOW_CURSOR);
98
- // Move below dashboard area
99
- if (this.displayLines > 0) {
100
- console.log('\n');
101
- }
102
- }
103
- if (this.onStop) {
104
- this.onStop();
105
- }
106
- }
107
-
108
- /**
109
- * Render the dashboard
110
- */
111
- async render() {
112
- if (!this.statsProvider) return;
113
-
114
- try {
115
- const stats = await this.statsProvider();
116
- this.lastStats = stats;
117
- this.draw(stats);
118
- } catch (err) {
119
- // Don't crash on stats collection failure - use cached stats if available
120
- // Only log once to avoid spam during persistent issues
121
- if (!this._lastError || this._lastError !== err.message) {
122
- this._lastError = err.message;
123
- console.error(`[stats] Collection failed: ${err.message}`);
124
- }
125
- if (this.lastStats) {
126
- this.draw(this.lastStats);
127
- }
128
- }
129
- }
130
-
131
- /**
132
- * Draw the dashboard to terminal
133
- */
134
- draw(stats) {
135
- const output = this.buildDisplay(stats);
136
-
137
- // Count actual lines (including newlines within sections)
138
- const totalLines = output.split('\n').length;
139
-
140
- // Clear previous output and redraw
141
- if (this.displayLines > 0) {
142
- process.stdout.write(ANSI.MOVE_UP(this.displayLines));
143
- }
144
-
145
- // Write each line with clear
146
- for (const line of output.split('\n')) {
147
- process.stdout.write(ANSI.CLEAR_LINE + line + '\n');
148
- }
149
-
150
- this.displayLines = totalLines;
151
- }
152
-
153
- /**
154
- * Build the display lines
155
- */
156
- buildDisplay(stats) {
157
- const lines = [];
158
- // Use actual terminal width (no arbitrary cap - users with wide terminals get wider display)
159
- const width = process.stdout.columns || 80;
160
-
161
- // Header bar
162
- lines.push(this.headerBar(width));
163
- lines.push('');
164
-
165
- // Server info section
166
- // Determine storage mode label
167
- let modeLabel = 'Persistent';
168
- if (stats.server?.memoryMode) {
169
- modeLabel = stats.server?.useRam ? 'RAM (/dev/shm)' : 'Ephemeral (temp)';
170
- }
171
-
172
- lines.push(this.section('SERVER', [
173
- `${ANSI.DIM}Endpoint:${ANSI.RESET} postgresql://${stats.server?.host || '127.0.0.1'}:${stats.server?.port || 8432}/<db>`,
174
- `${ANSI.DIM}Mode:${ANSI.RESET} ${modeLabel}`,
175
- `${ANSI.DIM}Uptime:${ANSI.RESET} ${this.formatUptime(stats.uptime || 0)}`
176
- ]));
177
- lines.push('');
178
-
179
- // Connections section
180
- const connActive = stats.connections?.active || 0;
181
- const connMax = stats.connections?.max || 1000;
182
- const connTotal = stats.connections?.totalConnected || 0;
183
- const connDisc = stats.connections?.totalDisconnected || 0;
184
-
185
- const connLines = [
186
- `${ANSI.DIM}Active:${ANSI.RESET} ${this.colorValue(connActive, connMax * THRESHOLD_WARN, connMax * THRESHOLD_CRITICAL)} / ${connMax} ${this.miniBar(connActive, connMax, 20)}`
187
- ];
188
-
189
- if (connTotal > 0 || connDisc > 0) {
190
- connLines.push(`${ANSI.DIM}Total:${ANSI.RESET} ${this.formatNumber(connTotal)} connected, ${this.formatNumber(connDisc)} disconnected`);
191
- }
192
-
193
- lines.push(this.section('CONNECTIONS', connLines));
194
- lines.push('');
195
-
196
- // Cluster section (if applicable)
197
- if (stats.cluster && stats.cluster.workers > 0) {
198
- const workerLines = [`${ANSI.DIM}Workers:${ANSI.RESET} ${stats.cluster.workers} processes`];
199
-
200
- if (stats.cluster.workerStats && Object.keys(stats.cluster.workerStats).length > 0) {
201
- const workerEntries = Object.entries(stats.cluster.workerStats);
202
- // Show up to 4 workers inline
203
- for (const [id, ws] of workerEntries.slice(0, 4)) {
204
- workerLines.push(` ${ANSI.DIM}Worker ${id}:${ANSI.RESET} ${ws.connections || 0} conn (PID ${ws.pid})`);
205
- }
206
- if (workerEntries.length > 4) {
207
- workerLines.push(` ${ANSI.DIM}... and ${workerEntries.length - 4} more workers${ANSI.RESET}`);
208
- }
209
- }
210
-
211
- lines.push(this.section('CLUSTER', workerLines));
212
- lines.push('');
213
- }
214
-
215
- // PostgreSQL section
216
- if (stats.postgres) {
217
- const pgLines = [
218
- `${ANSI.DIM}Internal Port:${ANSI.RESET} ${stats.postgres.port || 'N/A'}`,
219
- `${ANSI.DIM}Databases:${ANSI.RESET} ${stats.postgres.databases?.length || 0}`
220
- ];
221
-
222
- // Show database list if not too many
223
- const dbs = stats.postgres.databases || [];
224
- if (dbs.length > 0 && dbs.length <= 5) {
225
- pgLines.push(` ${ANSI.DIM}${ANSI.RESET} ${dbs.join(', ')}`);
226
- } else if (dbs.length > 5) {
227
- pgLines.push(` ${ANSI.DIM}${ANSI.RESET} ${dbs.slice(0, 5).join(', ')}${ANSI.DIM}... (+${dbs.length - 5})${ANSI.RESET}`);
228
- }
229
-
230
- lines.push(this.section('POSTGRESQL', pgLines));
231
- lines.push('');
232
- }
233
-
234
- // PostgreSQL Internals section (pg_stat_*)
235
- if (stats.internals) {
236
- const intLines = [];
237
-
238
- if (stats.internals.activity) {
239
- const a = stats.internals.activity;
240
- intLines.push(`${ANSI.DIM}Backends:${ANSI.RESET} ${a.total_connections || 0} total, ${a.active_queries || 0} active, ${a.idle_connections || 0} idle`);
241
- }
242
-
243
- if (stats.internals.databases?.length > 0) {
244
- intLines.push(`${ANSI.DIM}Top DBs by connections:${ANSI.RESET}`);
245
- for (const db of stats.internals.databases.slice(0, 3)) {
246
- // Use BigInt for precision on high-traffic systems (PostgreSQL returns 8-byte integers)
247
- const blksHit = BigInt(db.blks_hit || 0);
248
- const blksRead = BigInt(db.blks_read || 0);
249
- const total = blksHit + blksRead;
250
- // Calculate ratio safely: multiply by 1000 first, then convert to Number for final formatting
251
- const hitRatio = total > 0n
252
- ? (Number((blksHit * 1000n) / total) / 10).toFixed(1)
253
- : '0.0';
254
- intLines.push(` ${ANSI.CYAN}${db.datname}${ANSI.RESET}: ${db.numbackends} conn, ${hitRatio}% cache hit`);
255
- }
256
- }
257
-
258
- if (intLines.length > 0) {
259
- lines.push(this.section('PG INTERNALS', intLines));
260
- lines.push('');
261
- }
262
- }
263
-
264
- // pgvector section (only if vector data exists)
265
- if (stats.pgvector?.enabled && stats.pgvector.tableCount > 0) {
266
- const vecLines = [
267
- `${ANSI.DIM}Databases:${ANSI.RESET} ${stats.pgvector.databases} with vectors`,
268
- `${ANSI.DIM}Tables:${ANSI.RESET} ${stats.pgvector.tableCount} total`
269
- ];
270
-
271
- if (stats.pgvector.totalRows > 0) {
272
- vecLines.push(`${ANSI.DIM}Vectors:${ANSI.RESET} ${this.formatNumber(stats.pgvector.totalRows)} rows`);
273
- }
274
-
275
- if (stats.pgvector.dimensions) {
276
- vecLines.push(`${ANSI.DIM}Dimensions:${ANSI.RESET} ${stats.pgvector.dimensions}`);
277
- }
278
-
279
- lines.push(this.section('PGVECTOR', vecLines));
280
- lines.push('');
281
- }
282
-
283
- // System resources section
284
- const resourceLines = [];
285
-
286
- // CPU usage
287
- if (stats.process?.cpu !== undefined) {
288
- const cpuPct = stats.process.cpu;
289
- resourceLines.push(`${ANSI.DIM}CPU:${ANSI.RESET} ${cpuPct.toFixed(1)}% ${this.miniBar(cpuPct, 100, 15)}`);
290
- }
291
-
292
- // Load average
293
- if (stats.system?.loadAvg) {
294
- const load = stats.system.loadAvg;
295
- resourceLines.push(`${ANSI.DIM}Load Avg:${ANSI.RESET} ${load['1m'].toFixed(2)} / ${load['5m'].toFixed(2)} / ${load['15m'].toFixed(2)}`);
296
- }
297
-
298
- // Memory
299
- if (stats.process?.memory) {
300
- const mem = stats.process.memory;
301
- const rssMB = (mem.rss / 1024 / 1024).toFixed(1);
302
- resourceLines.push(`${ANSI.DIM}Memory:${ANSI.RESET} ${rssMB} MB RSS`);
303
- }
304
-
305
- // Disk I/O
306
- if (stats.system?.diskIO) {
307
- const io = stats.system.diskIO;
308
- const readSpeed = io.readMBps.toFixed(1);
309
- const writeSpeed = io.writeMBps.toFixed(1);
310
- const readIOPS = io.readIOPS.toFixed(0);
311
- const writeIOPS = io.writeIOPS.toFixed(0);
312
- resourceLines.push(`${ANSI.DIM}Disk I/O:${ANSI.RESET} R: ${readSpeed} MB/s (${readIOPS} IOPS) | W: ${writeSpeed} MB/s (${writeIOPS} IOPS)`);
313
- }
314
-
315
- if (resourceLines.length > 0) {
316
- lines.push(this.section('RESOURCES', resourceLines));
317
- lines.push('');
318
- }
319
-
320
- // Footer
321
- lines.push(`${ANSI.DIM}Last update: ${new Date().toLocaleTimeString()} | Refresh: ${this.refreshInterval / 1000}s | Press Ctrl+C to exit${ANSI.RESET}`);
322
-
323
- return lines.join('\n');
324
- }
325
-
326
- /**
327
- * Render header bar
328
- */
329
- headerBar(width) {
330
- const title = ' pgserve stats ';
331
- const padding = Math.max(0, Math.floor((width - title.length) / 2));
332
- const rightPad = Math.max(0, width - padding - title.length);
333
- return `${ANSI.INVERSE}${' '.repeat(padding)}${title}${' '.repeat(rightPad)}${ANSI.RESET}`;
334
- }
335
-
336
- /**
337
- * Render a section with title and lines
338
- */
339
- section(title, contentLines) {
340
- const header = `${ANSI.BOLD}${ANSI.CYAN}[${title}]${ANSI.RESET}`;
341
- return [header, ...contentLines.map(l => ' ' + l)].join('\n');
342
- }
343
-
344
- /**
345
- * Color a value based on thresholds
346
- */
347
- colorValue(value, warnThreshold, errorThreshold) {
348
- if (value >= errorThreshold) {
349
- return `${ANSI.RED}${value}${ANSI.RESET}`;
350
- } else if (value >= warnThreshold) {
351
- return `${ANSI.YELLOW}${value}${ANSI.RESET}`;
352
- }
353
- return `${ANSI.GREEN}${value}${ANSI.RESET}`;
354
- }
355
-
356
- /**
357
- * Render mini progress bar
358
- */
359
- miniBar(current, max, width = 10) {
360
- // Ensure valid numbers
361
- const safeMax = Math.max(1, Number(max) || 1);
362
- const safeCurrent = Math.max(0, Math.min(Number(current) || 0, safeMax));
363
-
364
- const pct = safeCurrent / safeMax;
365
- // filled is clamped to [0, width], so (width - filled) is always non-negative
366
- const filled = Math.max(0, Math.min(width, Math.round(pct * width)));
367
- const empty = width - filled;
368
-
369
- let color = ANSI.GREEN;
370
- if (pct > THRESHOLD_CRITICAL) color = ANSI.RED;
371
- else if (pct > THRESHOLD_WARN) color = ANSI.YELLOW;
372
-
373
- // Use Unicode block characters for progress bar
374
- const filledChar = '\u2588'; // Full block
375
- const emptyChar = '\u2591'; // Light shade
376
-
377
- return `${color}[${filledChar.repeat(filled)}${ANSI.DIM}${emptyChar.repeat(empty)}${ANSI.RESET}${color}]${ANSI.RESET}`;
378
- }
379
-
380
- /**
381
- * Format uptime as human-readable string
382
- */
383
- formatUptime(seconds) {
384
- const days = Math.floor(seconds / 86400);
385
- const hours = Math.floor((seconds % 86400) / 3600);
386
- const mins = Math.floor((seconds % 3600) / 60);
387
- const secs = Math.floor(seconds % 60);
388
-
389
- if (days > 0) return `${days}d ${hours}h ${mins}m`;
390
- if (hours > 0) return `${hours}h ${mins}m ${secs}s`;
391
- if (mins > 0) return `${mins}m ${secs}s`;
392
- return `${secs}s`;
393
- }
394
-
395
- /**
396
- * Format large numbers with commas
397
- */
398
- formatNumber(num) {
399
- return num.toLocaleString();
400
- }
401
- }