pyre-cli 9.4.0 → 9.5.1
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/dist/checkCmd-EWFNLBWE.js +74 -0
- package/dist/{chunk-QOLZ7T32.js → chunk-HGRBBPYX.js} +65 -3
- package/dist/chunk-MOAEGMFP.js +1042 -0
- package/dist/chunk-SG26Y3WF.js +0 -0
- package/dist/{collectors-VACLPHRP.js → collectors-WD6VKH6M.js} +1 -1
- package/dist/index.js +343 -1271
- package/dist/pipeCmd-7THXEGDH.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/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 +77 -7
- package/src/monitors/collectors.ts +50 -3
- package/src/monitors/smc.ts +27 -0
- 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,13 +73,20 @@ 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
|
|
@@ -175,8 +185,42 @@ async function runServerCommand(): Promise<void> {
|
|
|
175
185
|
}
|
|
176
186
|
|
|
177
187
|
async function main() {
|
|
188
|
+
const { runSetupWizardIfNeeded } = await import('./wizard.js');
|
|
189
|
+
await runSetupWizardIfNeeded(!opts.wizard);
|
|
190
|
+
|
|
178
191
|
const cmd = program.args[0];
|
|
179
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
|
+
|
|
180
224
|
if (cmd === 'anomalies') {
|
|
181
225
|
const { runAnomaliesDigest } = await import('./anomaliesCmd.js');
|
|
182
226
|
runAnomaliesDigest({ since: opts.since || '7d', dir: opts.exportDir });
|
|
@@ -261,6 +305,17 @@ async function main() {
|
|
|
261
305
|
}
|
|
262
306
|
|
|
263
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
|
+
}
|
|
264
319
|
await runWebCommand();
|
|
265
320
|
return;
|
|
266
321
|
}
|
|
@@ -400,7 +455,7 @@ async function main() {
|
|
|
400
455
|
else if (opts.md) output = formatMarkdown(data);
|
|
401
456
|
else if (opts.csv) output = formatCsv(data);
|
|
402
457
|
else if (opts.tsv) output = formatTsv(data);
|
|
403
|
-
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 } });
|
|
404
459
|
|
|
405
460
|
console.log(output);
|
|
406
461
|
|
|
@@ -697,6 +752,15 @@ async function runWebCommand(): Promise<number> {
|
|
|
697
752
|
return;
|
|
698
753
|
}
|
|
699
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
|
+
|
|
700
764
|
// 3. Real-time Server-Sent Events (SSE) stream endpoint
|
|
701
765
|
if (url === '/api/stream') {
|
|
702
766
|
res.writeHead(200, {
|
|
@@ -773,6 +837,7 @@ async function runWebCommand(): Promise<number> {
|
|
|
773
837
|
console.log(chalk.dim(` Local URL: http://localhost:${actualPort}/`));
|
|
774
838
|
console.log(chalk.hex('#ff6a39').bold(` Network URL: http://${ip}:${actualPort}/ (Accessible on same Wi-Fi / LAN)`));
|
|
775
839
|
console.log(chalk.dim(` API: http://${ip}:${actualPort}/api`));
|
|
840
|
+
console.log(chalk.dim(` Metrics: http://${ip}:${actualPort}/metrics`));
|
|
776
841
|
console.log(chalk.dim(` SSE Stream: http://${ip}:${actualPort}/api/stream`));
|
|
777
842
|
|
|
778
843
|
resolve(actualPort);
|
|
@@ -807,9 +872,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
807
872
|
|
|
808
873
|
func applicationDidFinishLaunching(_ n: Notification) {
|
|
809
874
|
// Window
|
|
875
|
+
let isCompact = ${!!opts.compact}
|
|
876
|
+
let isAlwaysOnTop = ${!!opts.ontop}
|
|
810
877
|
let screen = NSScreen.main?.visibleFrame ?? NSRect(x:0, y:0, width:1280, height:800)
|
|
811
|
-
let W = min(1180.0, screen.width - 80)
|
|
812
|
-
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)
|
|
813
880
|
let rect = NSRect(x: screen.minX + (screen.width - W) / 2,
|
|
814
881
|
y: screen.minY + (screen.height - H) / 2,
|
|
815
882
|
width: W, height: H)
|
|
@@ -817,8 +884,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
817
884
|
window = NSWindow(contentRect: rect,
|
|
818
885
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
819
886
|
backing: .buffered, defer: false)
|
|
820
|
-
window.title = "Pyre"
|
|
821
|
-
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
|
+
}
|
|
822
892
|
window.appearance = NSAppearance(named: .darkAqua)
|
|
823
893
|
window.backgroundColor = NSColor(white: 0, alpha: 1)
|
|
824
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/smc.ts
CHANGED
|
@@ -99,6 +99,33 @@ export async function getSmcMetrics(): Promise<SmcMetrics> {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// 2b. AppleSmartBattery fallback — works without sudo on all MacBooks
|
|
103
|
+
// VirtualTemperature reflects the SoC/die temperature; Temperature is
|
|
104
|
+
// the battery cell temperature. Both are reported in centi-Celsius
|
|
105
|
+
// (e.g. 3539 → 35.39 °C).
|
|
106
|
+
if (result.temps['cpu_die'] === undefined) {
|
|
107
|
+
try {
|
|
108
|
+
const battRaw = await run('ioreg -r -n AppleSmartBattery 2>/dev/null', '');
|
|
109
|
+
if (battRaw) {
|
|
110
|
+
// Prefer VirtualTemperature (closer to die temp) over Temperature (battery cell)
|
|
111
|
+
const virtMatch = battRaw.match(/"VirtualTemperature"\s*=\s*(\d+)/);
|
|
112
|
+
const cellMatch = battRaw.match(/"Temperature"\s*=\s*(\d+)/);
|
|
113
|
+
const rawVirt = virtMatch ? parseFloat(virtMatch[1]) : undefined;
|
|
114
|
+
const rawCell = cellMatch ? parseFloat(cellMatch[1]) : undefined;
|
|
115
|
+
// Convert centi-Celsius to Celsius (e.g. 3085 → 30.85 → 30.9)
|
|
116
|
+
const virtC = rawVirt !== undefined ? Math.round(rawVirt / 10) / 10 : undefined;
|
|
117
|
+
const cellC = rawCell !== undefined ? Math.round(rawCell / 10) / 10 : undefined;
|
|
118
|
+
if (virtC !== undefined && virtC >= 15 && virtC <= 115) {
|
|
119
|
+
result.temps['cpu_die'] = virtC;
|
|
120
|
+
} else if (cellC !== undefined && cellC >= 15 && cellC <= 115) {
|
|
121
|
+
result.temps['cpu_die'] = cellC;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// ignore
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
102
129
|
// 3. Linux /sys/class/thermal or /sys/class/hwmon fallback
|
|
103
130
|
if (result.temps['cpu_die'] === undefined) {
|
|
104
131
|
try {
|
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
|
+
}
|