pyre-cli 5.1.0 → 6.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 +1 -1
- package/dist/index.js +922 -367
- package/package.json +1 -1
- package/src/completions.ts +118 -0
- package/src/diffCmd.ts +36 -0
- package/src/doctor.ts +123 -0
- package/src/formatters/index.ts +1 -1
- package/src/formatters/output.ts +14 -13
- package/src/formatters/render.ts +305 -114
- package/src/formatters/themes.ts +47 -12
- package/src/formatters/types.ts +6 -1
- package/src/historyCmd.ts +71 -0
- package/src/index.ts +129 -24
- package/src/live/input.ts +14 -5
- package/src/live/render.ts +126 -90
- package/src/live/state.ts +3 -0
- package/src/live/types.ts +6 -1
- package/src/monitors/collectors.ts +160 -36
- package/src/monitors/smc.ts +60 -12
- package/src/monitors/types.ts +2 -0
- package/src/p2p/server.test.ts +2 -2
- package/src/sparkline.ts +82 -13
- package/src/state/config.ts +6 -1
- package/Formula/pyre.rb +0 -19
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { sparkline } from './sparkline.js';
|
|
5
|
+
|
|
6
|
+
export interface HistoryOptions {
|
|
7
|
+
days?: number;
|
|
8
|
+
dir?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function runHistoryCommand(opts: HistoryOptions = {}): void {
|
|
12
|
+
const days = opts.days || 7;
|
|
13
|
+
const dir = opts.dir || './pyre-exports';
|
|
14
|
+
|
|
15
|
+
console.log(chalk.bold(`\n pyre history — Past ${days} Days Trend\n`));
|
|
16
|
+
|
|
17
|
+
if (!fs.existsSync(dir)) {
|
|
18
|
+
console.log(chalk.yellow(` No logs directory found at ${dir}`));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const files = fs.readdirSync(dir).filter(f => f.startsWith('pyre-log-') && f.endsWith('.csv'));
|
|
23
|
+
if (files.length === 0) {
|
|
24
|
+
console.log(chalk.yellow(` No CSV logs found in ${dir}`));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
29
|
+
const cpuPoints: number[] = [];
|
|
30
|
+
const memPoints: number[] = [];
|
|
31
|
+
const tempPoints: number[] = [];
|
|
32
|
+
|
|
33
|
+
for (const file of files.sort()) {
|
|
34
|
+
const fullPath = path.join(dir, file);
|
|
35
|
+
try {
|
|
36
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
37
|
+
const lines = content.split('\n').slice(1);
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
if (!line.trim()) continue;
|
|
40
|
+
const parts = line.split(',');
|
|
41
|
+
if (parts.length >= 4) {
|
|
42
|
+
const tsStr = parts[0];
|
|
43
|
+
const ts = new Date(tsStr).getTime();
|
|
44
|
+
if (!isNaN(ts) && ts >= cutoff) {
|
|
45
|
+
const cpu = parseFloat(parts[1]);
|
|
46
|
+
const mem = parseFloat(parts[2]);
|
|
47
|
+
const temp = parseFloat(parts[3]);
|
|
48
|
+
if (!isNaN(cpu)) cpuPoints.push(cpu);
|
|
49
|
+
if (!isNaN(mem)) memPoints.push(mem);
|
|
50
|
+
if (!isNaN(temp)) tempPoints.push(temp);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// ignore
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (cpuPoints.length === 0) {
|
|
60
|
+
console.log(chalk.yellow(` No historical entries recorded within the last ${days} days.`));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(` Data Points: ${cpuPoints.length} logged snapshots`);
|
|
65
|
+
console.log(` CPU Usage: ${sparkline(cpuPoints, { min: 0, max: 100 })} (${(cpuPoints.reduce((a, b) => a + b, 0) / cpuPoints.length).toFixed(1)}% avg)`);
|
|
66
|
+
console.log(` Memory: ${sparkline(memPoints, { min: 0, max: 100 })} (${(memPoints.reduce((a, b) => a + b, 0) / memPoints.length).toFixed(1)}% avg)`);
|
|
67
|
+
if (tempPoints.length) {
|
|
68
|
+
console.log(` Temperature: ${sparkline(tempPoints)} (${(tempPoints.reduce((a, b) => a + b, 0) / tempPoints.length).toFixed(1)}°C avg)`);
|
|
69
|
+
}
|
|
70
|
+
console.log();
|
|
71
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,8 +17,11 @@ import { formatTable, formatJson, formatCsv, formatTsv, formatHtml, formatMarkdo
|
|
|
17
17
|
import { startLive, stopLive } from './live/index.js';
|
|
18
18
|
import { showSplash } from './splash.js';
|
|
19
19
|
import { P2PServer, P2PClient } from './p2p/index.js';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
20
|
+
import { runDoctor, printDoctorReport } from './doctor.js';
|
|
21
|
+
import { generateZshCompletions, generateBashCompletions, generateFishCompletions } from './completions.js';
|
|
22
|
+
import { runHistoryCommand } from './historyCmd.js';
|
|
23
|
+
import { runDiffCommand } from './diffCmd.js';
|
|
24
|
+
import { readConfig, CONFIG_FILE } from './state/config.js';
|
|
22
25
|
|
|
23
26
|
const config = readConfig();
|
|
24
27
|
|
|
@@ -61,7 +64,11 @@ program
|
|
|
61
64
|
.option('--p2p-allow <ips>', 'Comma-separated list of allowed IPs (empty = all)')
|
|
62
65
|
.option('--p2p-deny <ips>', 'Comma-separated list of denied IPs')
|
|
63
66
|
.option('--p2p-audit-log <dir>', 'Directory for P2P audit logs')
|
|
64
|
-
.option('--p2p-hmac-key <key>', 'HMAC key for message signing (default: derived from password)')
|
|
67
|
+
.option('--p2p-hmac-key <key>', 'HMAC key for message signing (default: derived from password)')
|
|
68
|
+
.option('--port <port>', 'Port number for web server mode', '3000')
|
|
69
|
+
.option('--web-auth', 'Enable Basic Authentication for web server')
|
|
70
|
+
.option('--web-user <user>', 'Username for web server authentication', 'admin')
|
|
71
|
+
.option('--web-pass <pass>', 'Password for web server authentication', 'pyre');
|
|
65
72
|
|
|
66
73
|
program.parse(process.argv);
|
|
67
74
|
|
|
@@ -122,8 +129,21 @@ async function runServerCommand(): Promise<void> {
|
|
|
122
129
|
async function main() {
|
|
123
130
|
const cmd = program.args[0];
|
|
124
131
|
|
|
125
|
-
if (cmd === '
|
|
126
|
-
|
|
132
|
+
if (cmd === 'history') {
|
|
133
|
+
const daysIdx = program.args.indexOf('--days');
|
|
134
|
+
const days = daysIdx !== -1 ? parseInt(program.args[daysIdx + 1], 10) || 7 : 7;
|
|
135
|
+
runHistoryCommand({ days });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (cmd === 'diff') {
|
|
140
|
+
const file1 = program.args[1];
|
|
141
|
+
const file2 = program.args[2];
|
|
142
|
+
if (!file1 || !file2) {
|
|
143
|
+
console.log(chalk.red('Usage: pyre diff <snapshot1.json> <snapshot2.json>'));
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
runDiffCommand(file1, file2);
|
|
127
147
|
return;
|
|
128
148
|
}
|
|
129
149
|
|
|
@@ -176,10 +196,18 @@ async function main() {
|
|
|
176
196
|
const tempUnit = opts.tempUnit === 'f' ? 'f' : 'c';
|
|
177
197
|
|
|
178
198
|
if (cmd === 'live' || (!isExportMode() && !opts.once && cmd !== 'p2p')) {
|
|
199
|
+
if (!process.stdout.isTTY && cmd !== 'live') {
|
|
200
|
+
await runWebCommand();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
179
203
|
if (cmd === 'live') {
|
|
180
204
|
program.args.shift();
|
|
181
205
|
}
|
|
182
|
-
const splashPromise = showSplash(
|
|
206
|
+
const splashPromise = showSplash({
|
|
207
|
+
enabled: config.splashEnabled,
|
|
208
|
+
colorScheme: config.splashColorScheme as any,
|
|
209
|
+
animation: config.splashAnimation as any,
|
|
210
|
+
});
|
|
183
211
|
const interval = parseFloat(opts.interval) || 2;
|
|
184
212
|
await startLive({
|
|
185
213
|
interval,
|
|
@@ -471,7 +499,7 @@ async function runSshCommand(host: string): Promise<void> {
|
|
|
471
499
|
}
|
|
472
500
|
process.exit(0);
|
|
473
501
|
});
|
|
474
|
-
child.stdin
|
|
502
|
+
child.stdin?.end();
|
|
475
503
|
|
|
476
504
|
process.once('SIGINT', () => {
|
|
477
505
|
child.kill('SIGTERM');
|
|
@@ -481,14 +509,90 @@ async function runSshCommand(host: string): Promise<void> {
|
|
|
481
509
|
|
|
482
510
|
async function runWebCommand(): Promise<void> {
|
|
483
511
|
const http = await import('node:http');
|
|
484
|
-
const
|
|
512
|
+
const port = parseInt(opts.port || process.env.PORT || '3000', 10) || 3000;
|
|
513
|
+
const authEnabled = !!opts.webAuth;
|
|
514
|
+
const authUser = opts.webUser || 'admin';
|
|
515
|
+
const authPass = opts.webPass || 'pyre';
|
|
516
|
+
|
|
517
|
+
const server = http.createServer(async (req, res) => {
|
|
518
|
+
// 1. Basic Authentication Check
|
|
519
|
+
if (authEnabled) {
|
|
520
|
+
const authHeader = req.headers.authorization;
|
|
521
|
+
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
522
|
+
res.writeHead(401, {
|
|
523
|
+
'WWW-Authenticate': 'Basic realm="pyre Web Dashboard"',
|
|
524
|
+
'Content-Type': 'text/plain',
|
|
525
|
+
});
|
|
526
|
+
res.end('401 Unauthorized');
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const credentials = Buffer.from(authHeader.split(' ')[1], 'base64').toString('utf-8');
|
|
530
|
+
const [user, pass] = credentials.split(':');
|
|
531
|
+
if (user !== authUser || pass !== authPass) {
|
|
532
|
+
res.writeHead(401, {
|
|
533
|
+
'WWW-Authenticate': 'Basic realm="pyre Web Dashboard"',
|
|
534
|
+
'Content-Type': 'text/plain',
|
|
535
|
+
});
|
|
536
|
+
res.end('401 Unauthorized');
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
485
540
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
} else if (req.url === '/api') {
|
|
541
|
+
const url = req.url || '/';
|
|
542
|
+
|
|
543
|
+
// 2. Health check endpoint
|
|
544
|
+
if (url === '/health' || url === '/api/health') {
|
|
491
545
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
546
|
+
res.end(JSON.stringify({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() }));
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// 3. Real-time Server-Sent Events (SSE) stream endpoint
|
|
551
|
+
if (url === '/api/stream') {
|
|
552
|
+
res.writeHead(200, {
|
|
553
|
+
'Content-Type': 'text/event-stream',
|
|
554
|
+
'Cache-Control': 'no-cache',
|
|
555
|
+
'Connection': 'keep-alive',
|
|
556
|
+
'Access-Control-Allow-Origin': '*',
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const sendStats = async () => {
|
|
560
|
+
try {
|
|
561
|
+
const data = await collectAll({ detailed: true });
|
|
562
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
563
|
+
} catch {
|
|
564
|
+
// ignore stream collect error
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
sendStats();
|
|
569
|
+
const intervalId = setInterval(sendStats, 2000);
|
|
570
|
+
|
|
571
|
+
req.on('close', () => {
|
|
572
|
+
clearInterval(intervalId);
|
|
573
|
+
});
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// 4. Static HTML dashboard & API snapshot endpoints
|
|
578
|
+
if (url === '/' || url === '/index.html') {
|
|
579
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
580
|
+
const indexPath = path.join(process.cwd(), 'index.html');
|
|
581
|
+
let htmlContent = '';
|
|
582
|
+
if (fs.existsSync(indexPath)) {
|
|
583
|
+
htmlContent = fs.readFileSync(indexPath, 'utf-8');
|
|
584
|
+
} else {
|
|
585
|
+
const data = await collectAll({ detailed: true });
|
|
586
|
+
htmlContent = formatHtml(data);
|
|
587
|
+
}
|
|
588
|
+
res.end(htmlContent);
|
|
589
|
+
} else if (url === '/api' || url === '/api/stats' || url === '/data') {
|
|
590
|
+
const data = await collectAll({ detailed: true });
|
|
591
|
+
res.writeHead(200, {
|
|
592
|
+
'Content-Type': 'application/json',
|
|
593
|
+
'Access-Control-Allow-Origin': '*',
|
|
594
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
595
|
+
});
|
|
492
596
|
res.end(JSON.stringify(data, null, 2));
|
|
493
597
|
} else {
|
|
494
598
|
res.writeHead(404);
|
|
@@ -496,19 +600,20 @@ async function runWebCommand(): Promise<void> {
|
|
|
496
600
|
}
|
|
497
601
|
});
|
|
498
602
|
|
|
499
|
-
server.listen(
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
console.log(chalk.
|
|
504
|
-
|
|
505
|
-
|
|
603
|
+
server.listen(port, '0.0.0.0', () => {
|
|
604
|
+
console.log(chalk.bold(`\n pyre web server running on port ${port}`));
|
|
605
|
+
console.log(chalk.dim(` Dashboard: http://0.0.0.0:${port}/`));
|
|
606
|
+
console.log(chalk.dim(` API: http://0.0.0.0:${port}/api`));
|
|
607
|
+
console.log(chalk.dim(` SSE Stream: http://0.0.0.0:${port}/api/stream`));
|
|
608
|
+
if (authEnabled) {
|
|
609
|
+
console.log(chalk.yellow(` Authentication: Enabled (${authUser}:${authPass})`));
|
|
610
|
+
}
|
|
506
611
|
console.log(chalk.dim(' Press Ctrl+C to stop\n'));
|
|
612
|
+
});
|
|
507
613
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
});
|
|
614
|
+
process.once('SIGINT', () => {
|
|
615
|
+
server.close();
|
|
616
|
+
process.exit(0);
|
|
512
617
|
});
|
|
513
618
|
}
|
|
514
619
|
|
package/src/live/input.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { THEMES, type ThemeName, type VisibleItems, getTabHitboxes, TAB_BAR_ROW
|
|
|
13
13
|
import { state, setStatus, getToggleKey, type SplashColorScheme, type SplashAnimation } from './state.js';
|
|
14
14
|
import { exportSnapshot, startLogging, stopLogging, toggleLogging, writeLogRow } from './export.js';
|
|
15
15
|
import { render, footerLine, checkAlerts, invalidateTableCache, invalidateFrame } from './render.js';
|
|
16
|
-
import type { LiveOptions, ExportFormat, InputMode, SortMode, GraphMode } from './types.js';
|
|
16
|
+
import type { LiveOptions, ExportFormat, InputMode, SortMode, GraphMode, ActivePanel } from './types.js';
|
|
17
17
|
import { startP2PServer } from '../p2p/index.js';
|
|
18
18
|
import { writeConfig } from '../state/config.js';
|
|
19
19
|
|
|
@@ -189,12 +189,20 @@ function handleInputModeKey(str: string, key: readline.Key) {
|
|
|
189
189
|
|
|
190
190
|
if (key.name === 'backspace') {
|
|
191
191
|
state.inputBuffer = state.inputBuffer.slice(0, -1);
|
|
192
|
+
if (state.inputMode === 'filter') {
|
|
193
|
+
state.processFilter = state.inputBuffer;
|
|
194
|
+
invalidateTableCache();
|
|
195
|
+
}
|
|
192
196
|
render();
|
|
193
197
|
return;
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
if (str && str.length === 1 && !key.ctrl && !key.meta) {
|
|
197
201
|
state.inputBuffer += str;
|
|
202
|
+
if (state.inputMode === 'filter') {
|
|
203
|
+
state.processFilter = state.inputBuffer;
|
|
204
|
+
invalidateTableCache();
|
|
205
|
+
}
|
|
198
206
|
render();
|
|
199
207
|
}
|
|
200
208
|
}
|
|
@@ -404,8 +412,9 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
|
|
|
404
412
|
return;
|
|
405
413
|
}
|
|
406
414
|
|
|
407
|
-
|
|
408
|
-
|
|
415
|
+
const mouse = (key as { mouse?: { x: number; y: number } }).mouse;
|
|
416
|
+
if (mouse) {
|
|
417
|
+
handleMouseClick(mouse.y, mouse.x);
|
|
409
418
|
return;
|
|
410
419
|
}
|
|
411
420
|
|
|
@@ -437,7 +446,7 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
|
|
|
437
446
|
return;
|
|
438
447
|
case '0': case '1': case '2': case '3': case '4':
|
|
439
448
|
case '5': case '6': case '7': case '8': case '9': {
|
|
440
|
-
const tabMap: Record<string,
|
|
449
|
+
const tabMap: Record<string, ActivePanel> = {
|
|
441
450
|
'0': 'disk', '1': 'cpu', '2': 'mem', '3': 'gpu', '4': 'power',
|
|
442
451
|
'5': 'battery', '6': 'thermal', '7': 'network', '8': 'packets', '9': 'tasks'
|
|
443
452
|
};
|
|
@@ -558,7 +567,7 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
|
|
|
558
567
|
if (str && str.length === 1) {
|
|
559
568
|
const tabId = tabKeyToId(str);
|
|
560
569
|
if (tabId) {
|
|
561
|
-
state.activePanel = tabId;
|
|
570
|
+
state.activePanel = tabId as ActivePanel;
|
|
562
571
|
setStatus(`Panel: ${tabId.toUpperCase()}`);
|
|
563
572
|
render();
|
|
564
573
|
}
|
package/src/live/render.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { execFile } from 'node:child_process';
|
|
10
10
|
import chalk from 'chalk';
|
|
11
|
-
import { formatTable, formatGraphs, gridColumns, THEMES, type ThemeName, panel } from '../formatters/index.js';
|
|
11
|
+
import { formatTable, formatGraphs, gridColumns, THEMES, type ThemeName, panel, fitVisible } from '../formatters/index.js';
|
|
12
12
|
import { detectAnomalies } from '../anomalies.js';
|
|
13
13
|
import { state, setStatus, SIGNAL_OPTIONS, getToggleKey } from './state.js';
|
|
14
14
|
import type { StatsData } from '../monitors/index.js';
|
|
@@ -52,6 +52,7 @@ function tableCacheParams(): string {
|
|
|
52
52
|
state.currentTheme,
|
|
53
53
|
state.activePanel,
|
|
54
54
|
state.treeView ? '1' : '0',
|
|
55
|
+
state.history.version,
|
|
55
56
|
JSON.stringify(state.visiblePanels),
|
|
56
57
|
].join('|');
|
|
57
58
|
}
|
|
@@ -64,29 +65,21 @@ function graphsCacheParams(): string {
|
|
|
64
65
|
].join('|');
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
*/
|
|
73
|
-
function processRowBudget(): number {
|
|
74
|
-
let overlayRows = 0;
|
|
75
|
-
if (state.inputMode === 'customizer') overlayRows = state.CUSTOMIZER_OPTIONS.length + 3;
|
|
76
|
-
else if (state.inputMode === 'signal') overlayRows = 6;
|
|
77
|
-
else if (state.inputMode === 'filter' || state.inputMode === 'kill' || state.inputMode === 'p2p') overlayRows = 3;
|
|
78
|
-
|
|
79
|
-
if (state.activePanel === 'process') {
|
|
80
|
-
return Math.max(10, state.termHeight - 12 - overlayRows);
|
|
81
|
-
}
|
|
68
|
+
function processRowBudget(): number {
|
|
69
|
+
let overlayRows = 0;
|
|
70
|
+
if (state.inputMode === 'customizer') overlayRows = state.CUSTOMIZER_OPTIONS.length + 3;
|
|
71
|
+
else if (state.inputMode === 'signal') overlayRows = 6;
|
|
72
|
+
else if (state.inputMode === 'filter' || state.inputMode === 'kill' || state.inputMode === 'p2p') overlayRows = 3;
|
|
82
73
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const cardRows = Math.ceil(visibleCards / columns);
|
|
86
|
-
const reserved = 4 + cardRows * 8 + 8 + (state.showGraphs ? 11 : 0) + 5 + overlayRows;
|
|
87
|
-
return Math.max(5, state.termHeight - reserved);
|
|
74
|
+
if (state.activePanel === 'process') {
|
|
75
|
+
return Math.max(10, state.termHeight - 12 - overlayRows);
|
|
88
76
|
}
|
|
89
77
|
|
|
78
|
+
const reserved = 16 + overlayRows;
|
|
79
|
+
return Math.max(4, state.termHeight - reserved);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
90
83
|
function p2pPanelLines(): { title: string; body: string[] } {
|
|
91
84
|
const events = state.p2pServer ? state.p2pServer.peerEventHistory : state.p2pEvents;
|
|
92
85
|
const body: string[] = [];
|
|
@@ -114,42 +107,45 @@ function p2pPanelLines(): { title: string; body: string[] } {
|
|
|
114
107
|
return { title, body };
|
|
115
108
|
}
|
|
116
109
|
|
|
117
|
-
function renderCustomizerOverlay(): string {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
110
|
+
function renderCustomizerOverlay(scrollOffset: number, maxVisibleItems: number): string {
|
|
111
|
+
const themesList = Object.keys(THEMES) as ThemeName[];
|
|
112
|
+
const headerLine = chalk.bgCyan.black.bold(' UI CUSTOMIZER (Use ↑/↓ or J/K to navigate, Enter/Space to toggle, Esc to exit) ');
|
|
113
|
+
const totalItems = state.CUSTOMIZER_OPTIONS.length;
|
|
114
|
+
const end = Math.min(scrollOffset + maxVisibleItems, totalItems);
|
|
115
|
+
|
|
116
|
+
const lines = [headerLine];
|
|
117
|
+
|
|
118
|
+
for (let i = scrollOffset; i < end; i++) {
|
|
119
|
+
const opt = state.CUSTOMIZER_OPTIONS[i];
|
|
120
|
+
const isSelected = i === state.customizerIndex;
|
|
121
|
+
const prefix = isSelected ? chalk.yellow('▶ ') : ' ';
|
|
122
|
+
|
|
123
|
+
if (opt === 'Theme') {
|
|
124
|
+
lines.push(`${prefix}${opt}: ${chalk.bold.green(state.currentTheme)} [${themesList.join(', ')}]`);
|
|
125
|
+
} else if (opt === 'Graph Mode') {
|
|
126
|
+
lines.push(`${prefix}${opt}: ${chalk.bold.green(state.graphMode)} [spark, bar]`);
|
|
127
|
+
} else if (opt === 'Splash Screen') {
|
|
128
|
+
const status = state.splashEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
|
|
129
|
+
lines.push(`${prefix}${opt}: ${status}`);
|
|
130
|
+
} else if (opt === 'Splash Color') {
|
|
131
|
+
lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashColorScheme)} [fire, ocean, forest, purple, monochrome]`);
|
|
132
|
+
} else if (opt === 'Splash Animation') {
|
|
133
|
+
lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashAnimation)} [classic, wave, sparks]`);
|
|
134
|
+
} else if (opt === 'Notifications') {
|
|
135
|
+
const status = state.notificationsEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
|
|
136
|
+
lines.push(`${prefix}${opt}: ${status}`);
|
|
137
|
+
} else if (opt === 'Temperature Unit') {
|
|
138
|
+
lines.push(`${prefix}${opt}: ${chalk.bold.green(state.tempUnit.toUpperCase())} [C, F]`);
|
|
139
|
+
} else {
|
|
140
|
+
const toggleKey = getToggleKey(opt);
|
|
141
|
+
const isVisible = toggleKey ? (toggleKey === 'tree' ? state.treeView : state.visiblePanels[toggleKey] !== false) : true;
|
|
142
|
+
const status = isVisible ? chalk.green('[VISIBLE]') : chalk.red('[HIDDEN]');
|
|
143
|
+
lines.push(`${prefix}${opt}: ${status}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
150
146
|
|
|
151
147
|
return lines.map(l => ` ${l}`).join('\n');
|
|
152
|
-
|
|
148
|
+
}
|
|
153
149
|
|
|
154
150
|
// --- frame diffing -------------------------------------------------------
|
|
155
151
|
// Instead of erasing the whole screen (`\x1b[2J`) and repainting everything
|
|
@@ -189,6 +185,18 @@ function writeFrame(lines: string[]) {
|
|
|
189
185
|
|
|
190
186
|
function render() {
|
|
191
187
|
if (!state.lastData) return;
|
|
188
|
+
const rows = state.termHeight || 24;
|
|
189
|
+
const cols = state.termWidth || 80;
|
|
190
|
+
if (cols < 50 || rows < 15) {
|
|
191
|
+
const msg = chalk.yellow(`pyre ${rows}x${cols} too small. Resize terminal to >= 50x20.`);
|
|
192
|
+
const centered = Math.max(0, Math.floor((rows - 2) / 2));
|
|
193
|
+
const out: string[] = [];
|
|
194
|
+
for (let i = 0; i < centered; i++) out.push('');
|
|
195
|
+
out.push(msg.padEnd(cols));
|
|
196
|
+
while (out.length < rows) out.push('');
|
|
197
|
+
writeFrame(out);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
192
200
|
const lines: string[] = [];
|
|
193
201
|
const theme = THEMES[state.currentTheme] || THEMES.default;
|
|
194
202
|
|
|
@@ -209,6 +217,8 @@ function render() {
|
|
|
209
217
|
activePanel: state.activePanel,
|
|
210
218
|
anomalies,
|
|
211
219
|
tempUnit: state.tempUnit,
|
|
220
|
+
history: state.history,
|
|
221
|
+
graphMode: state.graphMode,
|
|
212
222
|
});
|
|
213
223
|
_cachedTableData = state.lastData;
|
|
214
224
|
_cachedTableParams = cacheParams;
|
|
@@ -243,43 +253,56 @@ function render() {
|
|
|
243
253
|
bodyLines.push(...graphsStr.split('\n'));
|
|
244
254
|
}
|
|
245
255
|
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
256
|
+
const footerLine1 = footerLine()[0] || '';
|
|
257
|
+
const footerLine2 = footerLine()[1] || '';
|
|
258
|
+
const footerLine3 = footerLine()[2] || ' ';
|
|
259
|
+
const footerLines = [footerLine1, footerLine2, footerLine3];
|
|
249
260
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
} else if (state.inputMode === 'filter') {
|
|
253
|
-
footerLines.push(chalk.cyan(` Filter processes: ${state.inputBuffer}_`));
|
|
254
|
-
} else if (state.inputMode === 'kill') {
|
|
255
|
-
footerLines.push(chalk.cyan(` Kill PID: ${state.inputBuffer}_ (enter to confirm, esc to cancel)`));
|
|
256
|
-
} else if (state.inputMode === 'signal') {
|
|
257
|
-
const sigIdx = state.SIGNAL_OPTIONS.indexOf(state.inputBuffer as typeof state.SIGNAL_OPTIONS[number]);
|
|
258
|
-
const sigList = state.SIGNAL_OPTIONS.map((s, i) => i === sigIdx ? chalk.yellow(s) : chalk.dim(s)).join(' ');
|
|
259
|
-
footerLines.push(chalk.cyan(` Signal: ${state.inputBuffer}_`));
|
|
260
|
-
footerLines.push(chalk.dim(` ${sigList}`));
|
|
261
|
-
footerLines.push(chalk.dim(' ↑/↓ to select, Enter to confirm, Esc to cancel'));
|
|
262
|
-
} else if (state.inputMode === 'p2p') {
|
|
263
|
-
footerLines.push(chalk.cyan(` P2P password (${state.p2pPort}): ${state.inputBuffer}_ (enter to start, esc to cancel)`));
|
|
264
|
-
} else if (state.statusMessage) {
|
|
265
|
-
footerLines.push(` ${state.statusMessage}`);
|
|
266
|
-
} else {
|
|
267
|
-
footerLines.push('');
|
|
268
|
-
}
|
|
261
|
+
const footerHeight = 3;
|
|
262
|
+
const targetBodyHeight = Math.max(1, state.termHeight - footerHeight);
|
|
269
263
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
264
|
+
if (state.inputMode === 'customizer') {
|
|
265
|
+
const preOverlayCount = bodyLines.length;
|
|
266
|
+
const availableForOverlay = targetBodyHeight - preOverlayCount;
|
|
267
|
+
const totalItems = state.CUSTOMIZER_OPTIONS.length;
|
|
268
|
+
const headerLines = 1;
|
|
269
|
+
const blankLines = availableForOverlay >= 2 ? 1 : 0;
|
|
270
|
+
const maxVisibleItems = Math.max(0, availableForOverlay - headerLines - blankLines);
|
|
271
|
+
const scrollOffset = totalItems > maxVisibleItems
|
|
272
|
+
? Math.max(0, Math.min(state.customizerIndex, totalItems - maxVisibleItems))
|
|
273
|
+
: 0;
|
|
274
|
+
|
|
275
|
+
if (blankLines > 0) bodyLines.push('');
|
|
276
|
+
bodyLines.push(...renderCustomizerOverlay(scrollOffset, maxVisibleItems).split('\n'));
|
|
277
|
+
} else if (state.inputMode === 'filter') {
|
|
278
|
+
bodyLines.push(chalk.cyan(` Filter processes: ${state.inputBuffer}_`));
|
|
279
|
+
} else if (state.inputMode === 'kill') {
|
|
280
|
+
bodyLines.push(chalk.cyan(` Kill PID: ${state.inputBuffer}_ (enter to confirm, esc to cancel)`));
|
|
281
|
+
} else if (state.inputMode === 'signal') {
|
|
282
|
+
const sigIdx = state.SIGNAL_OPTIONS.indexOf(state.inputBuffer as typeof state.SIGNAL_OPTIONS[number]);
|
|
283
|
+
const sigList = state.SIGNAL_OPTIONS.map((s, i) => i === sigIdx ? chalk.yellow(s) : chalk.dim(s)).join(' ');
|
|
284
|
+
bodyLines.push(chalk.cyan(` Signal: ${state.inputBuffer}_`));
|
|
285
|
+
bodyLines.push(chalk.dim(` ${sigList}`));
|
|
286
|
+
bodyLines.push(chalk.dim(' ↑/↓ to select, Enter to confirm, Esc to cancel'));
|
|
287
|
+
} else if (state.inputMode === 'p2p') {
|
|
288
|
+
bodyLines.push(chalk.cyan(` P2P password (${state.p2pPort}): ${state.inputBuffer}_ (enter to start, esc to cancel)`));
|
|
289
|
+
} else if (state.statusMessage) {
|
|
290
|
+
bodyLines.push(` ${state.statusMessage}`);
|
|
273
291
|
}
|
|
274
292
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
293
|
+
if (bodyLines.length > targetBodyHeight) {
|
|
294
|
+
bodyLines.length = targetBodyHeight;
|
|
295
|
+
}
|
|
296
|
+
const frameLines = [...bodyLines];
|
|
297
|
+
while (frameLines.length < targetBodyHeight) {
|
|
298
|
+
frameLines.push('');
|
|
278
299
|
}
|
|
279
|
-
|
|
300
|
+
frameLines.push(...footerLines);
|
|
301
|
+
const fittedLines = frameLines.map(l => fitVisible(l, cols));
|
|
302
|
+
writeFrame(fittedLines);
|
|
280
303
|
}
|
|
281
304
|
|
|
282
|
-
function footerLine(): string {
|
|
305
|
+
function footerLine(): string[] {
|
|
283
306
|
const row1: [string, string][] = [
|
|
284
307
|
['q', 'quit'],
|
|
285
308
|
['c', 'customize UI'],
|
|
@@ -312,10 +335,10 @@ function footerLine(): string {
|
|
|
312
335
|
if (state.logging) badges.push(chalk.red.bold('● REC'));
|
|
313
336
|
if (state.activePanel !== 'grid') badges.push(chalk.cyan.bold(`◉ ${state.activePanel.toUpperCase()}`));
|
|
314
337
|
if (state.p2pServerRunning) badges.push(chalk.green.bold(`P2P ${state.p2pBind || '0.0.0.0'}:${state.p2pPort}`));
|
|
315
|
-
const badgeStr = badges.join(' ');
|
|
338
|
+
const badgeStr = badges.join(' ') || ' ';
|
|
316
339
|
|
|
317
|
-
return
|
|
318
|
-
|
|
340
|
+
return [str1, str2, badgeStr];
|
|
341
|
+
}
|
|
319
342
|
|
|
320
343
|
function sendNotification(title: string, message: string) {
|
|
321
344
|
if (!state.notificationsEnabled) return;
|
|
@@ -332,20 +355,33 @@ function checkAlerts(data: StatsData) {
|
|
|
332
355
|
const anomalies = detectAnomalies(data, state.history);
|
|
333
356
|
const hasCriticalAnomaly = anomalies.some(a => a.severity === 'critical');
|
|
334
357
|
const hasWarningAnomaly = anomalies.some(a => a.severity === 'warning');
|
|
358
|
+
// Process watchdog check
|
|
359
|
+
let watchdogTriggered = false;
|
|
360
|
+
if (state.watchdogProcess) {
|
|
361
|
+
const matches = data.processes.filter(p => p.command.toLowerCase().includes(state.watchdogProcess.toLowerCase()));
|
|
362
|
+
for (const p of matches) {
|
|
363
|
+
if (p.cpu >= state.watchdogCpu || p.mem >= state.watchdogMem) {
|
|
364
|
+
watchdogTriggered = true;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
335
370
|
const anomalyTriggered = hasCriticalAnomaly || hasWarningAnomaly;
|
|
336
371
|
|
|
337
|
-
if ((hot || anomalyTriggered) && !state.alerted) {
|
|
372
|
+
if ((hot || anomalyTriggered || watchdogTriggered) && !state.alerted) {
|
|
338
373
|
state.alerted = true;
|
|
339
374
|
process.stdout.write('\x07');
|
|
340
375
|
const reasons: string[] = [];
|
|
341
376
|
if (data.cpu.usage >= state.CPU_ALERT_PCT) reasons.push(`CPU at ${data.cpu.usage}%`);
|
|
342
377
|
if (temp !== null && temp >= state.TEMP_ALERT_C) reasons.push(`Temp at ${formatTempForUnit(temp)}`);
|
|
378
|
+
if (watchdogTriggered) reasons.push(`Watchdog process "${state.watchdogProcess}" crossed threshold`);
|
|
343
379
|
for (const a of anomalies) {
|
|
344
380
|
reasons.push(`${a.metric} anomaly (σ=${a.zScore.toFixed(1)})`);
|
|
345
381
|
}
|
|
346
382
|
setStatus(chalk.red(`⚠ Alert: ${reasons.join('; ')}`), 5000);
|
|
347
383
|
sendNotification('pyre Alert', reasons.join('; '));
|
|
348
|
-
} else if (!hot && !anomalyTriggered) {
|
|
384
|
+
} else if (!hot && !anomalyTriggered && !watchdogTriggered) {
|
|
349
385
|
state.alerted = false;
|
|
350
386
|
}
|
|
351
387
|
}
|
package/src/live/state.ts
CHANGED
|
@@ -89,6 +89,9 @@ const state = {
|
|
|
89
89
|
],
|
|
90
90
|
CPU_ALERT_PCT: config.cpuAlertPct,
|
|
91
91
|
TEMP_ALERT_C: config.tempAlertC,
|
|
92
|
+
watchdogProcess: config.watchdogProcess || '',
|
|
93
|
+
watchdogCpu: config.watchdogCpu || 80,
|
|
94
|
+
watchdogMem: config.watchdogMem || 80,
|
|
92
95
|
notificationsEnabled: config.notificationsEnabled,
|
|
93
96
|
alerted: false,
|
|
94
97
|
activePanel: 'grid' as 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p',
|