pgserve 1.1.3 → 1.1.4
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 +113 -19
- package/bin/pglite-server.js +58 -7
- package/package.json +1 -1
- package/src/cluster.js +81 -9
- package/src/index.js +2 -0
- package/src/postgres.js +1 -0
- package/src/stats-collector.js +267 -0
- package/src/stats-dashboard.js +382 -0
- package/tests/benchmarks/runner.js +871 -50
- package/tests/benchmarks/vector-generator.js +368 -0
- package/tests/quick-bench.js +135 -0
- package/tests/stress-test.js +439 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stats Collector - Centralized statistics gathering for pgserve
|
|
3
|
+
*
|
|
4
|
+
* Aggregates stats from:
|
|
5
|
+
* - Router (active connections)
|
|
6
|
+
* - PostgreSQL Manager (databases, storage)
|
|
7
|
+
* - PostgreSQL internals (pg_stat_activity, pg_stat_database)
|
|
8
|
+
* - Process (memory, uptime)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// CPU sampling threshold - avoid noise from too-frequent calls
|
|
12
|
+
const CPU_SAMPLE_MIN_INTERVAL_MS = 100;
|
|
13
|
+
|
|
14
|
+
// /proc/diskstats minimum fields per device line
|
|
15
|
+
// Fields: major minor device reads rd_merged rd_sectors rd_ms writes wr_merged wr_sectors wr_ms io_in_progress io_ms weighted_io_ms
|
|
16
|
+
const PROC_DISKSTATS_MIN_FIELDS = 14;
|
|
17
|
+
|
|
18
|
+
export class StatsCollector {
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.pgManager = options.pgManager;
|
|
21
|
+
this.router = options.router;
|
|
22
|
+
this.clusterStats = options.clusterStats; // Function that returns cluster stats
|
|
23
|
+
this.logger = options.logger;
|
|
24
|
+
|
|
25
|
+
// Override values for cluster mode where router is null
|
|
26
|
+
this.serverPort = options.port;
|
|
27
|
+
this.serverHost = options.host;
|
|
28
|
+
|
|
29
|
+
// Cache to avoid over-querying (configurable for different monitoring needs)
|
|
30
|
+
this.cache = null;
|
|
31
|
+
this.cacheTime = 0;
|
|
32
|
+
this.cacheTTL = options.cacheTTL || 1000; // Default 1s cache
|
|
33
|
+
|
|
34
|
+
// CPU tracking
|
|
35
|
+
this.lastCpuUsage = process.cpuUsage();
|
|
36
|
+
this.lastCpuTime = Date.now();
|
|
37
|
+
|
|
38
|
+
// Disk I/O tracking (Linux)
|
|
39
|
+
this.lastDiskStats = null;
|
|
40
|
+
this.lastDiskTime = 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Collect all available stats
|
|
45
|
+
* @returns {Promise<StatsSnapshot>}
|
|
46
|
+
*/
|
|
47
|
+
async collect() {
|
|
48
|
+
// Return cached if recent
|
|
49
|
+
if (this.cache && Date.now() - this.cacheTime < this.cacheTTL) {
|
|
50
|
+
return this.cache;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pgStats = this.pgManager?.getStats?.() || {};
|
|
54
|
+
const routerStats = this.router?.getStats?.() || {};
|
|
55
|
+
// Prefer clusterStats (cluster mode aggregates from all workers)
|
|
56
|
+
// Fall back to routerStats (single-process mode)
|
|
57
|
+
const clusterStats = this.clusterStats?.() || null;
|
|
58
|
+
|
|
59
|
+
const snapshot = {
|
|
60
|
+
timestamp: Date.now(),
|
|
61
|
+
uptime: process.uptime(),
|
|
62
|
+
|
|
63
|
+
// Connection stats - cluster mode has aggregated stats, single-process uses router directly
|
|
64
|
+
connections: {
|
|
65
|
+
active: clusterStats?.connections?.active ?? routerStats.activeConnections ?? 0,
|
|
66
|
+
totalConnected: clusterStats?.connections?.totalConnected ?? 0,
|
|
67
|
+
totalDisconnected: clusterStats?.connections?.totalDisconnected ?? 0,
|
|
68
|
+
max: this.router?.maxConnections || 1000
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
// Server config
|
|
72
|
+
server: {
|
|
73
|
+
port: this.serverPort || routerStats.port || this.router?.port || 8432,
|
|
74
|
+
host: this.serverHost || routerStats.host || this.router?.host || '127.0.0.1',
|
|
75
|
+
pgPort: routerStats.pgPort || pgStats.port || 0,
|
|
76
|
+
memoryMode: this.router?.memoryMode ?? !pgStats.persistent,
|
|
77
|
+
useRam: this.pgManager?.useRam || false
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// PostgreSQL manager stats
|
|
81
|
+
postgres: {
|
|
82
|
+
port: pgStats.port,
|
|
83
|
+
databases: pgStats.databases || [],
|
|
84
|
+
databaseDir: pgStats.databaseDir,
|
|
85
|
+
socketDir: pgStats.socketDir,
|
|
86
|
+
socketPath: pgStats.socketPath,
|
|
87
|
+
persistent: pgStats.persistent
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// Cluster stats (if in cluster mode)
|
|
91
|
+
cluster: clusterStats ? {
|
|
92
|
+
workers: clusterStats.workers,
|
|
93
|
+
pids: clusterStats.pids,
|
|
94
|
+
workerStats: clusterStats.workerStats || {}
|
|
95
|
+
} : null,
|
|
96
|
+
|
|
97
|
+
// PostgreSQL internals (pg_stat_*)
|
|
98
|
+
internals: await this.collectPgStats(),
|
|
99
|
+
|
|
100
|
+
// Process stats
|
|
101
|
+
process: {
|
|
102
|
+
pid: process.pid,
|
|
103
|
+
memory: process.memoryUsage(),
|
|
104
|
+
cpu: this.getCpuUsage()
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// System stats (Linux)
|
|
108
|
+
system: await this.getSystemStats()
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
this.cache = snapshot;
|
|
112
|
+
this.cacheTime = Date.now();
|
|
113
|
+
return snapshot;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get CPU usage percentage
|
|
118
|
+
*/
|
|
119
|
+
getCpuUsage() {
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
const elapsed = now - this.lastCpuTime;
|
|
122
|
+
if (elapsed < CPU_SAMPLE_MIN_INTERVAL_MS) return this.lastCpuPercent || 0;
|
|
123
|
+
|
|
124
|
+
const cpuUsage = process.cpuUsage(this.lastCpuUsage);
|
|
125
|
+
const totalMicros = cpuUsage.user + cpuUsage.system;
|
|
126
|
+
const elapsedMicros = elapsed * 1000; // Convert ms to microseconds
|
|
127
|
+
|
|
128
|
+
// CPU percentage (can be > 100% on multi-core)
|
|
129
|
+
const percent = (totalMicros / elapsedMicros) * 100;
|
|
130
|
+
|
|
131
|
+
this.lastCpuUsage = process.cpuUsage();
|
|
132
|
+
this.lastCpuTime = now;
|
|
133
|
+
this.lastCpuPercent = percent;
|
|
134
|
+
|
|
135
|
+
// Return raw percentage - can exceed 100% on multi-core systems
|
|
136
|
+
// Consumers (dashboard, etc.) can format/cap as needed for display
|
|
137
|
+
return percent;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get system stats (Linux-specific)
|
|
142
|
+
*/
|
|
143
|
+
async getSystemStats() {
|
|
144
|
+
const stats = {
|
|
145
|
+
loadAvg: null,
|
|
146
|
+
diskIO: null
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
// Load average (works on Linux/macOS)
|
|
151
|
+
const os = await import('os');
|
|
152
|
+
const loadAvg = os.loadavg();
|
|
153
|
+
stats.loadAvg = {
|
|
154
|
+
'1m': loadAvg[0],
|
|
155
|
+
'5m': loadAvg[1],
|
|
156
|
+
'15m': loadAvg[2]
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Disk I/O stats (Linux only via /proc/diskstats)
|
|
160
|
+
if (process.platform === 'linux') {
|
|
161
|
+
const fs = await import('fs/promises');
|
|
162
|
+
try {
|
|
163
|
+
const diskstats = await fs.readFile('/proc/diskstats', 'utf8');
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
|
|
166
|
+
// Parse diskstats - find main disk (sda, nvme0n1, vda, etc.)
|
|
167
|
+
let readSectors = 0;
|
|
168
|
+
let writeSectors = 0;
|
|
169
|
+
let readOps = 0;
|
|
170
|
+
let writeOps = 0;
|
|
171
|
+
|
|
172
|
+
for (const line of diskstats.split('\n')) {
|
|
173
|
+
const parts = line.trim().split(/\s+/);
|
|
174
|
+
if (parts.length < PROC_DISKSTATS_MIN_FIELDS) continue;
|
|
175
|
+
|
|
176
|
+
const device = parts[2];
|
|
177
|
+
// Match main disks (sda, sdb, nvme0n1, vda, etc.) but not partitions
|
|
178
|
+
if (/^(sd[a-z]|nvme\d+n\d+|vd[a-z])$/.test(device)) {
|
|
179
|
+
readOps += parseInt(parts[3]) || 0;
|
|
180
|
+
readSectors += parseInt(parts[5]) || 0;
|
|
181
|
+
writeOps += parseInt(parts[7]) || 0;
|
|
182
|
+
writeSectors += parseInt(parts[9]) || 0;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (this.lastDiskStats && this.lastDiskTime) {
|
|
187
|
+
const elapsed = (now - this.lastDiskTime) / 1000; // seconds
|
|
188
|
+
if (elapsed > 0) {
|
|
189
|
+
const readDiff = readSectors - this.lastDiskStats.readSectors;
|
|
190
|
+
const writeDiff = writeSectors - this.lastDiskStats.writeSectors;
|
|
191
|
+
const readOpsDiff = readOps - this.lastDiskStats.readOps;
|
|
192
|
+
const writeOpsDiff = writeOps - this.lastDiskStats.writeOps;
|
|
193
|
+
|
|
194
|
+
// Sectors are typically 512 bytes
|
|
195
|
+
stats.diskIO = {
|
|
196
|
+
readMBps: ((readDiff * 512) / (1024 * 1024)) / elapsed,
|
|
197
|
+
writeMBps: ((writeDiff * 512) / (1024 * 1024)) / elapsed,
|
|
198
|
+
readIOPS: readOpsDiff / elapsed,
|
|
199
|
+
writeIOPS: writeOpsDiff / elapsed
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.lastDiskStats = { readSectors, writeSectors, readOps, writeOps };
|
|
205
|
+
this.lastDiskTime = now;
|
|
206
|
+
} catch (err) {
|
|
207
|
+
// /proc/diskstats not available (normal on non-Linux or restricted environments)
|
|
208
|
+
this.logger?.debug?.({ err: err.message }, 'Could not read /proc/diskstats');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
// OS module or stats not available (normal on some platforms)
|
|
213
|
+
this.logger?.debug?.({ err: err.message }, 'Could not collect system stats');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return stats;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Query PostgreSQL internal statistics
|
|
221
|
+
*/
|
|
222
|
+
async collectPgStats() {
|
|
223
|
+
// Get admin pool from pgManager
|
|
224
|
+
const adminPool = this.pgManager?.adminPool;
|
|
225
|
+
if (!adminPool) return null;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
// Query pg_stat_activity for connection details
|
|
229
|
+
const activity = await adminPool`
|
|
230
|
+
SELECT
|
|
231
|
+
count(*) FILTER (WHERE state = 'active') as active_queries,
|
|
232
|
+
count(*) FILTER (WHERE state = 'idle') as idle_connections,
|
|
233
|
+
count(*) as total_connections
|
|
234
|
+
FROM pg_stat_activity
|
|
235
|
+
WHERE datname IS NOT NULL
|
|
236
|
+
`;
|
|
237
|
+
|
|
238
|
+
// Query pg_stat_database for DB-level stats
|
|
239
|
+
const dbStats = await adminPool`
|
|
240
|
+
SELECT
|
|
241
|
+
datname,
|
|
242
|
+
numbackends,
|
|
243
|
+
xact_commit,
|
|
244
|
+
xact_rollback,
|
|
245
|
+
blks_read,
|
|
246
|
+
blks_hit,
|
|
247
|
+
tup_returned,
|
|
248
|
+
tup_fetched,
|
|
249
|
+
tup_inserted,
|
|
250
|
+
tup_updated,
|
|
251
|
+
tup_deleted
|
|
252
|
+
FROM pg_stat_database
|
|
253
|
+
WHERE datname NOT IN ('template0', 'template1')
|
|
254
|
+
ORDER BY numbackends DESC
|
|
255
|
+
LIMIT 10
|
|
256
|
+
`;
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
activity: activity[0] || {},
|
|
260
|
+
databases: dbStats || []
|
|
261
|
+
};
|
|
262
|
+
} catch (err) {
|
|
263
|
+
this.logger?.debug?.({ err: err.message }, 'Failed to collect pg_stat_*');
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
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
|
+
// System resources section
|
|
265
|
+
const resourceLines = [];
|
|
266
|
+
|
|
267
|
+
// CPU usage
|
|
268
|
+
if (stats.process?.cpu !== undefined) {
|
|
269
|
+
const cpuPct = stats.process.cpu;
|
|
270
|
+
resourceLines.push(`${ANSI.DIM}CPU:${ANSI.RESET} ${cpuPct.toFixed(1)}% ${this.miniBar(cpuPct, 100, 15)}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Load average
|
|
274
|
+
if (stats.system?.loadAvg) {
|
|
275
|
+
const load = stats.system.loadAvg;
|
|
276
|
+
resourceLines.push(`${ANSI.DIM}Load Avg:${ANSI.RESET} ${load['1m'].toFixed(2)} / ${load['5m'].toFixed(2)} / ${load['15m'].toFixed(2)}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Memory
|
|
280
|
+
if (stats.process?.memory) {
|
|
281
|
+
const mem = stats.process.memory;
|
|
282
|
+
const rssMB = (mem.rss / 1024 / 1024).toFixed(1);
|
|
283
|
+
resourceLines.push(`${ANSI.DIM}Memory:${ANSI.RESET} ${rssMB} MB RSS`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Disk I/O
|
|
287
|
+
if (stats.system?.diskIO) {
|
|
288
|
+
const io = stats.system.diskIO;
|
|
289
|
+
const readSpeed = io.readMBps.toFixed(1);
|
|
290
|
+
const writeSpeed = io.writeMBps.toFixed(1);
|
|
291
|
+
const readIOPS = io.readIOPS.toFixed(0);
|
|
292
|
+
const writeIOPS = io.writeIOPS.toFixed(0);
|
|
293
|
+
resourceLines.push(`${ANSI.DIM}Disk I/O:${ANSI.RESET} R: ${readSpeed} MB/s (${readIOPS} IOPS) | W: ${writeSpeed} MB/s (${writeIOPS} IOPS)`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (resourceLines.length > 0) {
|
|
297
|
+
lines.push(this.section('RESOURCES', resourceLines));
|
|
298
|
+
lines.push('');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Footer
|
|
302
|
+
lines.push(`${ANSI.DIM}Last update: ${new Date().toLocaleTimeString()} | Refresh: ${this.refreshInterval / 1000}s | Press Ctrl+C to exit${ANSI.RESET}`);
|
|
303
|
+
|
|
304
|
+
return lines.join('\n');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Render header bar
|
|
309
|
+
*/
|
|
310
|
+
headerBar(width) {
|
|
311
|
+
const title = ' pgserve stats ';
|
|
312
|
+
const padding = Math.max(0, Math.floor((width - title.length) / 2));
|
|
313
|
+
const rightPad = Math.max(0, width - padding - title.length);
|
|
314
|
+
return `${ANSI.INVERSE}${' '.repeat(padding)}${title}${' '.repeat(rightPad)}${ANSI.RESET}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Render a section with title and lines
|
|
319
|
+
*/
|
|
320
|
+
section(title, contentLines) {
|
|
321
|
+
const header = `${ANSI.BOLD}${ANSI.CYAN}[${title}]${ANSI.RESET}`;
|
|
322
|
+
return [header, ...contentLines.map(l => ' ' + l)].join('\n');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Color a value based on thresholds
|
|
327
|
+
*/
|
|
328
|
+
colorValue(value, warnThreshold, errorThreshold) {
|
|
329
|
+
if (value >= errorThreshold) {
|
|
330
|
+
return `${ANSI.RED}${value}${ANSI.RESET}`;
|
|
331
|
+
} else if (value >= warnThreshold) {
|
|
332
|
+
return `${ANSI.YELLOW}${value}${ANSI.RESET}`;
|
|
333
|
+
}
|
|
334
|
+
return `${ANSI.GREEN}${value}${ANSI.RESET}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Render mini progress bar
|
|
339
|
+
*/
|
|
340
|
+
miniBar(current, max, width = 10) {
|
|
341
|
+
// Ensure valid numbers
|
|
342
|
+
const safeMax = Math.max(1, Number(max) || 1);
|
|
343
|
+
const safeCurrent = Math.max(0, Math.min(Number(current) || 0, safeMax));
|
|
344
|
+
|
|
345
|
+
const pct = safeCurrent / safeMax;
|
|
346
|
+
// filled is clamped to [0, width], so (width - filled) is always non-negative
|
|
347
|
+
const filled = Math.max(0, Math.min(width, Math.round(pct * width)));
|
|
348
|
+
const empty = width - filled;
|
|
349
|
+
|
|
350
|
+
let color = ANSI.GREEN;
|
|
351
|
+
if (pct > THRESHOLD_CRITICAL) color = ANSI.RED;
|
|
352
|
+
else if (pct > THRESHOLD_WARN) color = ANSI.YELLOW;
|
|
353
|
+
|
|
354
|
+
// Use Unicode block characters for progress bar
|
|
355
|
+
const filledChar = '\u2588'; // Full block
|
|
356
|
+
const emptyChar = '\u2591'; // Light shade
|
|
357
|
+
|
|
358
|
+
return `${color}[${filledChar.repeat(filled)}${ANSI.DIM}${emptyChar.repeat(empty)}${ANSI.RESET}${color}]${ANSI.RESET}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Format uptime as human-readable string
|
|
363
|
+
*/
|
|
364
|
+
formatUptime(seconds) {
|
|
365
|
+
const days = Math.floor(seconds / 86400);
|
|
366
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
367
|
+
const mins = Math.floor((seconds % 3600) / 60);
|
|
368
|
+
const secs = Math.floor(seconds % 60);
|
|
369
|
+
|
|
370
|
+
if (days > 0) return `${days}d ${hours}h ${mins}m`;
|
|
371
|
+
if (hours > 0) return `${hours}h ${mins}m ${secs}s`;
|
|
372
|
+
if (mins > 0) return `${mins}m ${secs}s`;
|
|
373
|
+
return `${secs}s`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Format large numbers with commas
|
|
378
|
+
*/
|
|
379
|
+
formatNumber(num) {
|
|
380
|
+
return num.toLocaleString();
|
|
381
|
+
}
|
|
382
|
+
}
|