pyre-cli 4.17.0 → 5.1.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/README.md +197 -255
- package/dist/index.js +958 -81
- package/package.json +1 -1
- package/src/anomalies.ts +102 -0
- package/src/formatters/index.ts +3 -3
- package/src/formatters/output.ts +341 -8
- package/src/formatters/render.ts +53 -16
- package/src/formatters/types.ts +27 -16
- package/src/history.ts +57 -13
- package/src/index.ts +204 -11
- package/src/live/export.ts +64 -7
- package/src/live/input.ts +78 -34
- package/src/live/render.ts +114 -66
- package/src/live/state.ts +76 -70
- package/src/live/types.ts +4 -1
- package/src/monitors/collectors.ts +23 -1
- package/src/monitors/types.ts +1 -0
- package/src/state/config.ts +130 -0
package/src/history.ts
CHANGED
|
@@ -47,21 +47,65 @@ export class History {
|
|
|
47
47
|
txPacketRate: number[] = [];
|
|
48
48
|
|
|
49
49
|
/** Rolling window of active TCP connections. */
|
|
50
|
-
|
|
50
|
+
connections: number[] = [];
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
private lastRxBytes = 0;
|
|
53
|
+
private lastTxBytes = 0;
|
|
54
|
+
private lastRxPackets = 0;
|
|
55
|
+
private lastTxPackets = 0;
|
|
56
|
+
private lastTs = 0;
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Create a new History instance.
|
|
60
|
+
* @param maxLen - Maximum number of samples to retain. Defaults to 40.
|
|
61
|
+
*/
|
|
62
|
+
constructor(maxLen = 40) {
|
|
63
|
+
this.maxLen = maxLen;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compute the arithmetic mean of a numeric array.
|
|
68
|
+
* Returns 0 if the array is empty.
|
|
69
|
+
*/
|
|
70
|
+
static mean(arr: number[]): number {
|
|
71
|
+
if (!arr.length) return 0;
|
|
72
|
+
return arr.reduce((s, v) => s + v, 0) / arr.length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Compute the population standard deviation of a numeric array.
|
|
77
|
+
* Returns 0 if the array has fewer than 2 elements.
|
|
78
|
+
*/
|
|
79
|
+
static stdDev(arr: number[]): number {
|
|
80
|
+
if (arr.length < 2) return 0;
|
|
81
|
+
const m = History.mean(arr);
|
|
82
|
+
const variance = arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length;
|
|
83
|
+
return Math.sqrt(variance);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Compute the z-score of a value against a reference array.
|
|
88
|
+
* Returns 0 when the array is empty or has zero std dev.
|
|
89
|
+
*/
|
|
90
|
+
static zScore(value: number, arr: number[]): number {
|
|
91
|
+
const m = History.mean(arr);
|
|
92
|
+
const sd = History.stdDev(arr);
|
|
93
|
+
if (sd === 0) return 0;
|
|
94
|
+
return (value - m) / sd;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check whether a value is a statistical outlier against a
|
|
99
|
+
* reference array.
|
|
100
|
+
* @param value - The current reading
|
|
101
|
+
* @param arr - The rolling history window
|
|
102
|
+
* @param threshold - Z-score threshold (default 2.5)
|
|
103
|
+
* @returns true if the value deviates from the baseline by more than `threshold` standard deviations
|
|
104
|
+
*/
|
|
105
|
+
static isAnomaly(value: number, arr: number[], threshold = 2.5): boolean {
|
|
106
|
+
if (arr.length < 5) return false;
|
|
107
|
+
return Math.abs(History.zScore(value, arr)) > threshold;
|
|
108
|
+
}
|
|
65
109
|
|
|
66
110
|
/**
|
|
67
111
|
* Push a new sample into the rolling window.
|
package/src/index.ts
CHANGED
|
@@ -8,14 +8,19 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
11
12
|
import readline from 'node:readline';
|
|
12
13
|
import { Command } from 'commander';
|
|
13
14
|
import chalk from 'chalk';
|
|
14
15
|
import { collectAll } from './monitors/index.js';
|
|
15
|
-
import { formatTable, formatJson, formatCsv, formatTsv } from './formatters/index.js';
|
|
16
|
+
import { formatTable, formatJson, formatCsv, formatTsv, formatHtml, formatMarkdown, formatBytes } from './formatters/index.js';
|
|
16
17
|
import { startLive, stopLive } from './live/index.js';
|
|
17
18
|
import { showSplash } from './splash.js';
|
|
18
19
|
import { P2PServer, P2PClient } from './p2p/index.js';
|
|
20
|
+
import { readConfig } from './state/config.js';
|
|
21
|
+
import { CONFIG_FILE } from './state/config.js';
|
|
22
|
+
|
|
23
|
+
const config = readConfig();
|
|
19
24
|
|
|
20
25
|
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
21
26
|
|
|
@@ -26,19 +31,24 @@ program
|
|
|
26
31
|
.version(pkg.version)
|
|
27
32
|
.description('Mac system monitoring CLI: interactive live dashboard, stats, graphs, export, packet monitor, battery predictor')
|
|
28
33
|
.option('-j, --json', 'Output as JSON')
|
|
34
|
+
.option('--html', 'Output as HTML')
|
|
35
|
+
.option('--md', 'Output as Markdown')
|
|
29
36
|
.option('-c, --csv', 'Output as CSV')
|
|
30
37
|
.option('-t, --tsv', 'Output as TSV')
|
|
31
38
|
.option('--detailed', 'Include detailed system info and sensor readings')
|
|
32
|
-
.option('--theme <name>', 'Default theme for live mode (default, dracula, cyberpunk, monochrome, nord, gruvbox)',
|
|
33
|
-
.option('--interval <seconds>', 'Refresh interval for live mode',
|
|
39
|
+
.option('--theme <name>', 'Default theme for live mode (default, dracula, cyberpunk, monochrome, nord, gruvbox)', config.theme)
|
|
40
|
+
.option('--interval <seconds>', 'Refresh interval for live mode', String(config.interval))
|
|
34
41
|
.option('--once', 'Show a single static snapshot instead of live feed')
|
|
35
42
|
.option('--out <file>', 'Also write the snapshot output to a file (--once/--json/--csv/--tsv modes)')
|
|
36
|
-
.option('--export-dir <dir>', 'Directory used for live-mode snapshot exports and logs',
|
|
43
|
+
.option('--export-dir <dir>', 'Directory used for live-mode snapshot exports and logs', config.exportDir)
|
|
37
44
|
.option('--log', 'Start continuous CSV logging immediately when live mode starts')
|
|
38
45
|
.option('--tree', 'Show process tree view instead of flat list')
|
|
39
|
-
.option('--sort <key>', 'Sort processes by: cpu, mem, pid, user, command, state, threads, runtime',
|
|
46
|
+
.option('--sort <key>', 'Sort processes by: cpu, mem, pid, user, command, state, threads, runtime', config.sortMode)
|
|
40
47
|
.option('--packets', 'Include packet monitor panel in output')
|
|
41
48
|
.option('--limit <n>', 'Max number of processes to include in --once/--json/--csv/--tsv snapshots (0 = all)', '10')
|
|
49
|
+
.option('--alert-cpu <pct>', 'CPU usage alert threshold (default: 90)', String(config.cpuAlertPct))
|
|
50
|
+
.option('--alert-temp <c>', 'CPU temperature alert threshold in Celsius (default: 95)', String(config.tempAlertC))
|
|
51
|
+
.option('--temp-unit <unit>', 'Temperature display unit: c or f (default: c)', 'c')
|
|
42
52
|
.option('--p2p-host <host>', 'P2P host address (server: bind address, client: server address)')
|
|
43
53
|
.option('--p2p-port <port>', 'P2P port number', '9876')
|
|
44
54
|
.option('--p2p-password <password>', 'Password for P2P authentication')
|
|
@@ -58,7 +68,7 @@ program.parse(process.argv);
|
|
|
58
68
|
const opts = program.opts();
|
|
59
69
|
|
|
60
70
|
function isExportMode() {
|
|
61
|
-
return opts.json || opts.csv || opts.tsv;
|
|
71
|
+
return opts.json || opts.csv || opts.tsv || opts.html || opts.md;
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
function stripAnsi(s: string): string {
|
|
@@ -112,11 +122,59 @@ async function runServerCommand(): Promise<void> {
|
|
|
112
122
|
async function main() {
|
|
113
123
|
const cmd = program.args[0];
|
|
114
124
|
|
|
125
|
+
if (cmd === 'info') {
|
|
126
|
+
await runInfoCommand();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (cmd === 'ssh') {
|
|
131
|
+
const host = program.args[1];
|
|
132
|
+
if (!host) {
|
|
133
|
+
console.log(chalk.red('Usage: pyre ssh <host>'));
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
await runSshCommand(host);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (cmd === 'web') {
|
|
141
|
+
await runWebCommand();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (cmd === 'bench') {
|
|
146
|
+
const benchCmd = program.args.slice(1).join(' ');
|
|
147
|
+
if (!benchCmd) {
|
|
148
|
+
console.log(chalk.red('Usage: pyre bench <command>'));
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
await runBenchCommand(benchCmd);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (cmd === 'config') {
|
|
156
|
+
const sub = program.args[1];
|
|
157
|
+
if (sub === 'show') {
|
|
158
|
+
const cfg = readConfig();
|
|
159
|
+
console.log(JSON.stringify(cfg, null, 2));
|
|
160
|
+
} else if (sub === 'reset') {
|
|
161
|
+
fs.rmSync(CONFIG_FILE, { force: true });
|
|
162
|
+
console.log(chalk.green('Config reset to defaults'));
|
|
163
|
+
} else {
|
|
164
|
+
console.log('Usage: pyre config <show|reset>');
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
115
169
|
if (cmd === 'server') {
|
|
116
170
|
await runServerCommand();
|
|
117
171
|
return;
|
|
118
172
|
}
|
|
119
173
|
|
|
174
|
+
const alertCpu = opts.alertCpu ? parseInt(opts.alertCpu, 10) : undefined;
|
|
175
|
+
const alertTemp = opts.alertTemp ? parseInt(opts.alertTemp, 10) : undefined;
|
|
176
|
+
const tempUnit = opts.tempUnit === 'f' ? 'f' : 'c';
|
|
177
|
+
|
|
120
178
|
if (cmd === 'live' || (!isExportMode() && !opts.once && cmd !== 'p2p')) {
|
|
121
179
|
if (cmd === 'live') {
|
|
122
180
|
program.args.shift();
|
|
@@ -125,10 +183,13 @@ async function main() {
|
|
|
125
183
|
const interval = parseFloat(opts.interval) || 2;
|
|
126
184
|
await startLive({
|
|
127
185
|
interval,
|
|
128
|
-
detailed: opts.detailed,
|
|
129
|
-
theme: opts.theme,
|
|
130
|
-
exportDir: opts.exportDir,
|
|
131
|
-
autoLog: opts.log,
|
|
186
|
+
detailed: opts.detailed ?? config.detailed,
|
|
187
|
+
theme: opts.theme || config.theme,
|
|
188
|
+
exportDir: opts.exportDir || config.exportDir,
|
|
189
|
+
autoLog: opts.log || config.autoLog,
|
|
190
|
+
alertCpu,
|
|
191
|
+
alertTemp,
|
|
192
|
+
tempUnit,
|
|
132
193
|
}, splashPromise);
|
|
133
194
|
return;
|
|
134
195
|
}
|
|
@@ -156,9 +217,11 @@ async function main() {
|
|
|
156
217
|
|
|
157
218
|
let output: string;
|
|
158
219
|
if (opts.json) output = formatJson(data);
|
|
220
|
+
else if (opts.html) output = formatHtml(data);
|
|
221
|
+
else if (opts.md) output = formatMarkdown(data);
|
|
159
222
|
else if (opts.csv) output = formatCsv(data);
|
|
160
223
|
else if (opts.tsv) output = formatTsv(data);
|
|
161
|
-
else output = formatTable(data, { width: process.stdout.columns || 80, sortBy: opts.sort, treeView: opts.tree, visible: { packets: opts.packets ? true : undefined } });
|
|
224
|
+
else output = formatTable(data, { width: process.stdout.columns || 80, sortBy: opts.sort, treeView: opts.tree ?? config.treeView, visible: { packets: opts.packets ? true : undefined } });
|
|
162
225
|
|
|
163
226
|
console.log(output);
|
|
164
227
|
|
|
@@ -363,4 +426,134 @@ async function runP2PConnect(): Promise<void> {
|
|
|
363
426
|
process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
|
|
364
427
|
process.exit(0);
|
|
365
428
|
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function runInfoCommand(): Promise<void> {
|
|
432
|
+
const data = await collectAll({ detailed: true });
|
|
433
|
+
const lines: string[] = [];
|
|
434
|
+
lines.push(chalk.bold(' pyre info'));
|
|
435
|
+
lines.push('');
|
|
436
|
+
lines.push(` Hostname: ${data.header.hostname}`);
|
|
437
|
+
lines.push(` OS: ${data.header.os}`);
|
|
438
|
+
lines.push(` Uptime: ${data.header.uptime}`);
|
|
439
|
+
lines.push(` CPU: ${data.cpu.brand}`);
|
|
440
|
+
lines.push(` Cores: ${data.cpu.physicalCores}/${data.cpu.cores} (phys/log)`);
|
|
441
|
+
lines.push(` Frequency: ${data.cpu.frequency} MHz`);
|
|
442
|
+
lines.push(` Memory: ${formatBytes(data.memory.total)}`);
|
|
443
|
+
if (data.gpu) lines.push(` GPU: ${data.gpu.model} (${formatBytes(data.gpu.memory)})`);
|
|
444
|
+
if (data.battery) {
|
|
445
|
+
lines.push(` Battery: ${data.battery.level}% (${data.battery.state})`);
|
|
446
|
+
lines.push(` Health: ${data.battery.health}`);
|
|
447
|
+
}
|
|
448
|
+
lines.push(` Thermal: ${data.thermal.state}`);
|
|
449
|
+
console.log(lines.join('\n'));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function runSshCommand(host: string): Promise<void> {
|
|
453
|
+
const password = opts.p2pPassword || 'mysecret';
|
|
454
|
+
const cmd = `ssh -o LogLevel=QUIET ${host} "pyre --json --once"`;
|
|
455
|
+
console.log(chalk.bold(`\n pyre ssh — ${host}`));
|
|
456
|
+
console.log(chalk.dim(` Running: ${cmd}\n`));
|
|
457
|
+
|
|
458
|
+
const { execFile } = await import('node:child_process');
|
|
459
|
+
const child = execFile('ssh', ['-o', 'LogLevel=QUIET', host, 'pyre', '--json', '--once'], (err, stdout, stderr) => {
|
|
460
|
+
if (err) {
|
|
461
|
+
console.log(chalk.red(` SSH failed: ${err.message}`));
|
|
462
|
+
console.log(chalk.dim(' Ensure SSH keys are set up or the host is reachable.'));
|
|
463
|
+
process.exit(1);
|
|
464
|
+
}
|
|
465
|
+
if (stderr) console.log(chalk.dim(stderr));
|
|
466
|
+
try {
|
|
467
|
+
const remote = JSON.parse(stdout);
|
|
468
|
+
console.log(formatTable(remote, { width: process.stdout.columns || 80, sortBy: 'cpu', treeView: false, visible: { packets: true } }));
|
|
469
|
+
} catch {
|
|
470
|
+
console.log(stdout);
|
|
471
|
+
}
|
|
472
|
+
process.exit(0);
|
|
473
|
+
});
|
|
474
|
+
child.stdin.end();
|
|
475
|
+
|
|
476
|
+
process.once('SIGINT', () => {
|
|
477
|
+
child.kill('SIGTERM');
|
|
478
|
+
process.exit(0);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function runWebCommand(): Promise<void> {
|
|
483
|
+
const http = await import('node:http');
|
|
484
|
+
const data = await collectAll({ detailed: true });
|
|
485
|
+
|
|
486
|
+
const server = http.createServer((req, res) => {
|
|
487
|
+
if (req.url === '/' || req.url === '/index.html') {
|
|
488
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
489
|
+
res.end(formatHtml(data));
|
|
490
|
+
} else if (req.url === '/api') {
|
|
491
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
492
|
+
res.end(JSON.stringify(data, null, 2));
|
|
493
|
+
} else {
|
|
494
|
+
res.writeHead(404);
|
|
495
|
+
res.end('Not found');
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
500
|
+
const address = server.address();
|
|
501
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
502
|
+
const url = `http://127.0.0.1:${port}`;
|
|
503
|
+
console.log(chalk.bold(`\n pyre web`));
|
|
504
|
+
console.log(chalk.dim(` Dashboard: ${url}`));
|
|
505
|
+
console.log(chalk.dim(` API: ${url}/api`));
|
|
506
|
+
console.log(chalk.dim(' Press Ctrl+C to stop\n'));
|
|
507
|
+
|
|
508
|
+
process.once('SIGINT', () => {
|
|
509
|
+
server.close();
|
|
510
|
+
process.exit(0);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function runBenchCommand(benchCmd: string): Promise<void> {
|
|
516
|
+
const { spawn } = await import('node:child_process');
|
|
517
|
+
const logDir = opts.exportDir || './pyre-exports';
|
|
518
|
+
const file = path.join(logDir, `pyre-bench-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`);
|
|
519
|
+
|
|
520
|
+
if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
|
|
521
|
+
const stream = fs.createWriteStream(file, { flags: 'a' });
|
|
522
|
+
stream.write('timestamp,cpu_usage,mem_usage_percent,temp_c,net_rx_bytes,net_tx_bytes,net_rx_packets,net_tx_packets,connections,thermal_state\n');
|
|
523
|
+
|
|
524
|
+
console.log(chalk.bold(`\n pyre bench`));
|
|
525
|
+
console.log(chalk.dim(` Command: ${benchCmd}`));
|
|
526
|
+
console.log(chalk.dim(` Log: ${file}\n`));
|
|
527
|
+
|
|
528
|
+
const child = spawn('sh', ['-c', benchCmd]);
|
|
529
|
+
child.stdout.on('data', d => process.stdout.write(d));
|
|
530
|
+
child.stderr.on('data', d => process.stdout.write(d));
|
|
531
|
+
|
|
532
|
+
const interval = parseFloat(opts.interval) || 2;
|
|
533
|
+
const handle = setInterval(async () => {
|
|
534
|
+
try {
|
|
535
|
+
const data = await collectAll({ detailed: opts.detailed });
|
|
536
|
+
const temp = data.cpu.temperature ?? data.thermal.temperatures?.cpu_die ?? '';
|
|
537
|
+
const rxPackets = data.network.rxPackets ?? 0;
|
|
538
|
+
const txPackets = data.network.txPackets ?? 0;
|
|
539
|
+
const connections = data.network.connections ?? 0;
|
|
540
|
+
stream.write(`${data.timestamp},${data.cpu.usage},${data.memory.usagePercent},${temp},${data.network.rxBytes},${data.network.txBytes},${rxPackets},${txPackets},${connections},${data.thermal.state}\n`);
|
|
541
|
+
} catch {
|
|
542
|
+
// skip bad tick
|
|
543
|
+
}
|
|
544
|
+
}, interval * 1000);
|
|
545
|
+
|
|
546
|
+
child.on('close', () => {
|
|
547
|
+
clearInterval(handle);
|
|
548
|
+
stream.end();
|
|
549
|
+
console.log(chalk.green(`\n Bench complete. Log saved to ${file}`));
|
|
550
|
+
process.exit(0);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
process.once('SIGINT', () => {
|
|
554
|
+
clearInterval(handle);
|
|
555
|
+
child.kill('SIGTERM');
|
|
556
|
+
stream.end();
|
|
557
|
+
process.exit(0);
|
|
558
|
+
});
|
|
366
559
|
}
|
package/src/live/export.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Live dashboard export and logging helpers.
|
|
3
3
|
*
|
|
4
|
-
* Handles snapshot export (JSON/CSV/TSV), continuous
|
|
5
|
-
* CSV logging, and the supporting file-system utilities.
|
|
4
|
+
* Handles snapshot export (JSON/CSV/TSV/HTML/Markdown), continuous
|
|
5
|
+
* CSV logging with rotation, and the supporting file-system utilities.
|
|
6
6
|
* All state variables are imported from `state.ts`.
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import { formatJson, formatCsv, formatTsv } from '../formatters/index.js';
|
|
10
|
+
import { formatJson, formatCsv, formatTsv, formatHtml, formatMarkdown } from '../formatters/index.js';
|
|
11
11
|
import { state, setStatus } from './state.js';
|
|
12
12
|
|
|
13
13
|
/** Ensure a directory exists, creating it recursively if needed. */
|
|
@@ -27,9 +27,16 @@ function exportSnapshot() {
|
|
|
27
27
|
}
|
|
28
28
|
try {
|
|
29
29
|
ensureDir(state.exportDir);
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
30
|
+
const ext = state.exportFormat === 'json' ? 'json' : state.exportFormat === 'csv' ? 'csv' : state.exportFormat === 'tsv' ? 'tsv' : state.exportFormat === 'html' ? 'html' : 'md';
|
|
31
|
+
const file = path.join(state.exportDir, `pyre-${timestampForFile()}.${ext}`);
|
|
32
|
+
let content: string;
|
|
33
|
+
switch (state.exportFormat) {
|
|
34
|
+
case 'json': content = formatJson(state.lastData); break;
|
|
35
|
+
case 'csv': content = formatCsv(state.lastData); break;
|
|
36
|
+
case 'tsv': content = formatTsv(state.lastData); break;
|
|
37
|
+
case 'html': content = formatHtml(state.lastData); break;
|
|
38
|
+
case 'md': content = formatMarkdown(state.lastData); break;
|
|
39
|
+
}
|
|
33
40
|
fs.writeFileSync(file, content);
|
|
34
41
|
setStatus(`Exported snapshot -> ${file}`);
|
|
35
42
|
} catch (err: any) {
|
|
@@ -40,6 +47,7 @@ function exportSnapshot() {
|
|
|
40
47
|
function startLogging() {
|
|
41
48
|
try {
|
|
42
49
|
ensureDir(state.exportDir);
|
|
50
|
+
rotateLogs();
|
|
43
51
|
const file = path.join(state.exportDir, `pyre-log-${timestampForFile()}.csv`);
|
|
44
52
|
state.logStream = fs.createWriteStream(file, { flags: 'a' });
|
|
45
53
|
state.logStream.write('timestamp,cpu_usage,mem_usage_percent,temp_c,net_rx_bytes,net_tx_bytes,net_rx_packets,net_tx_packets,connections,thermal_state\n');
|
|
@@ -50,6 +58,52 @@ function startLogging() {
|
|
|
50
58
|
}
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
const MAX_LOG_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
62
|
+
const MAX_LOG_COUNT = 20;
|
|
63
|
+
const MAX_LOG_BYTES = 50 * 1024 * 1024;
|
|
64
|
+
|
|
65
|
+
function rotateLogs() {
|
|
66
|
+
try {
|
|
67
|
+
const dir = state.exportDir;
|
|
68
|
+
if (!fs.existsSync(dir)) return;
|
|
69
|
+
const files = fs.readdirSync(dir)
|
|
70
|
+
.filter(f => f.startsWith('pyre-log-') && f.endsWith('.csv'))
|
|
71
|
+
.map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtimeMs, size: fs.statSync(path.join(dir, f)).size }))
|
|
72
|
+
.sort((a, b) => a.mtime - b.mtime);
|
|
73
|
+
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
for (const f of files) {
|
|
76
|
+
if (now - f.mtime > MAX_LOG_AGE_MS) {
|
|
77
|
+
try { fs.unlinkSync(f.path); } catch { /* ignore */ }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const remaining = fs.readdirSync(dir).filter(f => f.startsWith('pyre-log-') && f.endsWith('.csv'));
|
|
82
|
+
if (remaining.length > MAX_LOG_COUNT) {
|
|
83
|
+
const toDelete = remaining.sort().slice(0, remaining.length - MAX_LOG_COUNT);
|
|
84
|
+
for (const f of toDelete) {
|
|
85
|
+
try { fs.unlinkSync(path.join(dir, f)); } catch { /* ignore */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let totalSize = 0;
|
|
90
|
+
for (const f of remaining) {
|
|
91
|
+
totalSize += fs.statSync(path.join(dir, f)).size;
|
|
92
|
+
}
|
|
93
|
+
if (totalSize > MAX_LOG_BYTES) {
|
|
94
|
+
const sorted = remaining.sort();
|
|
95
|
+
for (const f of sorted) {
|
|
96
|
+
const sz = fs.statSync(path.join(dir, f)).size;
|
|
97
|
+
try { fs.unlinkSync(path.join(dir, f)); } catch { /* ignore */ }
|
|
98
|
+
totalSize -= sz;
|
|
99
|
+
if (totalSize <= MAX_LOG_BYTES * 0.8) break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore rotation errors
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
53
107
|
function stopLogging() {
|
|
54
108
|
state.logging = false;
|
|
55
109
|
state.logStream?.end();
|
|
@@ -64,7 +118,10 @@ function toggleLogging() {
|
|
|
64
118
|
|
|
65
119
|
function writeLogRow(data: any) {
|
|
66
120
|
if (!state.logging || !state.logStream) return;
|
|
67
|
-
|
|
121
|
+
let temp = data.cpu.temperature ?? data.thermal.temperatures?.cpu_die ?? '';
|
|
122
|
+
if (temp !== '' && state.tempUnit === 'f') {
|
|
123
|
+
temp = ((temp * 9 / 5) + 32).toFixed(1);
|
|
124
|
+
}
|
|
68
125
|
const rxPackets = data.network.rxPackets ?? 0;
|
|
69
126
|
const txPackets = data.network.txPackets ?? 0;
|
|
70
127
|
const connections = data.network.connections ?? 0;
|
package/src/live/input.ts
CHANGED
|
@@ -15,8 +15,33 @@ import { exportSnapshot, startLogging, stopLogging, toggleLogging, writeLogRow }
|
|
|
15
15
|
import { render, footerLine, checkAlerts, invalidateTableCache, invalidateFrame } from './render.js';
|
|
16
16
|
import type { LiveOptions, ExportFormat, InputMode, SortMode, GraphMode } from './types.js';
|
|
17
17
|
import { startP2PServer } from '../p2p/index.js';
|
|
18
|
+
import { writeConfig } from '../state/config.js';
|
|
19
|
+
|
|
20
|
+
function persistConfig() {
|
|
21
|
+
writeConfig({
|
|
22
|
+
theme: state.currentTheme,
|
|
23
|
+
interval: state.interval,
|
|
24
|
+
exportDir: state.exportDir,
|
|
25
|
+
detailed: state.detailed,
|
|
26
|
+
sortMode: state.sortMode,
|
|
27
|
+
treeView: state.treeView,
|
|
28
|
+
graphMode: state.graphMode,
|
|
29
|
+
showGraphs: state.showGraphs,
|
|
30
|
+
autoLog: state.logging,
|
|
31
|
+
mouseEnabled: state.mouseEnabled,
|
|
32
|
+
cpuAlertPct: state.CPU_ALERT_PCT,
|
|
33
|
+
tempAlertC: state.TEMP_ALERT_C,
|
|
34
|
+
p2pPort: state.p2pPort,
|
|
35
|
+
p2pPassword: state.p2pPassword,
|
|
36
|
+
splashEnabled: state.splashEnabled,
|
|
37
|
+
splashColorScheme: state.splashColorScheme,
|
|
38
|
+
splashAnimation: state.splashAnimation,
|
|
39
|
+
visiblePanels: { ...state.visiblePanels },
|
|
40
|
+
notificationsEnabled: state.notificationsEnabled,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
18
43
|
|
|
19
|
-
function isUpKey(key: readline.Key, str?: string): boolean {
|
|
44
|
+
function isUpKey(key: readline.Key, str?: string): boolean {
|
|
20
45
|
return key.name === 'up' || str === 'k' || key.sequence === '\x1b[A' || key.sequence === '\x1bOA';
|
|
21
46
|
}
|
|
22
47
|
function isDownKey(key: readline.Key, str?: string): boolean {
|
|
@@ -59,28 +84,35 @@ function handleInputModeKey(str: string, key: readline.Key) {
|
|
|
59
84
|
const curIdx = schemes.indexOf(state.splashColorScheme);
|
|
60
85
|
state.splashColorScheme = schemes[(curIdx + 1) % schemes.length];
|
|
61
86
|
setStatus(`Splash color: ${state.splashColorScheme}`);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
} else if (selected === 'Splash Animation') {
|
|
88
|
+
const animations: SplashAnimation[] = ['classic', 'wave', 'sparks'];
|
|
89
|
+
const curIdx = animations.indexOf(state.splashAnimation);
|
|
90
|
+
state.splashAnimation = animations[(curIdx + 1) % animations.length];
|
|
91
|
+
setStatus(`Splash animation: ${state.splashAnimation}`);
|
|
92
|
+
} else if (selected === 'Notifications') {
|
|
93
|
+
state.notificationsEnabled = !state.notificationsEnabled;
|
|
94
|
+
setStatus(`Notifications: ${state.notificationsEnabled ? 'on' : 'off'}`);
|
|
95
|
+
} else if (selected === 'Temperature Unit') {
|
|
96
|
+
state.tempUnit = state.tempUnit === 'c' ? 'f' : 'c';
|
|
97
|
+
setStatus(`Temperature unit: ${state.tempUnit.toUpperCase()}`);
|
|
98
|
+
} else {
|
|
99
|
+
const toggleKey = getToggleKey(selected);
|
|
100
|
+
if (toggleKey) {
|
|
101
|
+
if (toggleKey === 'tree') {
|
|
102
|
+
state.treeView = !state.treeView;
|
|
103
|
+
state.visiblePanels.tree = state.treeView;
|
|
104
|
+
} else {
|
|
105
|
+
const isVisible = state.visiblePanels[toggleKey] !== false;
|
|
106
|
+
state.visiblePanels[toggleKey] = !isVisible;
|
|
107
|
+
}
|
|
76
108
|
}
|
|
77
109
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
110
|
+
invalidateTableCache();
|
|
111
|
+
invalidateFrame();
|
|
112
|
+
persistConfig();
|
|
113
|
+
}
|
|
114
|
+
render();
|
|
115
|
+
return;
|
|
84
116
|
}
|
|
85
117
|
|
|
86
118
|
if (state.inputMode === 'signal') {
|
|
@@ -310,18 +342,21 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
|
|
|
310
342
|
* rendering interval. Idempotent — calling while already
|
|
311
343
|
* running is a no-op.
|
|
312
344
|
*/
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
345
|
+
export async function startLive(opts: LiveOptions, splashPromise?: Promise<void>) {
|
|
346
|
+
if (state.running) return;
|
|
347
|
+
state.running = true;
|
|
348
|
+
state.paused = false;
|
|
349
|
+
state.detailed = !!opts.detailed;
|
|
350
|
+
if (opts.theme) state.currentTheme = opts.theme;
|
|
351
|
+
state.interval = opts.interval;
|
|
352
|
+
if (opts.exportDir) state.exportDir = opts.exportDir;
|
|
353
|
+
if (opts.alertCpu !== undefined) state.CPU_ALERT_PCT = opts.alertCpu;
|
|
354
|
+
if (opts.alertTemp !== undefined) state.TEMP_ALERT_C = opts.alertTemp;
|
|
355
|
+
if (opts.tempUnit) state.tempUnit = opts.tempUnit;
|
|
356
|
+
state.termWidth = process.stdout.columns || 80;
|
|
357
|
+
state.termHeight = process.stdout.rows || 24;
|
|
358
|
+
state.history.reset();
|
|
359
|
+
state.history.setMaxLen(Math.max(20, Math.min(200, state.termWidth - 30)));
|
|
325
360
|
|
|
326
361
|
const warmupPromise = doWarmup();
|
|
327
362
|
|
|
@@ -467,6 +502,12 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
|
|
|
467
502
|
state.detailed = !state.detailed;
|
|
468
503
|
setStatus(`Detailed sensor mode: ${state.detailed ? 'on' : 'off'}`);
|
|
469
504
|
break;
|
|
505
|
+
case 'T':
|
|
506
|
+
state.tempUnit = state.tempUnit === 'c' ? 'f' : 'c';
|
|
507
|
+
setStatus(`Temperature unit: ${state.tempUnit.toUpperCase()}`);
|
|
508
|
+
invalidateTableCache();
|
|
509
|
+
render();
|
|
510
|
+
break;
|
|
470
511
|
case 's':
|
|
471
512
|
const sortCycle: SortMode[] = ['cpu', 'mem', 'pid', 'user', 'command', 'state', 'threads', 'runtime'];
|
|
472
513
|
const curIdx = sortCycle.indexOf(state.sortMode);
|
|
@@ -498,7 +539,9 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
|
|
|
498
539
|
render();
|
|
499
540
|
break;
|
|
500
541
|
case 'f':
|
|
501
|
-
|
|
542
|
+
const fmtCycle: ExportFormat[] = ['json', 'csv', 'tsv', 'html', 'md'];
|
|
543
|
+
const curFmt = fmtCycle.indexOf(state.exportFormat);
|
|
544
|
+
state.exportFormat = fmtCycle[(curFmt + 1) % fmtCycle.length];
|
|
502
545
|
setStatus(`Export format: ${state.exportFormat}`);
|
|
503
546
|
render();
|
|
504
547
|
break;
|
|
@@ -574,6 +617,7 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
|
|
|
574
617
|
}
|
|
575
618
|
|
|
576
619
|
export function stopLive() {
|
|
620
|
+
persistConfig();
|
|
577
621
|
if (state.intervalHandle) {
|
|
578
622
|
clearInterval(state.intervalHandle);
|
|
579
623
|
state.intervalHandle = null;
|