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.
@@ -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 { readConfig } from './state/config.js';
21
- import { CONFIG_FILE } from './state/config.js';
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 === 'info') {
126
- await runInfoCommand();
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.end();
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 data = await collectAll({ detailed: true });
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
- 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') {
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(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`));
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
- process.once('SIGINT', () => {
509
- server.close();
510
- process.exit(0);
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
- if (key.mouse) {
408
- handleMouseClick(key.mouse);
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, 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
  }
@@ -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
- * How many process rows we can realistically fit given
69
- * current terminal height. Computed from the number of
70
- * card columns and the reserved space for the header,
71
- * cards, graphs, and footer.
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
- const columns = gridColumns(state.termWidth);
84
- const visibleCards = Object.values(state.visiblePanels).filter(v => v !== false).length;
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
- const themesList = Object.keys(THEMES) as ThemeName[];
119
- const lines = [
120
- chalk.bgCyan.black.bold(' UI CUSTOMIZER (Use ↑/↓ or J/K to navigate, Enter/Space to toggle, Esc to exit) '),
121
- ];
122
-
123
- state.CUSTOMIZER_OPTIONS.forEach((opt, idx) => {
124
- const isSelected = idx === state.customizerIndex;
125
- const prefix = isSelected ? chalk.yellow('▶ ') : ' ';
126
-
127
- if (opt === 'Theme') {
128
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.currentTheme)} [${themesList.join(', ')}]`);
129
- } else if (opt === 'Graph Mode') {
130
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.graphMode)} [spark, bar]`);
131
- } else if (opt === 'Splash Screen') {
132
- const status = state.splashEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
133
- lines.push(`${prefix}${opt}: ${status}`);
134
- } else if (opt === 'Splash Color') {
135
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashColorScheme)} [fire, ocean, forest, purple, monochrome]`);
136
- } else if (opt === 'Splash Animation') {
137
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashAnimation)} [classic, wave, sparks]`);
138
- } else if (opt === 'Notifications') {
139
- const status = state.notificationsEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
140
- lines.push(`${prefix}${opt}: ${status}`);
141
- } else if (opt === 'Temperature Unit') {
142
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.tempUnit.toUpperCase())} [C, F]`);
143
- } else {
144
- const toggleKey = getToggleKey(opt);
145
- const isVisible = toggleKey ? (toggleKey === 'tree' ? state.treeView : state.visiblePanels[toggleKey] !== false) : true;
146
- const status = isVisible ? chalk.green('[VISIBLE]') : chalk.red('[HIDDEN]');
147
- lines.push(`${prefix}${opt}: ${status}`);
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 footerLines: string[] = [];
247
- footerLines.push('');
248
- footerLines.push(...footerLine().split('\n'));
256
+ const footerLine1 = footerLine()[0] || '';
257
+ const footerLine2 = footerLine()[1] || '';
258
+ const footerLine3 = footerLine()[2] || ' ';
259
+ const footerLines = [footerLine1, footerLine2, footerLine3];
249
260
 
250
- if (state.inputMode === 'customizer') {
251
- footerLines.push(...renderCustomizerOverlay().split('\n'));
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
- const maxBodyLines = Math.max(1, state.termHeight - footerLines.length);
271
- if (bodyLines.length > maxBodyLines) {
272
- bodyLines.length = maxBodyLines;
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
- const frameLines = [...bodyLines, ...footerLines];
276
- if (frameLines.length > state.termHeight) {
277
- frameLines.length = state.termHeight;
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
- writeFrame(frameLines);
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 badgeStr ? `${str1}\n${str2} ${badgeStr}` : `${str1}\n${str2}`;
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',