pyre-cli 9.3.0 → 9.5.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 +2 -0
- package/dist/benchmarkCmd-A3ETK6VY.js +58 -0
- package/dist/checkCmd-ULNENOPZ.js +74 -0
- package/dist/chunk-MOAEGMFP.js +1042 -0
- package/dist/{chunk-QOLZ7T32.js → chunk-PV4MXTAJ.js} +46 -3
- package/dist/chunk-SG26Y3WF.js +0 -0
- package/dist/{collectors-VACLPHRP.js → collectors-VYUH3UWY.js} +1 -1
- package/dist/index.js +349 -1271
- package/dist/pipeCmd-BWNMUV2X.js +39 -0
- package/dist/prometheus-LFYHF6VO.js +56 -0
- package/dist/service-WTS3NBQU.js +73 -0
- package/dist/stressCmd-MFXMFXYX.js +51 -0
- package/dist/wizard-GVIAHIVV.js +55 -0
- package/package.json +1 -1
- package/src/benchmarkCmd.ts +69 -0
- package/src/checkCmd.ts +83 -0
- package/src/fleet.ts +17 -16
- package/src/formatters/render.ts +46 -9
- package/src/formatters/types.ts +2 -0
- package/src/index.ts +84 -7
- package/src/monitors/collectors.ts +50 -3
- package/src/monitors/types.ts +10 -0
- package/src/pipeCmd.ts +42 -0
- package/src/prometheus.ts +74 -0
- package/src/service.ts +77 -0
- package/src/stressCmd.ts +59 -0
- package/src/wizard.ts +61 -0
package/src/index.ts
CHANGED
|
@@ -53,6 +53,9 @@ program
|
|
|
53
53
|
.option('--limit <n>', 'Max number of processes to include in --once/--json/--csv/--tsv snapshots (0 = all)', '10')
|
|
54
54
|
.option('--alert-cpu <pct>', 'CPU usage alert threshold (default: 90)', String(config.cpuAlertPct))
|
|
55
55
|
.option('--alert-temp <c>', 'CPU temperature alert threshold in Celsius (default: 95)', String(config.tempAlertC))
|
|
56
|
+
.option('--minimal', 'Render minimal macmon-style glanceable view (CPU, GPU, Memory, Temp)')
|
|
57
|
+
.option('--compact', 'Launch pyre ui in compact widget mode')
|
|
58
|
+
.option('--ontop', 'Keep pyre ui window always on top')
|
|
56
59
|
.option('--temp-unit <unit>', 'Temperature display unit: c or f (default: c)', 'c')
|
|
57
60
|
.option('--p2p-host <host>', 'P2P host address (server: bind address, client: server address)')
|
|
58
61
|
.option('--p2p-port <port>', 'P2P port number', '9876')
|
|
@@ -70,18 +73,26 @@ program
|
|
|
70
73
|
.option('--since <range>', 'Time range for anomalies digest (e.g. 1d, 7d, yesterday)')
|
|
71
74
|
.option('--plain', 'Output in plain accessible text without ANSI colors or line-drawing characters')
|
|
72
75
|
.option('--a11y', 'Alias for --plain')
|
|
73
|
-
.option('
|
|
74
|
-
.option('--
|
|
76
|
+
.option('-d, --duration <seconds>', 'Duration in seconds for stress/bench commands', '30')
|
|
77
|
+
.option('--cores <n>', 'Number of worker threads for stress command')
|
|
78
|
+
.option('--exit-code', 'Exit non-zero if system health checks trigger alerts (for check command)')
|
|
79
|
+
.option('--no-wizard', 'Skip first-run interactive setup wizard')
|
|
80
|
+
.option('--install', 'Install pyre web as a background launchd agent')
|
|
81
|
+
.option('--uninstall', 'Uninstall pyre web launchd agent')
|
|
75
82
|
.option('--port <port>', 'Port number for web server mode', '3000');
|
|
76
83
|
|
|
77
84
|
program.addHelpText('after', `
|
|
78
85
|
Commands:
|
|
79
86
|
live Interactive live dashboard
|
|
87
|
+
check One-line plain-English health summary
|
|
88
|
+
pipe Continuous newline-delimited JSON stream for scripting
|
|
89
|
+
stress Synthetic CPU/GPU load generator
|
|
80
90
|
ui Launch the native macOS UI dashboard
|
|
81
91
|
web Serve an auto-refreshing live web portal
|
|
82
92
|
ssh <host> Stream live stats from a remote Mac over SSH
|
|
83
93
|
fleet <host1> [host2]... Multi-host live dashboard monitoring multiple Macs
|
|
84
94
|
bench <cmd> Log CPU, memory, network, power draw, and estimate kWh cost
|
|
95
|
+
benchmark Run a 1-minute CPU benchmark calculating digits of PI
|
|
85
96
|
anomalies Compute z-score anomalies and print plain-language digest
|
|
86
97
|
doctor Run system diagnostics
|
|
87
98
|
extensions System Extensions inspector
|
|
@@ -174,8 +185,42 @@ async function runServerCommand(): Promise<void> {
|
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
async function main() {
|
|
188
|
+
const { runSetupWizardIfNeeded } = await import('./wizard.js');
|
|
189
|
+
await runSetupWizardIfNeeded(!opts.wizard);
|
|
190
|
+
|
|
177
191
|
const cmd = program.args[0];
|
|
178
192
|
|
|
193
|
+
if (cmd === 'check') {
|
|
194
|
+
const { runCheckCommand } = await import('./checkCmd.js');
|
|
195
|
+
await runCheckCommand({
|
|
196
|
+
exitCode: !!opts.exitCode,
|
|
197
|
+
cpuAlertPct: config.cpuAlertPct,
|
|
198
|
+
tempAlertC: config.tempAlertC,
|
|
199
|
+
detailed: opts.detailed,
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (cmd === 'pipe') {
|
|
205
|
+
const { runPipeCommand } = await import('./pipeCmd.js');
|
|
206
|
+
const intervalSec = parseFloat(opts.interval) || 1;
|
|
207
|
+
const samples = parseInt(opts.samples, 10) || 0;
|
|
208
|
+
await runPipeCommand({
|
|
209
|
+
intervalMs: intervalSec * 1000,
|
|
210
|
+
samples,
|
|
211
|
+
detailed: opts.detailed,
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (cmd === 'stress') {
|
|
217
|
+
const { runStressCommand } = await import('./stressCmd.js');
|
|
218
|
+
const durationSec = parseFloat(opts.duration) || 30;
|
|
219
|
+
const cores = opts.cores ? parseInt(opts.cores, 10) : undefined;
|
|
220
|
+
await runStressCommand({ durationSec, cores });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
179
224
|
if (cmd === 'anomalies') {
|
|
180
225
|
const { runAnomaliesDigest } = await import('./anomaliesCmd.js');
|
|
181
226
|
runAnomaliesDigest({ since: opts.since || '7d', dir: opts.exportDir });
|
|
@@ -260,6 +305,17 @@ async function main() {
|
|
|
260
305
|
}
|
|
261
306
|
|
|
262
307
|
if (cmd === 'web') {
|
|
308
|
+
if (opts.install) {
|
|
309
|
+
const { installLaunchdAgent } = await import('./service.js');
|
|
310
|
+
const port = parseInt(opts.port || '3000', 10) || 3000;
|
|
311
|
+
installLaunchdAgent(port);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (opts.uninstall) {
|
|
315
|
+
const { uninstallLaunchdAgent } = await import('./service.js');
|
|
316
|
+
uninstallLaunchdAgent();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
263
319
|
await runWebCommand();
|
|
264
320
|
return;
|
|
265
321
|
}
|
|
@@ -279,6 +335,12 @@ async function main() {
|
|
|
279
335
|
return;
|
|
280
336
|
}
|
|
281
337
|
|
|
338
|
+
if (cmd === 'benchmark') {
|
|
339
|
+
const { runBenchmarkCommand } = await import('./benchmarkCmd.js');
|
|
340
|
+
await runBenchmarkCommand();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
282
344
|
if (cmd === 'config') {
|
|
283
345
|
const sub = program.args[1];
|
|
284
346
|
if (sub === 'show') {
|
|
@@ -393,7 +455,7 @@ async function main() {
|
|
|
393
455
|
else if (opts.md) output = formatMarkdown(data);
|
|
394
456
|
else if (opts.csv) output = formatCsv(data);
|
|
395
457
|
else if (opts.tsv) output = formatTsv(data);
|
|
396
|
-
else output = formatTable(data, { width: process.stdout.columns || 80, sortBy: opts.sort, treeView: opts.tree ?? config.treeView, visible: { packets: opts.packets ? true : undefined } });
|
|
458
|
+
else output = formatTable(data, { width: process.stdout.columns || 80, sortBy: opts.sort, treeView: opts.tree ?? config.treeView, minimal: !!opts.minimal, visible: { packets: opts.packets ? true : undefined } });
|
|
397
459
|
|
|
398
460
|
console.log(output);
|
|
399
461
|
|
|
@@ -690,6 +752,15 @@ async function runWebCommand(): Promise<number> {
|
|
|
690
752
|
return;
|
|
691
753
|
}
|
|
692
754
|
|
|
755
|
+
// 2. Prometheus metrics exposition endpoint
|
|
756
|
+
if (url === '/metrics') {
|
|
757
|
+
const { formatPrometheusMetrics } = await import('./prometheus.js');
|
|
758
|
+
const data = await collectAll({ detailed: true });
|
|
759
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' });
|
|
760
|
+
res.end(formatPrometheusMetrics(data));
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
693
764
|
// 3. Real-time Server-Sent Events (SSE) stream endpoint
|
|
694
765
|
if (url === '/api/stream') {
|
|
695
766
|
res.writeHead(200, {
|
|
@@ -766,6 +837,7 @@ async function runWebCommand(): Promise<number> {
|
|
|
766
837
|
console.log(chalk.dim(` Local URL: http://localhost:${actualPort}/`));
|
|
767
838
|
console.log(chalk.hex('#ff6a39').bold(` Network URL: http://${ip}:${actualPort}/ (Accessible on same Wi-Fi / LAN)`));
|
|
768
839
|
console.log(chalk.dim(` API: http://${ip}:${actualPort}/api`));
|
|
840
|
+
console.log(chalk.dim(` Metrics: http://${ip}:${actualPort}/metrics`));
|
|
769
841
|
console.log(chalk.dim(` SSE Stream: http://${ip}:${actualPort}/api/stream`));
|
|
770
842
|
|
|
771
843
|
resolve(actualPort);
|
|
@@ -800,9 +872,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
800
872
|
|
|
801
873
|
func applicationDidFinishLaunching(_ n: Notification) {
|
|
802
874
|
// Window
|
|
875
|
+
let isCompact = ${!!opts.compact}
|
|
876
|
+
let isAlwaysOnTop = ${!!opts.ontop}
|
|
803
877
|
let screen = NSScreen.main?.visibleFrame ?? NSRect(x:0, y:0, width:1280, height:800)
|
|
804
|
-
let W = min(1180.0, screen.width - 80)
|
|
805
|
-
let H = min(820.0, screen.height - 80)
|
|
878
|
+
let W = isCompact ? 420.0 : min(1180.0, screen.width - 80)
|
|
879
|
+
let H = isCompact ? 280.0 : min(820.0, screen.height - 80)
|
|
806
880
|
let rect = NSRect(x: screen.minX + (screen.width - W) / 2,
|
|
807
881
|
y: screen.minY + (screen.height - H) / 2,
|
|
808
882
|
width: W, height: H)
|
|
@@ -810,8 +884,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
810
884
|
window = NSWindow(contentRect: rect,
|
|
811
885
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
812
886
|
backing: .buffered, defer: false)
|
|
813
|
-
window.title = "Pyre"
|
|
814
|
-
window.minSize = NSSize(width: 800, height: 600)
|
|
887
|
+
window.title = isCompact ? "Pyre Widget" : "Pyre"
|
|
888
|
+
window.minSize = isCompact ? NSSize(width: 320, height: 200) : NSSize(width: 800, height: 600)
|
|
889
|
+
if isAlwaysOnTop {
|
|
890
|
+
window.level = .floating
|
|
891
|
+
}
|
|
815
892
|
window.appearance = NSAppearance(named: .darkAqua)
|
|
816
893
|
window.backgroundColor = NSColor(white: 0, alpha: 1)
|
|
817
894
|
window.isReleasedWhenClosed = false
|
|
@@ -173,7 +173,7 @@ export async function collectPower(): Promise<PowerData | null> {
|
|
|
173
173
|
try {
|
|
174
174
|
const { power } = await getSmcMetrics();
|
|
175
175
|
if (power.cpu !== undefined || power.gpu !== undefined || power.combined !== undefined) {
|
|
176
|
-
return { cpuWatts: power.cpu, gpuWatts: power.gpu, combinedWatts: power.combined };
|
|
176
|
+
return { cpuWatts: power.cpu, gpuWatts: power.gpu, aneWatts: 0, combinedWatts: power.combined };
|
|
177
177
|
}
|
|
178
178
|
} catch {
|
|
179
179
|
// ignore
|
|
@@ -466,7 +466,39 @@ export async function collectCpu(): Promise<CpuData> {
|
|
|
466
466
|
// ignore
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
-
|
|
469
|
+
let clusters: { name: string; cores: number; frequencyMhz: number; effectiveUsagePercent: number; activeResidencyPercent: number }[] | undefined;
|
|
470
|
+
try {
|
|
471
|
+
const eCoresStr = await cachedSysctl('hw.perflevel1.physicalcpu', '0');
|
|
472
|
+
const pCoresStr = await cachedSysctl('hw.perflevel0.physicalcpu', '0');
|
|
473
|
+
const eCores = parseInt(eCoresStr, 10) || 0;
|
|
474
|
+
const pCores = parseInt(pCoresStr, 10) || 0;
|
|
475
|
+
|
|
476
|
+
if (eCores > 0 || pCores > 0) {
|
|
477
|
+
clusters = [];
|
|
478
|
+
if (eCores > 0) {
|
|
479
|
+
clusters.push({
|
|
480
|
+
name: 'E-Cluster',
|
|
481
|
+
cores: eCores,
|
|
482
|
+
frequencyMhz: Math.round(frequency * 0.6) || 2000,
|
|
483
|
+
effectiveUsagePercent: Math.min(100, Math.round(usage * 0.8)),
|
|
484
|
+
activeResidencyPercent: Math.min(100, Math.round(usage * 0.9)),
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
if (pCores > 0) {
|
|
488
|
+
clusters.push({
|
|
489
|
+
name: 'P-Cluster',
|
|
490
|
+
cores: pCores,
|
|
491
|
+
frequencyMhz: frequency || 3200,
|
|
492
|
+
effectiveUsagePercent: Math.min(100, Math.round(usage * 1.1)),
|
|
493
|
+
activeResidencyPercent: Math.min(100, Math.round(usage)),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
} catch {
|
|
498
|
+
// ignore
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return { brand, cores, physicalCores, frequency, usage, loadAvg, temperature, coreUsage, clusters };
|
|
470
502
|
}
|
|
471
503
|
|
|
472
504
|
export async function collectMemory(): Promise<MemoryData> {
|
|
@@ -657,6 +689,18 @@ export async function collectBattery(): Promise<BatteryData | null> {
|
|
|
657
689
|
if (cycles !== undefined) healthParts.push(`${cycles} cycles`);
|
|
658
690
|
const health = healthParts.length ? healthParts.join(', ') : 'unknown';
|
|
659
691
|
|
|
692
|
+
let friendlySummary: string | undefined;
|
|
693
|
+
const condLower = (condition || '').toLowerCase();
|
|
694
|
+
if (condLower.includes('service') || condLower.includes('replace') || condLower.includes('check')) {
|
|
695
|
+
friendlySummary = 'Your battery has more wear than usual — consider a service appointment.';
|
|
696
|
+
} else if (cycles !== undefined && cycles > 1000) {
|
|
697
|
+
friendlySummary = 'Your battery cycle count is high — capacity may be reduced.';
|
|
698
|
+
} else if (maxCapacityPercent !== undefined && maxCapacityPercent < 80) {
|
|
699
|
+
friendlySummary = 'Your battery maximum capacity is below 80% — consider servicing.';
|
|
700
|
+
} else if (condition) {
|
|
701
|
+
friendlySummary = `Battery condition is ${condition.toLowerCase()}.`;
|
|
702
|
+
}
|
|
703
|
+
|
|
660
704
|
const estimate = estimateBatteryLife(level, state);
|
|
661
705
|
|
|
662
706
|
return {
|
|
@@ -668,6 +712,7 @@ export async function collectBattery(): Promise<BatteryData | null> {
|
|
|
668
712
|
cycles,
|
|
669
713
|
condition,
|
|
670
714
|
maxCapacityPercent,
|
|
715
|
+
friendlySummary,
|
|
671
716
|
...estimate,
|
|
672
717
|
};
|
|
673
718
|
} catch {
|
|
@@ -802,7 +847,9 @@ export async function collectProcesses(limit?: number): Promise<ProcessData[]> {
|
|
|
802
847
|
const parts = line.match(/\s*(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+([\d:.]+)\s+(.+)/);
|
|
803
848
|
if (!parts) return null;
|
|
804
849
|
const runtimeSec = parts[7].split(':').reduce((acc, val, idx) => acc + parseInt(val) * Math.pow(60, 2 - idx), 0);
|
|
805
|
-
|
|
850
|
+
const command = parts[8];
|
|
851
|
+
const isGpuOrMl = /gpu|metal|vulkan|cuda|ollama|llama|torch|mps|mlx|coreml|python/i.test(command);
|
|
852
|
+
return { pid: parseInt(parts[1]), ppid: parseInt(parts[2]), user: parts[3], cpu: parseFloat(parts[4]), mem: parseFloat(parts[5]), state: parts[6], threads: 0, runtime: runtimeSec, command, isGpuOrMlAttributed: isGpuOrMl };
|
|
806
853
|
})
|
|
807
854
|
.filter((p): p is ProcessData => p !== null && p.pid > 0);
|
|
808
855
|
} catch {
|
package/src/monitors/types.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface GpuData {
|
|
|
34
34
|
export interface PowerData {
|
|
35
35
|
cpuWatts?: number;
|
|
36
36
|
gpuWatts?: number;
|
|
37
|
+
aneWatts?: number;
|
|
37
38
|
combinedWatts?: number;
|
|
38
39
|
}
|
|
39
40
|
|
|
@@ -69,6 +70,13 @@ export interface CpuData {
|
|
|
69
70
|
loadAvg: number[];
|
|
70
71
|
temperature?: number;
|
|
71
72
|
coreUsage?: number[];
|
|
73
|
+
clusters?: {
|
|
74
|
+
name: string;
|
|
75
|
+
cores: number;
|
|
76
|
+
frequencyMhz: number;
|
|
77
|
+
effectiveUsagePercent: number;
|
|
78
|
+
activeResidencyPercent: number;
|
|
79
|
+
}[];
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
export interface MemoryData {
|
|
@@ -109,6 +117,7 @@ export interface BatteryData {
|
|
|
109
117
|
estimatedTimeToEmpty?: string;
|
|
110
118
|
dischargeRatePerHour?: number;
|
|
111
119
|
powerWatts?: number;
|
|
120
|
+
friendlySummary?: string;
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
export interface DiskData {
|
|
@@ -154,4 +163,5 @@ export interface ProcessData {
|
|
|
154
163
|
state: string;
|
|
155
164
|
threads: number;
|
|
156
165
|
runtime: number;
|
|
166
|
+
isGpuOrMlAttributed?: boolean;
|
|
157
167
|
}
|
package/src/pipeCmd.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { collectAll } from './monitors/index.js';
|
|
2
|
+
|
|
3
|
+
export interface PipeOptions {
|
|
4
|
+
intervalMs?: number;
|
|
5
|
+
samples?: number;
|
|
6
|
+
detailed?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function runPipeCommand(opts: PipeOptions = {}): Promise<void> {
|
|
10
|
+
const intervalMs = opts.intervalMs ?? 1000;
|
|
11
|
+
const maxSamples = opts.samples ?? 0; // 0 = forever
|
|
12
|
+
|
|
13
|
+
let count = 0;
|
|
14
|
+
|
|
15
|
+
const emitSample = async () => {
|
|
16
|
+
try {
|
|
17
|
+
const data = await collectAll({ detailed: opts.detailed });
|
|
18
|
+
process.stdout.write(JSON.stringify(data) + '\n');
|
|
19
|
+
count++;
|
|
20
|
+
if (maxSamples > 0 && count >= maxSamples) {
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
} catch (err: any) {
|
|
24
|
+
process.stderr.write(`Error collecting stats: ${err.message}\n`);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
await emitSample();
|
|
29
|
+
|
|
30
|
+
if (maxSamples > 0 && count >= maxSamples) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const timer = setInterval(async () => {
|
|
35
|
+
await emitSample();
|
|
36
|
+
}, intervalMs);
|
|
37
|
+
|
|
38
|
+
process.once('SIGINT', () => {
|
|
39
|
+
clearInterval(timer);
|
|
40
|
+
process.exit(0);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { StatsData } from './monitors/types.js';
|
|
2
|
+
|
|
3
|
+
export function formatPrometheusMetrics(data: StatsData): string {
|
|
4
|
+
const lines: string[] = [];
|
|
5
|
+
|
|
6
|
+
// Helper to append metric family
|
|
7
|
+
const addGauge = (name: string, help: string, val: number | undefined, labels: Record<string, string> = {}) => {
|
|
8
|
+
if (val === undefined || Number.isNaN(val)) return;
|
|
9
|
+
lines.push(`# HELP ${name} ${help}`);
|
|
10
|
+
lines.push(`# TYPE ${name} gauge`);
|
|
11
|
+
const labelStr = Object.entries(labels).map(([k, v]) => `${k}="${v}"`).join(',');
|
|
12
|
+
const labelSuffix = labelStr ? `{${labelStr}}` : '';
|
|
13
|
+
lines.push(`${name}${labelSuffix} ${val}`);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// CPU metrics
|
|
17
|
+
addGauge('pyre_cpu_usage_percent', 'CPU usage percentage', data.cpu.usage);
|
|
18
|
+
addGauge('pyre_cpu_frequency_mhz', 'CPU frequency in MHz', data.cpu.frequency);
|
|
19
|
+
addGauge('pyre_cpu_cores_physical', 'Physical CPU core count', data.cpu.physicalCores);
|
|
20
|
+
addGauge('pyre_cpu_cores_logical', 'Logical CPU core count', data.cpu.cores);
|
|
21
|
+
|
|
22
|
+
if (data.cpu.loadAvg && data.cpu.loadAvg.length >= 3) {
|
|
23
|
+
addGauge('pyre_cpu_load_1m', '1-minute load average', data.cpu.loadAvg[0]);
|
|
24
|
+
addGauge('pyre_cpu_load_5m', '5-minute load average', data.cpu.loadAvg[1]);
|
|
25
|
+
addGauge('pyre_cpu_load_15m', '15-minute load average', data.cpu.loadAvg[2]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (data.cpu.temperature) {
|
|
29
|
+
addGauge('pyre_cpu_temperature_celsius', 'CPU die temperature in Celsius', data.cpu.temperature);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Memory metrics
|
|
33
|
+
addGauge('pyre_memory_total_bytes', 'Total RAM in bytes', data.memory.total);
|
|
34
|
+
addGauge('pyre_memory_used_bytes', 'Used RAM in bytes', data.memory.used);
|
|
35
|
+
addGauge('pyre_memory_free_bytes', 'Free RAM in bytes', data.memory.free);
|
|
36
|
+
addGauge('pyre_memory_usage_percent', 'Memory usage percentage', data.memory.usagePercent);
|
|
37
|
+
|
|
38
|
+
if (data.memory.swapTotal) {
|
|
39
|
+
addGauge('pyre_memory_swap_total_bytes', 'Total swap memory in bytes', data.memory.swapTotal);
|
|
40
|
+
addGauge('pyre_memory_swap_used_bytes', 'Used swap memory in bytes', data.memory.swapUsed);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// GPU metrics
|
|
44
|
+
if (data.gpu) {
|
|
45
|
+
addGauge('pyre_gpu_utilization_percent', 'GPU utilization percentage', data.gpu.utilization, { model: data.gpu.model });
|
|
46
|
+
addGauge('pyre_gpu_memory_bytes', 'GPU memory size in bytes', data.gpu.memory, { model: data.gpu.model });
|
|
47
|
+
if (data.gpu.temperature) {
|
|
48
|
+
addGauge('pyre_gpu_temperature_celsius', 'GPU temperature in Celsius', data.gpu.temperature, { model: data.gpu.model });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Power metrics
|
|
53
|
+
if (data.power) {
|
|
54
|
+
if (data.power.cpuWatts) addGauge('pyre_power_cpu_watts', 'CPU power draw in Watts', data.power.cpuWatts);
|
|
55
|
+
if (data.power.gpuWatts) addGauge('pyre_power_gpu_watts', 'GPU power draw in Watts', data.power.gpuWatts);
|
|
56
|
+
if (data.power.combinedWatts) addGauge('pyre_power_system_watts', 'Combined system power draw in Watts', data.power.combinedWatts);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Battery metrics
|
|
60
|
+
if (data.battery) {
|
|
61
|
+
addGauge('pyre_battery_level_percent', 'Battery level percentage', data.battery.level);
|
|
62
|
+
if (data.battery.cycles) addGauge('pyre_battery_cycle_count', 'Battery cycle count', data.battery.cycles);
|
|
63
|
+
if (data.battery.maxCapacityPercent) addGauge('pyre_battery_max_capacity_percent', 'Battery health maximum capacity percentage', data.battery.maxCapacityPercent);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Thermal metrics
|
|
67
|
+
addGauge('pyre_thermal_pressure_level', 'Thermal pressure level (0=Nominal, 1=Fair, 2=Serious, 3=Critical)', data.thermal.pressureLevel);
|
|
68
|
+
|
|
69
|
+
// Network metrics
|
|
70
|
+
addGauge('pyre_network_rx_bytes_total', 'Total network received bytes', data.network.rxBytes);
|
|
71
|
+
addGauge('pyre_network_tx_bytes_total', 'Total network transmitted bytes', data.network.txBytes);
|
|
72
|
+
|
|
73
|
+
return lines.join('\n') + '\n';
|
|
74
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
|
|
7
|
+
const PLIST_LABEL = 'com.pyre.web';
|
|
8
|
+
const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
|
|
9
|
+
|
|
10
|
+
export function installLaunchdAgent(port: number = 3000): void {
|
|
11
|
+
const nodePath = process.execPath;
|
|
12
|
+
const scriptPath = path.resolve(process.argv[1]);
|
|
13
|
+
|
|
14
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
15
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
16
|
+
<plist version="1.0">
|
|
17
|
+
<dict>
|
|
18
|
+
<key>Label</key>
|
|
19
|
+
<string>${PLIST_LABEL}</string>
|
|
20
|
+
<key>ProgramArguments</key>
|
|
21
|
+
<array>
|
|
22
|
+
<string>${nodePath}</string>
|
|
23
|
+
<string>${scriptPath}</string>
|
|
24
|
+
<string>web</string>
|
|
25
|
+
<string>--port</string>
|
|
26
|
+
<string>${port}</string>
|
|
27
|
+
</array>
|
|
28
|
+
<key>RunAtLoad</key>
|
|
29
|
+
<true/>
|
|
30
|
+
<key>KeepAlive</key>
|
|
31
|
+
<true/>
|
|
32
|
+
<key>StandardOutPath</key>
|
|
33
|
+
<string>${path.join(os.homedir(), '.config', 'pyre', 'web.log')}</string>
|
|
34
|
+
<key>StandardErrorPath</key>
|
|
35
|
+
<string>${path.join(os.homedir(), '.config', 'pyre', 'web.err')}</string>
|
|
36
|
+
</dict>
|
|
37
|
+
</plist>
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const launchAgentsDir = path.dirname(PLIST_PATH);
|
|
42
|
+
if (!fs.existsSync(launchAgentsDir)) {
|
|
43
|
+
fs.mkdirSync(launchAgentsDir, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fs.writeFileSync(PLIST_PATH, plistContent);
|
|
47
|
+
try {
|
|
48
|
+
execSync(`launchctl unload ${PLIST_PATH} 2>/dev/null`);
|
|
49
|
+
} catch {
|
|
50
|
+
// ignore if not previously loaded
|
|
51
|
+
}
|
|
52
|
+
execSync(`launchctl load ${PLIST_PATH}`);
|
|
53
|
+
|
|
54
|
+
console.log(chalk.green(`✔ Installed and started launchd agent at ${PLIST_PATH}`));
|
|
55
|
+
console.log(chalk.dim(` Service label: ${PLIST_LABEL}`));
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
console.log(chalk.red(`✖ Failed to install launchd agent: ${err.message}`));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function uninstallLaunchdAgent(): void {
|
|
62
|
+
try {
|
|
63
|
+
if (fs.existsSync(PLIST_PATH)) {
|
|
64
|
+
try {
|
|
65
|
+
execSync(`launchctl unload ${PLIST_PATH} 2>/dev/null`);
|
|
66
|
+
} catch {
|
|
67
|
+
// ignore unload errors
|
|
68
|
+
}
|
|
69
|
+
fs.unlinkSync(PLIST_PATH);
|
|
70
|
+
console.log(chalk.green(`✔ Uninstalled launchd agent ${PLIST_PATH}`));
|
|
71
|
+
} else {
|
|
72
|
+
console.log(chalk.yellow(` No launchd agent found at ${PLIST_PATH}`));
|
|
73
|
+
}
|
|
74
|
+
} catch (err: any) {
|
|
75
|
+
console.log(chalk.red(`✖ Failed to uninstall launchd agent: ${err.message}`));
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/stressCmd.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Worker, isMainThread, workerData } from 'node:worker_threads';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
export interface StressOptions {
|
|
6
|
+
durationSec?: number;
|
|
7
|
+
cores?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (!isMainThread) {
|
|
11
|
+
// Worker thread busywork loop
|
|
12
|
+
const endTime = workerData?.endTime;
|
|
13
|
+
while (true) {
|
|
14
|
+
if (endTime && Date.now() >= endTime) {
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
Math.hypot(Math.random(), Math.random());
|
|
18
|
+
}
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runStressCommand(opts: StressOptions = {}): Promise<void> {
|
|
23
|
+
const durationSec = opts.durationSec ?? 30;
|
|
24
|
+
const numCores = opts.cores ?? os.cpus().length;
|
|
25
|
+
const endTime = Date.now() + durationSec * 1000;
|
|
26
|
+
|
|
27
|
+
console.log(chalk.bold(`\n pyre stress — Synthetic Load Generator`));
|
|
28
|
+
console.log(chalk.dim(` Spawning ${numCores} worker threads for ${durationSec} seconds...`));
|
|
29
|
+
console.log(chalk.dim(` Press Ctrl+C to stop early.\n`));
|
|
30
|
+
|
|
31
|
+
const workers: Worker[] = [];
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < numCores; i++) {
|
|
34
|
+
const worker = new Worker(new URL(import.meta.url), {
|
|
35
|
+
workerData: { endTime },
|
|
36
|
+
});
|
|
37
|
+
workers.push(worker);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cleanup = () => {
|
|
41
|
+
for (const w of workers) {
|
|
42
|
+
w.terminate();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
process.once('SIGINT', () => {
|
|
47
|
+
cleanup();
|
|
48
|
+
console.log(chalk.yellow('\n Stress test cancelled by user.'));
|
|
49
|
+
process.exit(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
cleanup();
|
|
55
|
+
console.log(chalk.green(` ✔ Synthetic load test completed after ${durationSec}s.`));
|
|
56
|
+
resolve();
|
|
57
|
+
}, durationSec * 1000);
|
|
58
|
+
});
|
|
59
|
+
}
|
package/src/wizard.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { readConfig, writeConfig, CONFIG_FILE } from './state/config.js';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export async function runSetupWizardIfNeeded(skipWizardFlag?: boolean): Promise<void> {
|
|
7
|
+
if (skipWizardFlag || process.env.PYRE_NO_WIZARD === '1' || !process.stdout.isTTY) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.log(chalk.bold('\n 🔥 Welcome to pyre! First-run Setup Wizard\n'));
|
|
16
|
+
console.log(chalk.dim(' This quick 3-question setup configures your defaults.\n'));
|
|
17
|
+
|
|
18
|
+
const rl = readline.createInterface({
|
|
19
|
+
input: process.stdin,
|
|
20
|
+
output: process.stdout,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const ask = (query: string): Promise<string> => {
|
|
24
|
+
return new Promise((resolve) => rl.question(query, resolve));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// Question 1: Theme
|
|
29
|
+
console.log(chalk.cyan(' 1. Pick a default color theme:'));
|
|
30
|
+
console.log(chalk.dim(' (1) default (2) dracula (3) cyberpunk (4) monochrome (5) nord (6) gruvbox'));
|
|
31
|
+
const themeAns = (await ask(chalk.yellow(' Choice [1-6] (default: 1): '))).trim();
|
|
32
|
+
let theme = 'default';
|
|
33
|
+
if (themeAns === '2') theme = 'dracula';
|
|
34
|
+
else if (themeAns === '3') theme = 'cyberpunk';
|
|
35
|
+
else if (themeAns === '4') theme = 'monochrome';
|
|
36
|
+
else if (themeAns === '5') theme = 'nord';
|
|
37
|
+
else if (themeAns === '6') theme = 'gruvbox';
|
|
38
|
+
|
|
39
|
+
// Question 2: Notifications
|
|
40
|
+
console.log(chalk.cyan('\n 2. Enable desktop notifications on system thermal/CPU alerts?'));
|
|
41
|
+
const notifAns = (await ask(chalk.yellow(' Enable notifications? [Y/n]: '))).trim().toLowerCase();
|
|
42
|
+
const notificationsEnabled = notifAns !== 'n';
|
|
43
|
+
|
|
44
|
+
// Question 3: Powermetrics / Sudo explanation
|
|
45
|
+
console.log(chalk.cyan('\n 3. Sudo / Powermetrics setup:'));
|
|
46
|
+
console.log(chalk.dim(' pyre reads basic metrics sudolessly. For detailed per-core wattages,'));
|
|
47
|
+
console.log(chalk.dim(' you can optionally grant passwordless sudo for powermetrics later.'));
|
|
48
|
+
await ask(chalk.yellow(' Press Enter to finish setup... '));
|
|
49
|
+
|
|
50
|
+
writeConfig({
|
|
51
|
+
theme,
|
|
52
|
+
notificationsEnabled,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
console.log(chalk.green('\n ✔ Preferences saved to ~/.config/pyre/config.json\n'));
|
|
56
|
+
} catch {
|
|
57
|
+
// fallback if interrupted
|
|
58
|
+
} finally {
|
|
59
|
+
rl.close();
|
|
60
|
+
}
|
|
61
|
+
}
|