pyre-cli 6.0.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 +416 -157
- 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/render.ts +12 -3
- package/src/formatters/themes.ts +47 -12
- package/src/historyCmd.ts +71 -0
- package/src/index.ts +90 -5
- package/src/live/input.ts +8 -0
- package/src/live/render.ts +82 -55
- package/src/live/state.ts +3 -0
- package/src/monitors/collectors.ts +41 -2
- package/src/monitors/types.ts +2 -0
- package/src/state/config.ts +6 -1
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export function generateZshCompletions(): string {
|
|
2
|
+
return `#compdef pyre
|
|
3
|
+
|
|
4
|
+
_pyre() {
|
|
5
|
+
local -a commands
|
|
6
|
+
commands=(
|
|
7
|
+
'live:Start interactive live monitoring dashboard'
|
|
8
|
+
'info:Display static system overview'
|
|
9
|
+
'doctor:Run system permissions and diagnostic checks'
|
|
10
|
+
'web:Launch web dashboard server'
|
|
11
|
+
'config:Show or reset configuration options'
|
|
12
|
+
'p2p:P2P streaming server or client'
|
|
13
|
+
'ssh:Stream metrics over SSH'
|
|
14
|
+
'bench:Benchmark command while logging system stats'
|
|
15
|
+
'completions:Generate shell completion scripts'
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_arguments -s \\
|
|
19
|
+
'(-j --json)'{-j,--json}'[Output snapshot as JSON]' \\
|
|
20
|
+
'--html[Output snapshot as HTML]' \\
|
|
21
|
+
'--md[Output snapshot as Markdown]' \\
|
|
22
|
+
'(-c --csv)'{-c,--csv}'[Output snapshot as CSV]' \\
|
|
23
|
+
'(-t --tsv)'{-t,--tsv}'[Output snapshot as TSV]' \\
|
|
24
|
+
'--detailed[Include detailed system info and sensor readings]' \\
|
|
25
|
+
'--theme[Default theme]:theme:(default dracula cyberpunk monochrome nord gruvbox)' \\
|
|
26
|
+
'--interval[Refresh interval in seconds]:seconds:' \\
|
|
27
|
+
'--once[Show a single static snapshot]' \\
|
|
28
|
+
'--out[Write snapshot output to file]:file:_files' \\
|
|
29
|
+
'--export-dir[Directory for snapshot exports and logs]:directory:_files -/' \\
|
|
30
|
+
'--log[Start continuous CSV logging]' \\
|
|
31
|
+
'--tree[Show process tree view]' \\
|
|
32
|
+
'--sort[Sort processes by]:key:(cpu mem pid user command state threads runtime)' \\
|
|
33
|
+
'--packets[Include packet monitor panel]' \\
|
|
34
|
+
'--limit[Max processes to include]:count:' \\
|
|
35
|
+
'--alert-cpu[CPU alert threshold pct]:pct:' \\
|
|
36
|
+
'--alert-temp[CPU alert threshold temp C]:temp:' \\
|
|
37
|
+
'--temp-unit[Temperature display unit]:unit:(c f)' \\
|
|
38
|
+
'*::command:->command'
|
|
39
|
+
|
|
40
|
+
case $state in
|
|
41
|
+
command)
|
|
42
|
+
_describe -t commands 'pyre command' commands
|
|
43
|
+
;;
|
|
44
|
+
esac
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_pyre "$@"
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function generateBashCompletions(): string {
|
|
52
|
+
return `# bash completion for pyre
|
|
53
|
+
|
|
54
|
+
_pyre_completions() {
|
|
55
|
+
local cur prev opts commands
|
|
56
|
+
COMPREPLY=()
|
|
57
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
58
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
59
|
+
commands="live info doctor web config p2p ssh bench completions"
|
|
60
|
+
opts="--json --html --md --csv --tsv --detailed --theme --interval --once --out --export-dir --log --tree --sort --packets --limit --alert-cpu --alert-temp --temp-unit"
|
|
61
|
+
|
|
62
|
+
case "\${prev}" in
|
|
63
|
+
--theme)
|
|
64
|
+
COMPREPLY=( $(compgen -W "default dracula cyberpunk monochrome nord gruvbox" -- \${cur}) )
|
|
65
|
+
return 0
|
|
66
|
+
;;
|
|
67
|
+
--sort)
|
|
68
|
+
COMPREPLY=( $(compgen -W "cpu mem pid user command state threads runtime" -- \${cur}) )
|
|
69
|
+
return 0
|
|
70
|
+
;;
|
|
71
|
+
--temp-unit)
|
|
72
|
+
COMPREPLY=( $(compgen -W "c f" -- \${cur}) )
|
|
73
|
+
return 0
|
|
74
|
+
;;
|
|
75
|
+
config)
|
|
76
|
+
COMPREPLY=( $(compgen -W "show reset" -- \${cur}) )
|
|
77
|
+
return 0
|
|
78
|
+
;;
|
|
79
|
+
p2p)
|
|
80
|
+
COMPREPLY=( $(compgen -W "server connect" -- \${cur}) )
|
|
81
|
+
return 0
|
|
82
|
+
;;
|
|
83
|
+
esac
|
|
84
|
+
|
|
85
|
+
if [[ \${cur} == -* ]] ; then
|
|
86
|
+
COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) )
|
|
87
|
+
return 0
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
COMPREPLY=( $(compgen -W "\${commands}" -- \${cur}) )
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
complete -F _pyre_completions pyre
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function generateFishCompletions(): string {
|
|
98
|
+
return `# fish completion for pyre
|
|
99
|
+
|
|
100
|
+
complete -c pyre -n "__fish_use_subcommand" -a live -d "Start interactive live monitoring dashboard"
|
|
101
|
+
complete -c pyre -n "__fish_use_subcommand" -a info -d "Display static system overview"
|
|
102
|
+
complete -c pyre -n "__fish_use_subcommand" -a doctor -d "Run system diagnostics"
|
|
103
|
+
complete -c pyre -n "__fish_use_subcommand" -a web -d "Launch web dashboard server"
|
|
104
|
+
complete -c pyre -n "__fish_use_subcommand" -a config -d "Show or reset configuration options"
|
|
105
|
+
complete -c pyre -n "__fish_use_subcommand" -a p2p -d "P2P streaming mode"
|
|
106
|
+
complete -c pyre -n "__fish_use_subcommand" -a completions -d "Generate shell completions"
|
|
107
|
+
|
|
108
|
+
complete -c pyre -l json -s j -d "Output snapshot as JSON"
|
|
109
|
+
complete -c pyre -l html -d "Output snapshot as HTML"
|
|
110
|
+
complete -c pyre -l md -d "Output snapshot as Markdown"
|
|
111
|
+
complete -c pyre -l csv -s c -d "Output snapshot as CSV"
|
|
112
|
+
complete -c pyre -l tsv -s t -d "Output snapshot as TSV"
|
|
113
|
+
complete -c pyre -l detailed -d "Include detailed system info and sensor readings"
|
|
114
|
+
complete -c pyre -l theme -x -a "default dracula cyberpunk monochrome nord gruvbox" -d "Set color theme"
|
|
115
|
+
complete -c pyre -l sort -x -a "cpu mem pid user command state threads runtime" -d "Sort processes by key"
|
|
116
|
+
complete -c pyre -l temp-unit -x -a "c f" -d "Temperature unit"
|
|
117
|
+
`;
|
|
118
|
+
}
|
package/src/diffCmd.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import type { StatsData } from './monitors/types.js';
|
|
4
|
+
|
|
5
|
+
export function runDiffCommand(file1: string, file2: string): void {
|
|
6
|
+
console.log(chalk.bold(`\n pyre diff — Snapshot Comparison\n`));
|
|
7
|
+
console.log(chalk.dim(` Snapshot A: ${file1}`));
|
|
8
|
+
console.log(chalk.dim(` Snapshot B: ${file2}\n`));
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const dataA: StatsData = JSON.parse(fs.readFileSync(file1, 'utf-8'));
|
|
12
|
+
const dataB: StatsData = JSON.parse(fs.readFileSync(file2, 'utf-8'));
|
|
13
|
+
|
|
14
|
+
const diffNum = (label: string, valA: number, valB: number, unit = '') => {
|
|
15
|
+
const delta = valB - valA;
|
|
16
|
+
const sign = delta > 0 ? '+' : '';
|
|
17
|
+
const color = delta > 0 ? chalk.red : delta < 0 ? chalk.green : chalk.dim;
|
|
18
|
+
console.log(` ${label.padEnd(20)} ${valA.toFixed(1)}${unit} ➔ ${valB.toFixed(1)}${unit} (${color(sign + delta.toFixed(1) + unit)})`);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
diffNum('CPU Usage', dataA.cpu.usage, dataB.cpu.usage, '%');
|
|
22
|
+
diffNum('Memory Usage', dataA.memory.usagePercent, dataB.memory.usagePercent, '%');
|
|
23
|
+
if (dataA.cpu.temperature && dataB.cpu.temperature) {
|
|
24
|
+
diffNum('CPU Temp', dataA.cpu.temperature, dataB.cpu.temperature, '°C');
|
|
25
|
+
}
|
|
26
|
+
if (dataA.gpu && dataB.gpu) {
|
|
27
|
+
diffNum('GPU Util', dataA.gpu.utilization, dataB.gpu.utilization, '%');
|
|
28
|
+
}
|
|
29
|
+
diffNum('Network RX Packets', dataA.network.rxPackets, dataB.network.rxPackets);
|
|
30
|
+
diffNum('Network TX Packets', dataA.network.txPackets, dataB.network.txPackets);
|
|
31
|
+
|
|
32
|
+
console.log();
|
|
33
|
+
} catch (err: any) {
|
|
34
|
+
console.log(chalk.red(`Failed to compare snapshots: ${err.message}`));
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { run } from './monitors/run.js';
|
|
3
|
+
import { readConfig, CONFIG_FILE } from './state/config.js';
|
|
4
|
+
|
|
5
|
+
export interface DoctorCheck {
|
|
6
|
+
name: string;
|
|
7
|
+
status: 'ok' | 'warn' | 'error';
|
|
8
|
+
message: string;
|
|
9
|
+
details?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function runDoctor(): Promise<DoctorCheck[]> {
|
|
13
|
+
const checks: DoctorCheck[] = [];
|
|
14
|
+
|
|
15
|
+
// 1. Sudo / Powermetrics Access
|
|
16
|
+
try {
|
|
17
|
+
const pmResult = await run('sudo -n powermetrics --samplers cpu_power -n 1 2>&1', '');
|
|
18
|
+
if (pmResult.includes('Password:') || pmResult.includes('Permission denied') || pmResult.includes('sudo: a password is required')) {
|
|
19
|
+
checks.push({
|
|
20
|
+
name: 'Sudo / Powermetrics',
|
|
21
|
+
status: 'warn',
|
|
22
|
+
message: 'Passwordless sudo for powermetrics is not configured.',
|
|
23
|
+
details: 'Powermetrics allows high-precision GPU & CPU power sampling. Run with sudo or configure passwordless sudo in /etc/sudoers.',
|
|
24
|
+
});
|
|
25
|
+
} else {
|
|
26
|
+
checks.push({
|
|
27
|
+
name: 'Sudo / Powermetrics',
|
|
28
|
+
status: 'ok',
|
|
29
|
+
message: 'Powermetrics access available.',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
checks.push({
|
|
34
|
+
name: 'Sudo / Powermetrics',
|
|
35
|
+
status: 'warn',
|
|
36
|
+
message: 'Powermetrics check failed.',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2. TCC / System Permissions
|
|
41
|
+
try {
|
|
42
|
+
const psCheck = await run('ps -ax -o pid,user,command', '');
|
|
43
|
+
if (psCheck.length > 0) {
|
|
44
|
+
checks.push({
|
|
45
|
+
name: 'TCC / Process Visibility',
|
|
46
|
+
status: 'ok',
|
|
47
|
+
message: 'Process listing access available.',
|
|
48
|
+
});
|
|
49
|
+
} else {
|
|
50
|
+
checks.push({
|
|
51
|
+
name: 'TCC / Process Visibility',
|
|
52
|
+
status: 'warn',
|
|
53
|
+
message: 'Restricted process visibility.',
|
|
54
|
+
details: 'Grant Full Disk Access or Terminal permissions in System Settings -> Privacy & Security.',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
checks.push({
|
|
59
|
+
name: 'TCC / Process Visibility',
|
|
60
|
+
status: 'error',
|
|
61
|
+
message: 'Unable to list system processes.',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. Network Reachability for P2P
|
|
66
|
+
try {
|
|
67
|
+
const netCheck = await run('netstat -rn', '');
|
|
68
|
+
if (netCheck.includes('default') || netCheck.includes('gateway')) {
|
|
69
|
+
checks.push({
|
|
70
|
+
name: 'Network Reachability',
|
|
71
|
+
status: 'ok',
|
|
72
|
+
message: 'Network interface active with default gateway.',
|
|
73
|
+
});
|
|
74
|
+
} else {
|
|
75
|
+
checks.push({
|
|
76
|
+
name: 'Network Reachability',
|
|
77
|
+
status: 'warn',
|
|
78
|
+
message: 'No active network route detected for P2P mode.',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
checks.push({
|
|
83
|
+
name: 'Network Reachability',
|
|
84
|
+
status: 'warn',
|
|
85
|
+
message: 'Could not verify network routes.',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 4. Config file integrity
|
|
90
|
+
try {
|
|
91
|
+
const cfg = readConfig();
|
|
92
|
+
checks.push({
|
|
93
|
+
name: 'Configuration File',
|
|
94
|
+
status: 'ok',
|
|
95
|
+
message: `Config file valid (${CONFIG_FILE}).`,
|
|
96
|
+
});
|
|
97
|
+
} catch (err: any) {
|
|
98
|
+
checks.push({
|
|
99
|
+
name: 'Configuration File',
|
|
100
|
+
status: 'error',
|
|
101
|
+
message: 'Failed to parse config file.',
|
|
102
|
+
details: err.message,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return checks;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function printDoctorReport(checks: DoctorCheck[]): void {
|
|
110
|
+
console.log(chalk.bold('\n pyre doctor — System Diagnostics\n'));
|
|
111
|
+
|
|
112
|
+
for (const check of checks) {
|
|
113
|
+
let icon = chalk.green('✔');
|
|
114
|
+
if (check.status === 'warn') icon = chalk.yellow('⚠');
|
|
115
|
+
if (check.status === 'error') icon = chalk.red('✖');
|
|
116
|
+
|
|
117
|
+
console.log(` ${icon} ${chalk.bold(check.name.padEnd(26))} ${check.message}`);
|
|
118
|
+
if (check.details) {
|
|
119
|
+
console.log(chalk.dim(` ↳ ${check.details}`));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
console.log();
|
|
123
|
+
}
|
package/src/formatters/render.ts
CHANGED
|
@@ -714,7 +714,7 @@ function btopCpuBox(data: StatsData, width: number, theme: ThemeColors, opts: Ta
|
|
|
714
714
|
if (data.cpu.coreUsage && data.cpu.coreUsage.length) {
|
|
715
715
|
const coreColW = Math.floor((rightW - 1) / 2);
|
|
716
716
|
const half = Math.ceil(data.cpu.coreUsage.length / 2);
|
|
717
|
-
for (let i = 0; i <
|
|
717
|
+
for (let i = 0; i < half; i++) {
|
|
718
718
|
const u1 = data.cpu.coreUsage[i];
|
|
719
719
|
const u2 = data.cpu.coreUsage[i + half];
|
|
720
720
|
const c1BarW = Math.max(3, coreColW - 8);
|
|
@@ -763,9 +763,18 @@ function btopLeftColumn(data: StatsData, width: number, theme: ThemeColors, opts
|
|
|
763
763
|
for (const d of data.disk.slice(0, 2)) {
|
|
764
764
|
const capNum = parseInt(d.capacity, 10) || 0;
|
|
765
765
|
const mountShort = truncatePlain(d.mountpoint, 8);
|
|
766
|
-
|
|
766
|
+
let ioStr = '';
|
|
767
|
+
if (d.readBytesSec !== undefined || d.writeBytesSec !== undefined) {
|
|
768
|
+
const rStr = formatBytes(d.readBytesSec || 0);
|
|
769
|
+
const wStr = formatBytes(d.writeBytesSec || 0);
|
|
770
|
+
ioStr = ` R:${rStr}/s W:${wStr}/s`;
|
|
771
|
+
}
|
|
772
|
+
diskLines.push(`${mountShort} ${d.used}/${d.size} [${gaugeBar(capNum, Math.max(3, contentWidth - 18 - ioStr.length))}] ${d.capacity}${ioStr}`);
|
|
767
773
|
}
|
|
768
|
-
|
|
774
|
+
const totalRead = data.disk.reduce((acc, d) => acc + (d.readBytesSec || 0), 0);
|
|
775
|
+
const totalWrite = data.disk.reduce((acc, d) => acc + (d.writeBytesSec || 0), 0);
|
|
776
|
+
const diskHeaderRight = `R:${formatBytes(totalRead)}/s W:${formatBytes(totalWrite)}/s`;
|
|
777
|
+
out.push(...panel('0 disk io', diskLines, width, theme.disk, theme.border, diskLines.length, diskHeaderRight));
|
|
769
778
|
}
|
|
770
779
|
|
|
771
780
|
// --- Box 3: 7 net ---
|
package/src/formatters/themes.ts
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
export type ThemeName = string;
|
|
12
16
|
|
|
13
17
|
export interface ThemeColors {
|
|
14
18
|
border: (s: string) => string;
|
|
@@ -24,16 +28,7 @@ export interface ThemeColors {
|
|
|
24
28
|
process: (s: string) => string;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
* Built-in colour themes.
|
|
29
|
-
*
|
|
30
|
-
* - **default** — muted greys and cyan accents, safe for any terminal.
|
|
31
|
-
* - **dracula** — the popular Dracula dark palette.
|
|
32
|
-
* - **cyberpunk** — high-contrast neon on black.
|
|
33
|
-
* - **monochrome** — white-only, useful for colour-blind users or
|
|
34
|
-
* terminals without 256-colour support.
|
|
35
|
-
*/
|
|
36
|
-
export const THEMES: Record<ThemeName, ThemeColors> = {
|
|
31
|
+
export const BUILTIN_THEMES: Record<string, ThemeColors> = {
|
|
37
32
|
default: {
|
|
38
33
|
border: chalk.hex('#4b5563'),
|
|
39
34
|
cpu: chalk.hex('#22d3ee'),
|
|
@@ -112,4 +107,44 @@ export const THEMES: Record<ThemeName, ThemeColors> = {
|
|
|
112
107
|
graphs: chalk.hex('#fb4934'),
|
|
113
108
|
process: chalk.hex('#ebdbb2'),
|
|
114
109
|
},
|
|
115
|
-
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export function loadCustomThemes(): Record<string, ThemeColors> {
|
|
113
|
+
const themes = { ...BUILTIN_THEMES };
|
|
114
|
+
const themesDir = path.join(os.homedir(), '.config', 'pyre', 'themes');
|
|
115
|
+
try {
|
|
116
|
+
if (fs.existsSync(themesDir)) {
|
|
117
|
+
const files = fs.readdirSync(themesDir);
|
|
118
|
+
for (const file of files) {
|
|
119
|
+
if (file.endsWith('.json')) {
|
|
120
|
+
const name = path.basename(file, '.json');
|
|
121
|
+
try {
|
|
122
|
+
const raw = fs.readFileSync(path.join(themesDir, file), 'utf-8');
|
|
123
|
+
const data = JSON.parse(raw);
|
|
124
|
+
const buildColor = (val?: string) => (val ? (val.startsWith('#') ? chalk.hex(val) : (chalk as any)[val] || chalk.white) : chalk.white);
|
|
125
|
+
themes[name] = {
|
|
126
|
+
border: buildColor(data.border),
|
|
127
|
+
cpu: buildColor(data.cpu),
|
|
128
|
+
mem: buildColor(data.mem),
|
|
129
|
+
gpu: buildColor(data.gpu),
|
|
130
|
+
power: buildColor(data.power),
|
|
131
|
+
battery: buildColor(data.battery),
|
|
132
|
+
thermal: buildColor(data.thermal),
|
|
133
|
+
network: buildColor(data.network),
|
|
134
|
+
disk: buildColor(data.disk),
|
|
135
|
+
graphs: buildColor(data.graphs),
|
|
136
|
+
process: buildColor(data.process),
|
|
137
|
+
};
|
|
138
|
+
} catch {
|
|
139
|
+
// ignore bad theme file
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// ignore directory read error
|
|
146
|
+
}
|
|
147
|
+
return themes;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const THEMES: Record<string, ThemeColors> = loadCustomThemes();
|
|
@@ -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
|
|
|
@@ -62,7 +65,10 @@ program
|
|
|
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
67
|
.option('--p2p-hmac-key <key>', 'HMAC key for message signing (default: derived from password)')
|
|
65
|
-
.option('--port <port>', 'Port number for web server mode', '3000')
|
|
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');
|
|
66
72
|
|
|
67
73
|
program.parse(process.argv);
|
|
68
74
|
|
|
@@ -123,8 +129,21 @@ async function runServerCommand(): Promise<void> {
|
|
|
123
129
|
async function main() {
|
|
124
130
|
const cmd = program.args[0];
|
|
125
131
|
|
|
126
|
-
if (cmd === '
|
|
127
|
-
|
|
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);
|
|
128
147
|
return;
|
|
129
148
|
}
|
|
130
149
|
|
|
@@ -491,9 +510,71 @@ async function runSshCommand(host: string): Promise<void> {
|
|
|
491
510
|
async function runWebCommand(): Promise<void> {
|
|
492
511
|
const http = await import('node:http');
|
|
493
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';
|
|
494
516
|
|
|
495
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
|
+
}
|
|
540
|
+
|
|
496
541
|
const url = req.url || '/';
|
|
542
|
+
|
|
543
|
+
// 2. Health check endpoint
|
|
544
|
+
if (url === '/health' || url === '/api/health') {
|
|
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
|
|
497
578
|
if (url === '/' || url === '/index.html') {
|
|
498
579
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
499
580
|
const indexPath = path.join(process.cwd(), 'index.html');
|
|
@@ -523,6 +604,10 @@ async function runWebCommand(): Promise<void> {
|
|
|
523
604
|
console.log(chalk.bold(`\n pyre web server running on port ${port}`));
|
|
524
605
|
console.log(chalk.dim(` Dashboard: http://0.0.0.0:${port}/`));
|
|
525
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
|
+
}
|
|
526
611
|
console.log(chalk.dim(' Press Ctrl+C to stop\n'));
|
|
527
612
|
});
|
|
528
613
|
|
package/src/live/input.ts
CHANGED
|
@@ -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
|
}
|