pyre-cli 1.3.0 → 1.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/README.md +209 -16
- package/dist/index.js +735 -332
- package/package.json +5 -2
- package/parity.md +542 -0
- package/src/formatters/index.ts +17 -0
- package/src/formatters/output.ts +137 -0
- package/src/formatters/render.ts +468 -0
- package/src/formatters/themes.ts +115 -0
- package/src/formatters/types.ts +58 -0
- package/src/history.ts +115 -0
- package/src/index.ts +81 -0
- package/src/live/export.ts +73 -0
- package/src/live/index.ts +16 -0
- package/src/live/input.ts +311 -0
- package/src/live/render.ts +142 -0
- package/src/live/state.ts +83 -0
- package/src/live/types.ts +23 -0
- package/src/monitors/collectors.ts +484 -0
- package/src/monitors/index.ts +14 -0
- package/src/monitors/run.ts +18 -0
- package/src/monitors/smc.ts +107 -0
- package/src/monitors/types.ts +107 -0
- package/src/pyre_img +0 -0
- package/src/sparkline.ts +34 -0
- package/src/splash.ts +102 -0
- package/tsconfig.json +19 -0
package/src/history.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rolling window of recent metric samples, used to render
|
|
3
|
+
* sparkline graphs in the live dashboard.
|
|
4
|
+
*
|
|
5
|
+
* Network rates (bytes/sec) are derived from the cumulative
|
|
6
|
+
* counters returned by the monitors module by computing the
|
|
7
|
+
* delta between consecutive samples.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* const h = new History(60);
|
|
12
|
+
* h.push({ cpuUsage: 42, memUsage: 55, temp: 62, rxBytes: 1024, txBytes: 512 });
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export class History {
|
|
16
|
+
/** Maximum number of samples retained in the rolling window. */
|
|
17
|
+
maxLen: number;
|
|
18
|
+
|
|
19
|
+
/** Rolling window of CPU usage percentages (0–100). */
|
|
20
|
+
cpuUsage: number[] = [];
|
|
21
|
+
|
|
22
|
+
/** Rolling window of memory usage percentages (0–100). */
|
|
23
|
+
memUsage: number[] = [];
|
|
24
|
+
|
|
25
|
+
/** Rolling window of CPU die temperatures in °C. */
|
|
26
|
+
temp: number[] = [];
|
|
27
|
+
|
|
28
|
+
/** Rolling window of network receive rates in bytes/sec. */
|
|
29
|
+
netRxRate: number[] = [];
|
|
30
|
+
|
|
31
|
+
/** Rolling window of network transmit rates in bytes/sec. */
|
|
32
|
+
netTxRate: number[] = [];
|
|
33
|
+
|
|
34
|
+
/** Rolling window of GPU utilization percentages (0–100). */
|
|
35
|
+
gpuUtil: number[] = [];
|
|
36
|
+
|
|
37
|
+
private lastRxBytes = 0;
|
|
38
|
+
private lastTxBytes = 0;
|
|
39
|
+
private lastTs = 0;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a new History instance.
|
|
43
|
+
* @param maxLen - Maximum number of samples to retain. Defaults to 40.
|
|
44
|
+
*/
|
|
45
|
+
constructor(maxLen = 40) {
|
|
46
|
+
this.maxLen = maxLen;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Push a new sample into the rolling window.
|
|
51
|
+
*
|
|
52
|
+
* Network rates are computed as bytes/sec from the cumulative
|
|
53
|
+
* counters. The first sample always reports 0 for rates
|
|
54
|
+
* because there is no prior sample to compute a delta from.
|
|
55
|
+
*/
|
|
56
|
+
push(sample: {
|
|
57
|
+
cpuUsage: number;
|
|
58
|
+
memUsage: number;
|
|
59
|
+
temp: number | null;
|
|
60
|
+
rxBytes: number;
|
|
61
|
+
txBytes: number;
|
|
62
|
+
gpuUtil?: number;
|
|
63
|
+
}) {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
let rxRate = 0;
|
|
66
|
+
let txRate = 0;
|
|
67
|
+
|
|
68
|
+
if (this.lastTs > 0) {
|
|
69
|
+
const dtSeconds = Math.max((now - this.lastTs) / 1000, 0.001);
|
|
70
|
+
rxRate = Math.max(0, (sample.rxBytes - this.lastRxBytes) / dtSeconds);
|
|
71
|
+
txRate = Math.max(0, (sample.txBytes - this.lastTxBytes) / dtSeconds);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.lastRxBytes = sample.rxBytes;
|
|
75
|
+
this.lastTxBytes = sample.txBytes;
|
|
76
|
+
this.lastTs = now;
|
|
77
|
+
|
|
78
|
+
this.pushBounded(this.cpuUsage, sample.cpuUsage);
|
|
79
|
+
this.pushBounded(this.memUsage, sample.memUsage);
|
|
80
|
+
if (sample.temp !== null) this.pushBounded(this.temp, sample.temp);
|
|
81
|
+
this.pushBounded(this.netRxRate, rxRate);
|
|
82
|
+
this.pushBounded(this.netTxRate, txRate);
|
|
83
|
+
if (sample.gpuUtil !== undefined) this.pushBounded(this.gpuUtil, sample.gpuUtil);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Clear all rolling windows and reset cumulative byte counters. */
|
|
87
|
+
reset() {
|
|
88
|
+
this.cpuUsage = [];
|
|
89
|
+
this.memUsage = [];
|
|
90
|
+
this.temp = [];
|
|
91
|
+
this.netRxRate = [];
|
|
92
|
+
this.netTxRate = [];
|
|
93
|
+
this.gpuUtil = [];
|
|
94
|
+
this.lastRxBytes = 0;
|
|
95
|
+
this.lastTxBytes = 0;
|
|
96
|
+
this.lastTs = 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Grow or shrink the rolling window (e.g. on terminal resize),
|
|
101
|
+
* trimming from the oldest samples.
|
|
102
|
+
* @param n - New maximum length; clamped to at least 1.
|
|
103
|
+
*/
|
|
104
|
+
setMaxLen(n: number) {
|
|
105
|
+
this.maxLen = Math.max(1, n);
|
|
106
|
+
for (const arr of [this.cpuUsage, this.memUsage, this.temp, this.netRxRate, this.netTxRate, this.gpuUtil]) {
|
|
107
|
+
while (arr.length > this.maxLen) arr.shift();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private pushBounded(arr: number[], v: number) {
|
|
112
|
+
arr.push(v);
|
|
113
|
+
if (arr.length > this.maxLen) arr.shift();
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pyre - Mac system monitoring CLI
|
|
4
|
+
* Monitors temps, cpu, memory, disk, battery, and system stats.
|
|
5
|
+
* Live mode is a full interactive dashboard: graphs, pause/resume,
|
|
6
|
+
* on-demand snapshot export, and continuous CSV logging.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import { Command } from 'commander';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import { collectAll } from './monitors/index.js';
|
|
12
|
+
import { formatTable, formatJson, formatCsv, formatTsv } from './formatters/index.js';
|
|
13
|
+
import { startLive, stopLive } from './live/index.js';
|
|
14
|
+
import { showSplash } from './splash.js';
|
|
15
|
+
|
|
16
|
+
const program = new Command();
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.name('pyre')
|
|
20
|
+
.version('2.0.0')
|
|
21
|
+
.description('Mac system monitoring CLI: interactive live dashboard, stats, graphs, export')
|
|
22
|
+
.option('-j, --json', 'Output as JSON')
|
|
23
|
+
.option('-c, --csv', 'Output as CSV')
|
|
24
|
+
.option('-t, --tsv', 'Output as TSV')
|
|
25
|
+
.option('--detailed', 'Include detailed system info and sensor readings')
|
|
26
|
+
.option('--interval <seconds>', 'Refresh interval for live mode', '2')
|
|
27
|
+
.option('--once', 'Show a single static snapshot instead of live feed')
|
|
28
|
+
.option('--out <file>', 'Also write the snapshot output to a file (--once/--json/--csv/--tsv modes)')
|
|
29
|
+
.option('--export-dir <dir>', 'Directory used for live-mode snapshot exports and logs', './pyre-exports')
|
|
30
|
+
.option('--log', 'Start continuous CSV logging immediately when live mode starts')
|
|
31
|
+
.option('--tree', 'Show process tree view instead of flat list')
|
|
32
|
+
.option('--sort <key>', 'Sort processes by: cpu, mem, pid, user, command, state, threads, runtime', 'cpu');
|
|
33
|
+
|
|
34
|
+
program.parse(process.argv);
|
|
35
|
+
|
|
36
|
+
const opts = program.opts();
|
|
37
|
+
|
|
38
|
+
function isExportMode() {
|
|
39
|
+
return opts.json || opts.csv || opts.tsv;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stripAnsi(s: string): string {
|
|
43
|
+
// eslint-disable-next-line no-control-regex
|
|
44
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
const requestedLive = program.args[0] === 'live' || (!isExportMode() && !opts.once);
|
|
49
|
+
if (requestedLive) {
|
|
50
|
+
await showSplash();
|
|
51
|
+
const interval = parseFloat(opts.interval) || 2;
|
|
52
|
+
await startLive({
|
|
53
|
+
interval,
|
|
54
|
+
detailed: opts.detailed,
|
|
55
|
+
exportDir: opts.exportDir,
|
|
56
|
+
autoLog: opts.log,
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const data = await collectAll({ detailed: opts.detailed });
|
|
62
|
+
|
|
63
|
+
let output: string;
|
|
64
|
+
if (opts.json) output = formatJson(data);
|
|
65
|
+
else if (opts.csv) output = formatCsv(data);
|
|
66
|
+
else if (opts.tsv) output = formatTsv(data);
|
|
67
|
+
else output = formatTable(data, { width: process.stdout.columns || 80, sortBy: opts.sort, treeView: opts.tree });
|
|
68
|
+
|
|
69
|
+
console.log(output);
|
|
70
|
+
|
|
71
|
+
if (opts.out) {
|
|
72
|
+
fs.writeFileSync(opts.out, stripAnsi(output));
|
|
73
|
+
console.log(chalk.dim(`\nSaved to ${opts.out}`));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main().catch((err) => {
|
|
78
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
79
|
+
stopLive();
|
|
80
|
+
process.exit(1);
|
|
81
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live dashboard export and logging helpers.
|
|
3
|
+
*
|
|
4
|
+
* Handles snapshot export (JSON/CSV/TSV), continuous
|
|
5
|
+
* CSV logging, and the supporting file-system utilities.
|
|
6
|
+
* All state variables are imported from `state.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { formatJson, formatCsv, formatTsv } from '../formatters/index.js';
|
|
11
|
+
import { state, setStatus } from './state.js';
|
|
12
|
+
|
|
13
|
+
/** Ensure a directory exists, creating it recursively if needed. */
|
|
14
|
+
function ensureDir(dir: string) {
|
|
15
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Generate a filesystem-safe timestamp string for export filenames. */
|
|
19
|
+
function timestampForFile(): string {
|
|
20
|
+
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function exportSnapshot() {
|
|
24
|
+
if (!state.lastData) {
|
|
25
|
+
setStatus('No data yet — try again in a moment');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
ensureDir(state.exportDir);
|
|
30
|
+
const file = path.join(state.exportDir, `pyre-${timestampForFile()}.${state.exportFormat}`);
|
|
31
|
+
const content =
|
|
32
|
+
state.exportFormat === 'json' ? formatJson(state.lastData) : state.exportFormat === 'csv' ? formatCsv(state.lastData) : formatTsv(state.lastData);
|
|
33
|
+
fs.writeFileSync(file, content);
|
|
34
|
+
setStatus(`Exported snapshot -> ${file}`);
|
|
35
|
+
} catch (err: any) {
|
|
36
|
+
setStatus(`Export failed: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function startLogging() {
|
|
41
|
+
try {
|
|
42
|
+
ensureDir(state.exportDir);
|
|
43
|
+
const file = path.join(state.exportDir, `pyre-log-${timestampForFile()}.csv`);
|
|
44
|
+
state.logStream = fs.createWriteStream(file, { flags: 'a' });
|
|
45
|
+
state.logStream.write('timestamp,cpu_usage,mem_usage_percent,temp_c,net_rx_bytes,net_tx_bytes,thermal_state\n');
|
|
46
|
+
state.logging = true;
|
|
47
|
+
setStatus(`Logging every tick -> ${file}`);
|
|
48
|
+
} catch (err: any) {
|
|
49
|
+
setStatus(`Logging failed to start: ${err.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stopLogging() {
|
|
54
|
+
state.logging = false;
|
|
55
|
+
state.logStream?.end();
|
|
56
|
+
state.logStream = null;
|
|
57
|
+
setStatus('Logging stopped');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toggleLogging() {
|
|
61
|
+
if (state.logging) stopLogging();
|
|
62
|
+
else startLogging();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function writeLogRow(data: any) {
|
|
66
|
+
if (!state.logging || !state.logStream) return;
|
|
67
|
+
const temp = data.cpu.temperature ?? data.thermal.temperatures?.cpu_die ?? '';
|
|
68
|
+
state.logStream.write(
|
|
69
|
+
`${data.timestamp},${data.cpu.usage},${data.memory.usagePercent},${temp},${data.network.rxBytes},${data.network.txBytes},${data.thermal.state}\n`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { ensureDir, timestampForFile, exportSnapshot, startLogging, stopLogging, toggleLogging, writeLogRow };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pyre live dashboard module.
|
|
3
|
+
*
|
|
4
|
+
* Provides the interactive live monitoring mode with
|
|
5
|
+
* keyboard-driven customization, process filtering,
|
|
6
|
+
* snapshot export, and continuous CSV logging.
|
|
7
|
+
*
|
|
8
|
+
* The module is organised into five sub-files:
|
|
9
|
+
* - `types.ts` — type definitions
|
|
10
|
+
* - `state.ts` — shared mutable session state
|
|
11
|
+
* - `render.ts` — screen rendering and alert checking
|
|
12
|
+
* - `input.ts` — keypress handling and session lifecycle
|
|
13
|
+
* - `export.ts` — snapshot export and CSV logging
|
|
14
|
+
*/
|
|
15
|
+
export { startLive, stopLive } from './input.js';
|
|
16
|
+
export type { LiveOptions } from './types.js';
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live dashboard input handling and session lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Contains `startLive`, `stopLive`, the keypress handler,
|
|
5
|
+
* resize handling, and the ticker restart logic.
|
|
6
|
+
*
|
|
7
|
+
* Imported by `index.ts` as the public API of the live module.
|
|
8
|
+
*/
|
|
9
|
+
import readline from 'node:readline';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import { collectAll, StatsData } from '../monitors/index.js';
|
|
12
|
+
import { THEMES, type ThemeName, type VisibleItems } from '../formatters/index.js';
|
|
13
|
+
import { state, setStatus } from './state.js';
|
|
14
|
+
import { exportSnapshot, startLogging, stopLogging, toggleLogging, writeLogRow } from './export.js';
|
|
15
|
+
import { render, footerLine, checkAlerts, processRowBudget } from './render.js';
|
|
16
|
+
import type { LiveOptions, ExportFormat, InputMode, SortMode, GraphMode } from './types.js';
|
|
17
|
+
|
|
18
|
+
function handleInputModeKey(str: string, key: readline.Key) {
|
|
19
|
+
if (key.name === 'escape') {
|
|
20
|
+
state.inputMode = null;
|
|
21
|
+
state.inputBuffer = '';
|
|
22
|
+
render();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (state.inputMode === 'customizer') {
|
|
27
|
+
const themesList = Object.keys(THEMES) as ThemeName[];
|
|
28
|
+
|
|
29
|
+
if (key.name === 'up' || str === 'k') {
|
|
30
|
+
state.customizerIndex = (state.customizerIndex - 1 + state.CUSTOMIZER_OPTIONS.length) % state.CUSTOMIZER_OPTIONS.length;
|
|
31
|
+
} else if (key.name === 'down' || str === 'j') {
|
|
32
|
+
state.customizerIndex = (state.customizerIndex + 1) % state.CUSTOMIZER_OPTIONS.length;
|
|
33
|
+
} else if (key.name === 'return' || str === ' ') {
|
|
34
|
+
const selected = state.CUSTOMIZER_OPTIONS[state.customizerIndex];
|
|
35
|
+
if (selected === 'Theme') {
|
|
36
|
+
const nextIdx = (themesList.indexOf(state.currentTheme) + 1) % themesList.length;
|
|
37
|
+
state.currentTheme = themesList[nextIdx];
|
|
38
|
+
} else if (selected === 'Toggle Tree View') {
|
|
39
|
+
state.treeView = !state.treeView;
|
|
40
|
+
} else if (selected === 'Graph Mode') {
|
|
41
|
+
state.graphMode = state.graphMode === 'spark' ? 'bar' : 'spark';
|
|
42
|
+
} else {
|
|
43
|
+
|
|
44
|
+
const itemKey = selected.replace('Toggle ', '').toLowerCase() as keyof VisibleItems;
|
|
45
|
+
state.visiblePanels[itemKey] = !state.visiblePanels[itemKey];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
render();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (state.inputMode === 'signal') {
|
|
53
|
+
if (key.name === 'up' || str === 'k') {
|
|
54
|
+
const idx = state.SIGNAL_OPTIONS.indexOf(state.inputBuffer as typeof state.SIGNAL_OPTIONS[number]);
|
|
55
|
+
state.inputBuffer = state.SIGNAL_OPTIONS[(idx - 1 + state.SIGNAL_OPTIONS.length) % state.SIGNAL_OPTIONS.length];
|
|
56
|
+
} else if (key.name === 'down' || str === 'j') {
|
|
57
|
+
const idx = state.SIGNAL_OPTIONS.indexOf(state.inputBuffer as typeof state.SIGNAL_OPTIONS[number]);
|
|
58
|
+
state.inputBuffer = state.SIGNAL_OPTIONS[(idx + 1) % state.SIGNAL_OPTIONS.length];
|
|
59
|
+
} else if (key.name === 'return' || str === ' ') {
|
|
60
|
+
killProcess(state.inputBuffer.trim(), state.inputBuffer.trim() as NodeJS.Signals);
|
|
61
|
+
state.inputMode = null;
|
|
62
|
+
state.inputBuffer = '';
|
|
63
|
+
render();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
render();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (key.name === 'return') {
|
|
71
|
+
if (state.inputMode === 'filter') {
|
|
72
|
+
state.processFilter = state.inputBuffer.trim();
|
|
73
|
+
setStatus(state.processFilter ? `Filtering: "${state.processFilter}"` : 'Filter cleared');
|
|
74
|
+
} else if (state.inputMode === 'kill') {
|
|
75
|
+
killProcess(state.inputBuffer.trim());
|
|
76
|
+
}
|
|
77
|
+
state.inputMode = null;
|
|
78
|
+
state.inputBuffer = '';
|
|
79
|
+
render();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (key.name === 'backspace') {
|
|
84
|
+
state.inputBuffer = state.inputBuffer.slice(0, -1);
|
|
85
|
+
render();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (str && str.length === 1 && !key.ctrl && !key.meta) {
|
|
90
|
+
state.inputBuffer += str;
|
|
91
|
+
render();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function killProcess(pidStr: string, signal: string = 'SIGTERM') {
|
|
96
|
+
const pid = parseInt(pidStr, 10);
|
|
97
|
+
if (!pid || pid <= 0) {
|
|
98
|
+
setStatus(`Invalid PID: ${pidStr}`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
process.kill(pid, signal as NodeJS.Signals);
|
|
103
|
+
setStatus(`Sent ${signal} to PID ${pid}`);
|
|
104
|
+
} catch (err: any) {
|
|
105
|
+
setStatus(`Failed to send ${signal} to ${pid}: ${err.message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function onResize() {
|
|
110
|
+
state.termWidth = process.stdout.columns || 80;
|
|
111
|
+
state.termHeight = process.stdout.rows || 24;
|
|
112
|
+
state.history.setMaxLen(Math.max(20, Math.min(200, state.termWidth - 30)));
|
|
113
|
+
render();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function restartTicker() {
|
|
117
|
+
if (state.intervalHandle) clearInterval(state.intervalHandle);
|
|
118
|
+
state.intervalHandle = setInterval(tick, state.interval * 1000);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function tick() {
|
|
122
|
+
if (state.paused) return;
|
|
123
|
+
try {
|
|
124
|
+
const data = await collectAll({ detailed: state.detailed, processLimit: processRowBudget() });
|
|
125
|
+
state.lastData = data;
|
|
126
|
+
|
|
127
|
+
const temp = data.cpu.temperature ?? data.thermal.temperatures?.cpu_die ?? null;
|
|
128
|
+
state.history.push({
|
|
129
|
+
cpuUsage: data.cpu.usage,
|
|
130
|
+
memUsage: data.memory.usagePercent,
|
|
131
|
+
temp,
|
|
132
|
+
rxBytes: data.network.rxBytes,
|
|
133
|
+
txBytes: data.network.txBytes,
|
|
134
|
+
gpuUtil: data.gpu?.utilization,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
writeLogRow(data);
|
|
138
|
+
checkAlerts(data);
|
|
139
|
+
render();
|
|
140
|
+
} catch {
|
|
141
|
+
// skip bad tick
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Start the interactive live dashboard.
|
|
147
|
+
*
|
|
148
|
+
* Sets up raw TTY mode, keypress listeners, and the
|
|
149
|
+
* rendering interval. Idempotent — calling while already
|
|
150
|
+
* running is a no-op.
|
|
151
|
+
*/
|
|
152
|
+
export async function startLive(opts: LiveOptions) {
|
|
153
|
+
if (state.running) return;
|
|
154
|
+
state.running = true;
|
|
155
|
+
state.paused = false;
|
|
156
|
+
state.detailed = !!opts.detailed;
|
|
157
|
+
state.interval = opts.interval;
|
|
158
|
+
if (opts.exportDir) state.exportDir = opts.exportDir;
|
|
159
|
+
state.termWidth = process.stdout.columns || 80;
|
|
160
|
+
state.termHeight = process.stdout.rows || 24;
|
|
161
|
+
state.history.reset();
|
|
162
|
+
state.history.setMaxLen(Math.max(20, Math.min(200, state.termWidth - 30)));
|
|
163
|
+
|
|
164
|
+
process.stdout.write('\x1b[?1049h');
|
|
165
|
+
process.stdout.write('\x1b[?25l');
|
|
166
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
167
|
+
process.title = 'pyre';
|
|
168
|
+
|
|
169
|
+
await tick();
|
|
170
|
+
restartTicker();
|
|
171
|
+
|
|
172
|
+
if (opts.autoLog) startLogging();
|
|
173
|
+
|
|
174
|
+
readline.emitKeypressEvents(process.stdin);
|
|
175
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
176
|
+
process.stdin.resume();
|
|
177
|
+
|
|
178
|
+
process.stdout.on('resize', onResize);
|
|
179
|
+
|
|
180
|
+
state.keypressHandler = (str: string, key: readline.Key) => {
|
|
181
|
+
if (!key) return;
|
|
182
|
+
|
|
183
|
+
if (key.ctrl && key.name === 'c') {
|
|
184
|
+
stopLive();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (state.inputMode) {
|
|
189
|
+
handleInputModeKey(str, key);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
switch (key.sequence) {
|
|
194
|
+
case '+':
|
|
195
|
+
state.interval += 1;
|
|
196
|
+
restartTicker();
|
|
197
|
+
setStatus(`Interval set to ${state.interval}s`);
|
|
198
|
+
return;
|
|
199
|
+
case '-':
|
|
200
|
+
state.interval = Math.max(1, state.interval - 1);
|
|
201
|
+
restartTicker();
|
|
202
|
+
setStatus(`Interval set to ${state.interval}s`);
|
|
203
|
+
return;
|
|
204
|
+
case '/':
|
|
205
|
+
state.inputMode = 'filter';
|
|
206
|
+
state.inputBuffer = state.processFilter;
|
|
207
|
+
render();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
switch (key.name) {
|
|
212
|
+
case 'q':
|
|
213
|
+
stopLive();
|
|
214
|
+
break;
|
|
215
|
+
case 'c':
|
|
216
|
+
state.inputMode = 'customizer';
|
|
217
|
+
state.customizerIndex = 0;
|
|
218
|
+
render();
|
|
219
|
+
break;
|
|
220
|
+
case 'p':
|
|
221
|
+
state.paused = !state.paused;
|
|
222
|
+
setStatus(state.paused ? 'Paused' : 'Resumed');
|
|
223
|
+
render();
|
|
224
|
+
break;
|
|
225
|
+
case 'g':
|
|
226
|
+
state.showGraphs = !state.showGraphs;
|
|
227
|
+
render();
|
|
228
|
+
break;
|
|
229
|
+
case 'b':
|
|
230
|
+
state.graphMode = state.graphMode === 'spark' ? 'bar' : 'spark';
|
|
231
|
+
setStatus(`Graph mode: ${state.graphMode}`);
|
|
232
|
+
render();
|
|
233
|
+
break;
|
|
234
|
+
case 'd':
|
|
235
|
+
|
|
236
|
+
state.detailed = !state.detailed;
|
|
237
|
+
setStatus(`Detailed sensor mode: ${state.detailed ? 'on' : 'off'}`);
|
|
238
|
+
break;
|
|
239
|
+
case 's':
|
|
240
|
+
const sortCycle: SortMode[] = ['cpu', 'mem', 'pid', 'user', 'command', 'state', 'threads', 'runtime'];
|
|
241
|
+
const curIdx = sortCycle.indexOf(state.sortMode);
|
|
242
|
+
state.sortMode = sortCycle[(curIdx + 1) % sortCycle.length];
|
|
243
|
+
setStatus(`Sorting by ${state.sortMode}`);
|
|
244
|
+
render();
|
|
245
|
+
break;
|
|
246
|
+
case 'k':
|
|
247
|
+
state.inputMode = 'kill';
|
|
248
|
+
state.inputBuffer = '';
|
|
249
|
+
render();
|
|
250
|
+
break;
|
|
251
|
+
case 'S':
|
|
252
|
+
state.inputMode = 'signal';
|
|
253
|
+
state.inputBuffer = 'SIGTERM';
|
|
254
|
+
render();
|
|
255
|
+
break;
|
|
256
|
+
case 't':
|
|
257
|
+
state.treeView = !state.treeView;
|
|
258
|
+
setStatus(state.treeView ? 'Tree view enabled' : 'Flat view enabled');
|
|
259
|
+
render();
|
|
260
|
+
break;
|
|
261
|
+
case 'e':
|
|
262
|
+
exportSnapshot();
|
|
263
|
+
render();
|
|
264
|
+
break;
|
|
265
|
+
case 'l':
|
|
266
|
+
toggleLogging();
|
|
267
|
+
render();
|
|
268
|
+
break;
|
|
269
|
+
case 'f':
|
|
270
|
+
state.exportFormat = state.exportFormat === 'json' ? 'csv' : state.exportFormat === 'csv' ? 'tsv' : 'json';
|
|
271
|
+
setStatus(`Export format: ${state.exportFormat}`);
|
|
272
|
+
render();
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
process.stdin.on('keypress', state.keypressHandler);
|
|
278
|
+
process.once('SIGINT', () => stopLive());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function stopLive() {
|
|
282
|
+
if (state.intervalHandle) {
|
|
283
|
+
clearInterval(state.intervalHandle);
|
|
284
|
+
state.intervalHandle = null;
|
|
285
|
+
}
|
|
286
|
+
if (state.statusTimer) {
|
|
287
|
+
clearTimeout(state.statusTimer);
|
|
288
|
+
state.statusTimer = null;
|
|
289
|
+
}
|
|
290
|
+
if (state.logStream) {
|
|
291
|
+
state.logStream.end();
|
|
292
|
+
state.logStream = null;
|
|
293
|
+
}
|
|
294
|
+
if (state.keypressHandler) {
|
|
295
|
+
process.stdin.removeListener('keypress', state.keypressHandler);
|
|
296
|
+
state.keypressHandler = null;
|
|
297
|
+
}
|
|
298
|
+
process.stdout.removeListener('resize', onResize);
|
|
299
|
+
|
|
300
|
+
state.running = false;
|
|
301
|
+
state.logging = false;
|
|
302
|
+
|
|
303
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
304
|
+
process.stdin.pause();
|
|
305
|
+
|
|
306
|
+
process.stdout.write('\x1b[?25h');
|
|
307
|
+
process.stdout.write('\x1b[?1049l');
|
|
308
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
309
|
+
|
|
310
|
+
process.exit(0);
|
|
311
|
+
}
|