pgserve 2.3.0 → 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.
- package/bin/pgserve-wrapper.cjs +5 -4
- package/bin/postgres-server.js +142 -631
- package/config/logrotate.d/pgserve +47 -0
- package/config/pgaudit.conf +31 -0
- package/package.json +2 -2
- package/scripts/test-npx.sh +32 -10
- package/src/cli-install.cjs +147 -77
- package/src/commands/uninstall.js +241 -0
- package/src/index.js +11 -44
- package/src/lib/admin-json.js +202 -0
- package/src/lib/pm2-args.js +119 -0
- package/src/lib/socket-dir.js +69 -0
- package/src/postgres.js +64 -5
- package/src/admin-client.js +0 -223
- package/src/audit.js +0 -168
- package/src/cluster.js +0 -654
- package/src/control-db.js +0 -330
- package/src/daemon-control.js +0 -468
- package/src/daemon-shared.js +0 -18
- package/src/daemon-tcp.js +0 -297
- package/src/daemon.js +0 -709
- package/src/dashboard.js +0 -217
- package/src/fingerprint.js +0 -479
- package/src/gc.js +0 -351
- package/src/pg-wire.js +0 -869
- package/src/protocol.js +0 -389
- package/src/restore.js +0 -574
- package/src/router.js +0 -546
- package/src/sdk.js +0 -137
- package/src/stats-collector.js +0 -453
- package/src/stats-dashboard.js +0 -401
- package/src/sync.js +0 -335
- package/src/tenancy.js +0 -75
- package/src/tokens.js +0 -102
package/src/stats-dashboard.js
DELETED
|
@@ -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
|
-
}
|