pyre-cli 1.2.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 +210 -17
- 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
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System-metric collectors.
|
|
3
|
+
*
|
|
4
|
+
* Each `collect*` function queries a single category of
|
|
5
|
+
* macOS system data via shell commands and returns a
|
|
6
|
+
* strongly-typed result. `collectAll` orchestrates them
|
|
7
|
+
* in parallel and assembles the final {@link StatsData}
|
|
8
|
+
* object.
|
|
9
|
+
*
|
|
10
|
+
* All commands are wrapped in {@link run} so that a
|
|
11
|
+
* failure in one category never prevents the others from
|
|
12
|
+
* returning their best-effort values.
|
|
13
|
+
*/
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
import { run } from './run.js';
|
|
16
|
+
import { getSmcMetrics, parseSuffix } from './smc.js';
|
|
17
|
+
import type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData } from './types.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Gather every metric category in parallel and return a
|
|
21
|
+
* complete {@link StatsData} snapshot.
|
|
22
|
+
*/
|
|
23
|
+
export async function collectAll(opts: { detailed?: boolean; processLimit?: number } = {}): Promise<StatsData> {
|
|
24
|
+
const [cpu, gpu, memory, disk, battery, thermal, network, processes, system, power] = await Promise.all([
|
|
25
|
+
collectCpu(),
|
|
26
|
+
collectGpu(opts.detailed),
|
|
27
|
+
collectMemory(),
|
|
28
|
+
collectDisk(),
|
|
29
|
+
collectBattery(),
|
|
30
|
+
collectThermal(opts.detailed),
|
|
31
|
+
collectNetwork(),
|
|
32
|
+
collectProcesses(opts.processLimit ?? 10),
|
|
33
|
+
collectSystem(),
|
|
34
|
+
collectPower(),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
header: {
|
|
39
|
+
title: chalk.hex('#ff5500').bold('PYRE'),
|
|
40
|
+
hostname: system.hostname,
|
|
41
|
+
os: system.os,
|
|
42
|
+
uptime: system.uptime,
|
|
43
|
+
},
|
|
44
|
+
cpu,
|
|
45
|
+
gpu,
|
|
46
|
+
memory,
|
|
47
|
+
disk,
|
|
48
|
+
battery,
|
|
49
|
+
thermal,
|
|
50
|
+
network,
|
|
51
|
+
processes,
|
|
52
|
+
power,
|
|
53
|
+
timestamp: new Date().toISOString(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read power-draw metrics (CPU watts, GPU watts, combined)
|
|
59
|
+
* from the SMC via powermetrics. Returns `null` when no
|
|
60
|
+
* power data is available.
|
|
61
|
+
*/
|
|
62
|
+
export async function collectPower(): Promise<PowerData | null> {
|
|
63
|
+
try {
|
|
64
|
+
const { power } = await getSmcMetrics();
|
|
65
|
+
if (power.cpu === undefined && power.gpu === undefined && power.combined === undefined) return null;
|
|
66
|
+
return { cpuWatts: power.cpu, gpuWatts: power.gpu, combinedWatts: power.combined };
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Gather GPU information from system_profiler and powermetrics.
|
|
74
|
+
* Returns null when GPU data is unavailable (e.g. on machines
|
|
75
|
+
* without a discrete GPU or when powermetrics is inaccessible).
|
|
76
|
+
*/
|
|
77
|
+
export async function collectGpu(detailed?: boolean): Promise<GpuData | null> {
|
|
78
|
+
try {
|
|
79
|
+
const spRaw = (await run('system_profiler SPDisplaysDataType 2>/dev/null')).trim();
|
|
80
|
+
if (!spRaw) return null;
|
|
81
|
+
|
|
82
|
+
const modelMatch = spRaw.match(/Chipset Model:\s*(.+)/i);
|
|
83
|
+
const model = modelMatch ? modelMatch[1].trim() : 'Unknown';
|
|
84
|
+
|
|
85
|
+
const memMatch = spRaw.match(/VRAM \(Total\):\s*(.+)/i);
|
|
86
|
+
let memory = 0;
|
|
87
|
+
if (memMatch) {
|
|
88
|
+
const memStr = memMatch[1].trim();
|
|
89
|
+
const memNum = parseFloat(memStr);
|
|
90
|
+
if (memStr.includes('GB')) memory = memNum * 1024 * 1024 * 1024;
|
|
91
|
+
else if (memStr.includes('MB')) memory = memNum * 1024 * 1024;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let utilization = 0;
|
|
95
|
+
let temperature: number | undefined;
|
|
96
|
+
let gpuProcesses = 0;
|
|
97
|
+
|
|
98
|
+
if (detailed) {
|
|
99
|
+
try {
|
|
100
|
+
const pm = (await run('sudo -n powermetrics --samplers gpu_power -n 1 --format text 2>/dev/null', '')).trim();
|
|
101
|
+
const gpuPowerMatch = pm.match(/GPU Power:\s*([\d.]+)\s*mW/i);
|
|
102
|
+
if (gpuPowerMatch) {
|
|
103
|
+
utilization = Math.min(100, Math.round(parseFloat(gpuPowerMatch[1]) / 10));
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// ignore
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const { temps } = await getSmcMetrics();
|
|
111
|
+
if (temps.gpu_die !== undefined) temperature = temps.gpu_die;
|
|
112
|
+
} catch {
|
|
113
|
+
// ignore
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const psRaw = (await run('ps aux | grep -i "[g]pu" | wc -l')).trim();
|
|
118
|
+
gpuProcesses = parseInt(psRaw) || 0;
|
|
119
|
+
} catch {
|
|
120
|
+
// ignore
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { model, memory, utilization, temperature, processes: gpuProcesses };
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function collectSystem(): Promise<{ hostname: string; os: string; uptime: string }> {
|
|
131
|
+
const hostname = (await run('hostname', 'Mac')).trim();
|
|
132
|
+
const osRaw = (await run('sw_vers -productVersion', 'Unknown')).trim();
|
|
133
|
+
const uptimeRaw = (await run('uptime')).trim();
|
|
134
|
+
const uptimeMatch = uptimeRaw.match(/up\s+(.*?),\s+\d+\s+users/);
|
|
135
|
+
return {
|
|
136
|
+
hostname,
|
|
137
|
+
os: `macOS ${osRaw}`,
|
|
138
|
+
uptime: uptimeMatch ? uptimeMatch[1] : uptimeRaw,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function collectCpu(): Promise<CpuData> {
|
|
143
|
+
const brand = (await run('sysctl -n machdep.cpu.brand_string', 'Unknown CPU')).trim();
|
|
144
|
+
const cores = parseInt((await run('sysctl -n hw.ncpu')).trim()) || 1;
|
|
145
|
+
const physicalCores = parseInt((await run('sysctl -n hw.physicalcpu')).trim()) || cores;
|
|
146
|
+
const frequencyHz = parseInt((await run('sysctl -n hw.cpufrequency')).trim());
|
|
147
|
+
let frequency = frequencyHz > 0 ? Math.round(frequencyHz / 1_000_000) : 0;
|
|
148
|
+
|
|
149
|
+
if (frequency === 0) {
|
|
150
|
+
// hw.cpufrequency is an Intel-era sysctl. On Apple Silicon it's either
|
|
151
|
+
// absent or reads back 0, so frequency was silently stuck at 0 MHz on
|
|
152
|
+
// every M-series Mac. powermetrics reports the actual active clock speed
|
|
153
|
+
// per core cluster (E/P-cluster) instead — reuse the same cached call
|
|
154
|
+
// already used for temperature/power rather than shelling out again.
|
|
155
|
+
try {
|
|
156
|
+
const { freq } = await getSmcMetrics();
|
|
157
|
+
const values = Object.values(freq);
|
|
158
|
+
if (values.length) {
|
|
159
|
+
// powermetrics reports in Hz, system profiler reports in MHz.
|
|
160
|
+
// We need MHz for the display.
|
|
161
|
+
frequency = Math.round(Math.max(...values) / 1_000_000);
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// ignore — frequency stays 0, meaning "unavailable" rather than wrong
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let usage = 0;
|
|
169
|
+
try {
|
|
170
|
+
const top = (await run('top -l 1 -n 0')).trim();
|
|
171
|
+
// Real `top -l 1 -n 0` output is comma-separated and newline-terminated,
|
|
172
|
+
// e.g. "CPU usage: 5.26% user, 10.52% sys, 84.21% idle" — there is no
|
|
173
|
+
// semicolon anywhere on that line. The old regex required one to
|
|
174
|
+
// terminate the match, so it never matched and usage stayed at 0.
|
|
175
|
+
const cpuLine = top.match(/CPU usage:\s*[\d.]+%\s*user,\s*[\d.]+%\s*sys,\s*([\d.]+)%\s*idle/);
|
|
176
|
+
if (cpuLine) {
|
|
177
|
+
const idle = parseFloat(cpuLine[1]);
|
|
178
|
+
usage = Math.round(100 - idle);
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// ignore
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const loadAvgStr = (await run('sysctl -n vm.loadavg', '0.00 0.00 0.00')).trim();
|
|
185
|
+
const loadAvg = loadAvgStr.match(/\{ (.+?) \}/)?.[1].split(' ').map(Number) || [0, 0, 0];
|
|
186
|
+
|
|
187
|
+
let temperature: number | undefined;
|
|
188
|
+
try {
|
|
189
|
+
const { temps } = await getSmcMetrics();
|
|
190
|
+
if (temps.cpu_die !== undefined) temperature = temps.cpu_die;
|
|
191
|
+
} catch {
|
|
192
|
+
// ignore
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { brand, cores, physicalCores, frequency, usage, loadAvg, temperature };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function collectMemory(): Promise<MemoryData> {
|
|
199
|
+
const totalBytes = parseInt((await run('sysctl -n hw.memsize')).trim()) || 0;
|
|
200
|
+
const pageSize = parseInt((await run('sysctl -n hw.pagesize', '4096')).trim()) || 4096;
|
|
201
|
+
|
|
202
|
+
const vmStat = (await run('vm_stat')).trim();
|
|
203
|
+
const getPageCount = (label: string) => {
|
|
204
|
+
const m = vmStat.match(new RegExp(`Pages\\s+${label}:\\s+(\\d+)\\.`));
|
|
205
|
+
return m ? parseInt(m[1]) : 0;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const freePages = getPageCount('free');
|
|
209
|
+
const activePages = getPageCount('active');
|
|
210
|
+
const inactivePages = getPageCount('inactive');
|
|
211
|
+
const wiredPages = getPageCount('wired down');
|
|
212
|
+
const compressedPages = getPageCount('occupied by compressor');
|
|
213
|
+
|
|
214
|
+
const free = freePages * pageSize;
|
|
215
|
+
const used = (activePages + inactivePages + wiredPages + compressedPages) * pageSize;
|
|
216
|
+
const usagePercent = totalBytes > 0 ? Math.round((used / totalBytes) * 100) : 0;
|
|
217
|
+
|
|
218
|
+
let swapTotal = 0;
|
|
219
|
+
let swapUsed = 0;
|
|
220
|
+
let swapFree = 0;
|
|
221
|
+
try {
|
|
222
|
+
const swapRaw = (await run('sysctl -n vm.swapusage')).trim();
|
|
223
|
+
// Real output is "total = 3072.00M used = 1497.75M free = 1574.25M
|
|
224
|
+
// (encrypted)" — the parentheses wrap the trailing "(encrypted)" note,
|
|
225
|
+
// not the used value. The old regex required a literal "(" right before
|
|
226
|
+
// "used", which never appears, so this never matched and swap stayed at
|
|
227
|
+
// 0 forever instead of updating.
|
|
228
|
+
const swapMatch = swapRaw.match(
|
|
229
|
+
/total\s*=\s*([\d.]+)([KMGkmg])\s+used\s*=\s*([\d.]+)([KMGkmg])\s+free\s*=\s*([\d.]+)([KMGkmg])/
|
|
230
|
+
);
|
|
231
|
+
if (swapMatch) {
|
|
232
|
+
swapTotal = parseFloat(swapMatch[1]) * parseSuffix(swapMatch[2]);
|
|
233
|
+
swapUsed = parseFloat(swapMatch[3]) * parseSuffix(swapMatch[4]);
|
|
234
|
+
swapFree = parseFloat(swapMatch[5]) * parseSuffix(swapMatch[6]);
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
// ignore
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
total: totalBytes,
|
|
242
|
+
used,
|
|
243
|
+
free,
|
|
244
|
+
swapTotal,
|
|
245
|
+
swapUsed,
|
|
246
|
+
swapFree,
|
|
247
|
+
pageSize,
|
|
248
|
+
usagePercent,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function collectDisk(): Promise<DiskData[]> {
|
|
253
|
+
const raw = (await run('df -h')).trim();
|
|
254
|
+
const lines = raw.split('\n').slice(1);
|
|
255
|
+
return lines
|
|
256
|
+
.map(line => {
|
|
257
|
+
const parts = line.split(/\s+/);
|
|
258
|
+
return {
|
|
259
|
+
filesystem: parts[0],
|
|
260
|
+
size: parts[1],
|
|
261
|
+
used: parts[2],
|
|
262
|
+
available: parts[3],
|
|
263
|
+
capacity: parts[4],
|
|
264
|
+
mountpoint: parts[5] || parts[6] || '/',
|
|
265
|
+
};
|
|
266
|
+
})
|
|
267
|
+
.filter(d => d.filesystem.includes('/') || d.size.includes('G') || d.size.includes('T'));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function collectBattery(): Promise<BatteryData | null> {
|
|
271
|
+
try {
|
|
272
|
+
const raw = (await run('pmset -g batt')).trim();
|
|
273
|
+
if (!raw.includes('Battery Power') && !raw.includes('AC Power')) return null;
|
|
274
|
+
|
|
275
|
+
const levelMatch = raw.match(/(\d+)%/);
|
|
276
|
+
const stateMatch = raw.match(/;\s*(charging|discharging|charged|finishing charge|attached to charger)/i);
|
|
277
|
+
const timeMatch = raw.match(/:\s*([\d:]+)\s+remaining/i);
|
|
278
|
+
const powerPlugged = raw.includes('AC Power') || raw.includes('attached to charger');
|
|
279
|
+
|
|
280
|
+
const level = levelMatch ? parseInt(levelMatch[1]) : 0;
|
|
281
|
+
const state = powerPlugged ? 'charged' : (stateMatch ? stateMatch[1].toLowerCase() : 'discharging');
|
|
282
|
+
const timeRemaining = timeMatch ? timeMatch[1] : (powerPlugged ? '∞' : 'calculating');
|
|
283
|
+
|
|
284
|
+
let cycles: number | undefined;
|
|
285
|
+
let condition: string | undefined;
|
|
286
|
+
let maxCapacityPercent: number | undefined;
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
// Plain text output (NOT -json) — its field names ("Cycle Count:", "Condition:")
|
|
290
|
+
// are what the regexes below expect. Using -json here was the original bug:
|
|
291
|
+
// it silently returned JSON that these text regexes could never match, so
|
|
292
|
+
// health always fell back to 'unknown'.
|
|
293
|
+
const battInfo = (await run('system_profiler SPPowerDataType 2>/dev/null')).trim();
|
|
294
|
+
|
|
295
|
+
const cycleMatch = battInfo.match(/Cycle Count:\s+(\d+)/);
|
|
296
|
+
if (cycleMatch) cycles = parseInt(cycleMatch[1]);
|
|
297
|
+
|
|
298
|
+
const conditionMatch = battInfo.match(/Condition:\s+(.+)/);
|
|
299
|
+
if (conditionMatch) condition = conditionMatch[1].trim();
|
|
300
|
+
|
|
301
|
+
const maxCapMatch = battInfo.match(/Maximum Capacity:\s+(\d+)%/);
|
|
302
|
+
if (maxCapMatch) {
|
|
303
|
+
maxCapacityPercent = parseInt(maxCapMatch[1]);
|
|
304
|
+
} else {
|
|
305
|
+
// Older/alternate output reports Full Charge Capacity vs Design Capacity
|
|
306
|
+
// instead of a direct percentage — derive it if both are present.
|
|
307
|
+
const fullMatch = battInfo.match(/Full Charge Capacity[^\d]*(\d+)/i);
|
|
308
|
+
const designMatch = battInfo.match(/Design Capacity[^\d]*(\d+)/i);
|
|
309
|
+
if (fullMatch && designMatch) {
|
|
310
|
+
const full = parseInt(fullMatch[1]);
|
|
311
|
+
const design = parseInt(designMatch[1]);
|
|
312
|
+
if (design > 0) maxCapacityPercent = Math.round((full / design) * 100);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// health stays unknown
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const healthParts: string[] = [];
|
|
320
|
+
if (condition) healthParts.push(condition);
|
|
321
|
+
if (maxCapacityPercent !== undefined) healthParts.push(`${maxCapacityPercent}% capacity`);
|
|
322
|
+
if (cycles !== undefined) healthParts.push(`${cycles} cycles`);
|
|
323
|
+
const health = healthParts.length ? healthParts.join(', ') : 'unknown';
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
level,
|
|
327
|
+
state,
|
|
328
|
+
timeRemaining,
|
|
329
|
+
health,
|
|
330
|
+
powerSource: powerPlugged ? 'AC' : 'Battery',
|
|
331
|
+
cycles,
|
|
332
|
+
condition,
|
|
333
|
+
maxCapacityPercent,
|
|
334
|
+
};
|
|
335
|
+
} catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function pressureFromState(state: string): number {
|
|
341
|
+
const s = state.toLowerCase();
|
|
342
|
+
if (s.includes('critical')) return 3;
|
|
343
|
+
if (s.includes('serious') || s.includes('heavy')) return 2;
|
|
344
|
+
if (s.includes('fair') || s.includes('moderate')) return 1;
|
|
345
|
+
return 0; // nominal / normal / unknown
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function collectThermal(detailed?: boolean): Promise<ThermalData> {
|
|
349
|
+
try {
|
|
350
|
+
const therm = (await run('pmset -g therm', '')).trim();
|
|
351
|
+
|
|
352
|
+
let state = 'Unknown';
|
|
353
|
+
let detail = therm || 'No pmset thermal data';
|
|
354
|
+
|
|
355
|
+
const stateMatch = therm.match(/Thermal state:\s*(.+)/i);
|
|
356
|
+
|
|
357
|
+
if (stateMatch) {
|
|
358
|
+
const raw = stateMatch[1].trim();
|
|
359
|
+
state = /0|no warning|normal/i.test(raw) ? 'Nominal' : raw;
|
|
360
|
+
detail = state;
|
|
361
|
+
} else {
|
|
362
|
+
const limitMatch = therm.match(
|
|
363
|
+
/CPU_(?:Speed|Scheduler)_Limit\s*(?:=|:)\s*([\d.]+)/i
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
if (limitMatch) {
|
|
367
|
+
const limit = parseFloat(limitMatch[1]);
|
|
368
|
+
|
|
369
|
+
if (limit >= 100) state = 'Nominal';
|
|
370
|
+
else if (limit >= 85) state = 'Fair';
|
|
371
|
+
else if (limit >= 50) state = 'Serious';
|
|
372
|
+
else state = 'Critical';
|
|
373
|
+
|
|
374
|
+
detail = `${state} (CPU limit ${limit}%)`;
|
|
375
|
+
} else if (/no thermal warning level has been recorded/i.test(therm)) {
|
|
376
|
+
// The most common real-world case: pmset hasn't recorded any warning
|
|
377
|
+
// and never emits a CPU_Speed_Limit line at all when nothing has ever
|
|
378
|
+
// throttled. That's not "unknown" — it's the system telling us
|
|
379
|
+
// everything is nominal — but the old code fell through to
|
|
380
|
+
// 'Unknown' here with no other way to recover, since the
|
|
381
|
+
// powermetrics fallback below almost always fails without
|
|
382
|
+
// passwordless sudo configured. That's the bug: thermal state read
|
|
383
|
+
// as "Unknown" on effectively every normal, non-throttling Mac.
|
|
384
|
+
state = 'Nominal';
|
|
385
|
+
detail = 'Nominal (no thermal warning recorded)';
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const temperatures: Record<string, number | null> = {};
|
|
390
|
+
let cpuDieTemp: number | undefined;
|
|
391
|
+
|
|
392
|
+
// Grab the CPU die temp regardless of `detailed` — we need it both for the
|
|
393
|
+
// detailed sensor breakdown and as a state fallback below.
|
|
394
|
+
try {
|
|
395
|
+
const { temps } = await getSmcMetrics();
|
|
396
|
+
cpuDieTemp = temps.cpu_die;
|
|
397
|
+
if (detailed) {
|
|
398
|
+
if (temps.cpu_die !== undefined) temperatures['cpu_die'] = temps.cpu_die;
|
|
399
|
+
if (temps.gpu_die !== undefined) temperatures['gpu_die'] = temps.gpu_die;
|
|
400
|
+
if (temps.smc_die !== undefined) temperatures['smc_die'] = temps.smc_die;
|
|
401
|
+
if (!Object.keys(temps).length) {
|
|
402
|
+
detail += ' (sensor data needs powermetrics privileges)';
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
} catch {
|
|
406
|
+
if (detailed) detail += ' (powermetrics unavailable)';
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Last resort: if we still don't know the state (e.g. pmset gave us nothing
|
|
410
|
+
// usable) but we do have a die temperature, estimate state from that.
|
|
411
|
+
if (state === 'Unknown' && cpuDieTemp !== undefined) {
|
|
412
|
+
if (cpuDieTemp >= 100) state = 'Critical';
|
|
413
|
+
else if (cpuDieTemp >= 90) state = 'Serious';
|
|
414
|
+
else if (cpuDieTemp >= 80) state = 'Fair';
|
|
415
|
+
else state = 'Nominal';
|
|
416
|
+
detail = `${state} (estimated from ${cpuDieTemp}°C)`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
state,
|
|
421
|
+
detail,
|
|
422
|
+
pressureLevel: pressureFromState(state),
|
|
423
|
+
temperatures: Object.keys(temperatures).length ? temperatures : undefined,
|
|
424
|
+
};
|
|
425
|
+
} catch {
|
|
426
|
+
return { state: 'Unknown', pressureLevel: 0, error: 'Thermal info unavailable on this system' };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function collectNetwork(): Promise<NetworkData> {
|
|
431
|
+
try {
|
|
432
|
+
const primary = (await run('route -n get default')).trim();
|
|
433
|
+
const ifaceMatch = primary.match(/interface:\s+(\S+)/);
|
|
434
|
+
const iface = ifaceMatch?.[1] || 'en0';
|
|
435
|
+
|
|
436
|
+
let ip = '0.0.0.0';
|
|
437
|
+
try {
|
|
438
|
+
const ifconfig = (await run(`ifconfig ${iface}`)).trim();
|
|
439
|
+
const ipMatch = ifconfig.match(/inet\s+([\d.]+)\s+netmask/);
|
|
440
|
+
if (ipMatch) ip = ipMatch[1];
|
|
441
|
+
} catch {
|
|
442
|
+
// ignore
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const netStat = (await run('netstat -ib')).trim();
|
|
446
|
+
let rxBytes = 0;
|
|
447
|
+
let txBytes = 0;
|
|
448
|
+
let rxPackets = 0;
|
|
449
|
+
let txPackets = 0;
|
|
450
|
+
|
|
451
|
+
for (const line of netStat.split('\n')) {
|
|
452
|
+
if (line.startsWith(iface) || line.startsWith(iface + ':')) {
|
|
453
|
+
const parts = line.split(/\s+/);
|
|
454
|
+
if (parts.length >= 9) {
|
|
455
|
+
rxPackets += parseInt(parts[4]) || 0;
|
|
456
|
+
rxBytes += parseInt(parts[5]) || 0;
|
|
457
|
+
txPackets += parseInt(parts[6]) || 0;
|
|
458
|
+
txBytes += parseInt(parts[7]) || 0;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return { interface: iface, ip, rxBytes, txBytes, rxPackets, txPackets };
|
|
464
|
+
} catch {
|
|
465
|
+
return { interface: 'unknown', ip: '0.0.0.0', rxBytes: 0, txBytes: 0, rxPackets: 0, txPackets: 0 };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export async function collectProcesses(limit = 10): Promise<ProcessData[]> {
|
|
470
|
+
try {
|
|
471
|
+
const raw = (await run(`ps -Ao pid,ppid,user,pcpu,pmem,state,threads,time,comm -r | head -n ${limit + 1}`)).trim();
|
|
472
|
+
const lines = raw.split('\n').slice(1);
|
|
473
|
+
return lines
|
|
474
|
+
.map(line => {
|
|
475
|
+
const parts = line.match(/\s*(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(\d+)\s+([\d:]+)\s+(.+)/);
|
|
476
|
+
if (!parts) return null;
|
|
477
|
+
const runtimeSec = parts[8].split(':').reduce((acc, val, idx) => acc + parseInt(val) * Math.pow(60, 2 - idx), 0);
|
|
478
|
+
return { pid: parseInt(parts[1]), ppid: parseInt(parts[2]), user: parts[3], cpu: parseFloat(parts[4]), mem: parseFloat(parts[5]), state: parts[6], threads: parseInt(parts[7]), runtime: runtimeSec, command: parts[9] };
|
|
479
|
+
})
|
|
480
|
+
.filter((p): p is ProcessData => p !== null && p.pid > 0);
|
|
481
|
+
} catch {
|
|
482
|
+
return [];
|
|
483
|
+
}
|
|
484
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pyre monitors module.
|
|
3
|
+
*
|
|
4
|
+
* Provides {@link collectAll} to gather a complete system-metric
|
|
5
|
+
* snapshot, and {@link collectPower} for power-draw data alone.
|
|
6
|
+
*
|
|
7
|
+
* The module is organised into three sub-files:
|
|
8
|
+
* - `types.ts` — all TypeScript interfaces
|
|
9
|
+
* - `smc.ts` — powermetrics/SMC sensor reading with caching
|
|
10
|
+
* - `collectors.ts` — individual metric collectors and the orchestrator
|
|
11
|
+
* - `run.ts` — shared shell-execution helper
|
|
12
|
+
*/
|
|
13
|
+
export { collectAll, collectPower } from './collectors.js';
|
|
14
|
+
export type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData } from './types.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared shell-execution helper for the monitors module.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `child_process.execSync` with a timeout and a
|
|
5
|
+
* fallback return value so that a failing command never
|
|
6
|
+
* crashes the entire collection pipeline.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
async function run(cmd: string, fallback: string = ''): Promise<string> {
|
|
10
|
+
try {
|
|
11
|
+
const { execSync } = await import('node:child_process');
|
|
12
|
+
return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }).trim();
|
|
13
|
+
} catch {
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { run };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMC and powermetrics sensor reader.
|
|
3
|
+
*
|
|
4
|
+
* Modern macOS requires `powermetrics` (root) to read CPU die
|
|
5
|
+
* temperature, GPU die temperature, SMC die temperature, and
|
|
6
|
+
* power draw (CPU/GPU/combined watts). This module wraps that
|
|
7
|
+
* call with a short-lived cache so that multiple collectors can
|
|
8
|
+
* reuse the same data within a single tick without invoking
|
|
9
|
+
* `powermetrics` repeatedly.
|
|
10
|
+
*
|
|
11
|
+
* It also provides the `parseSuffix` helper used when decoding
|
|
12
|
+
* `vm.swapusage` output.
|
|
13
|
+
*/
|
|
14
|
+
import { run } from './run.js';
|
|
15
|
+
|
|
16
|
+
export interface SmcMetrics {
|
|
17
|
+
temps: Record<string, number>;
|
|
18
|
+
power: { cpu?: number; gpu?: number; combined?: number };
|
|
19
|
+
freq: Record<string, number>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let smcCache: { data: SmcMetrics; ts: number } | null = null;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Query `powermetrics` for SMC temperatures, power draw, and
|
|
26
|
+
* CPU frequency. Results are cached for 1.5 s so that
|
|
27
|
+
* concurrent collectors (CPU temp, power, thermal) share a
|
|
28
|
+
* single invocation.
|
|
29
|
+
*
|
|
30
|
+
* Falls back to an empty object on any failure (missing
|
|
31
|
+
* sudoers entry, unavailable binary, etc.) so the rest of the
|
|
32
|
+
* application degrades gracefully.
|
|
33
|
+
*/
|
|
34
|
+
export async function getSmcMetrics(): Promise<SmcMetrics> {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
if (smcCache && now - smcCache.ts < 1500) return smcCache.data;
|
|
37
|
+
|
|
38
|
+
const result: SmcMetrics = { temps: {}, power: {}, freq: {} };
|
|
39
|
+
try {
|
|
40
|
+
// `sudo -n` (non-interactive) so we never hang waiting on a
|
|
41
|
+
// password prompt that can't be answered from a piped child
|
|
42
|
+
// process; if the user hasn't set up passwordless sudo for
|
|
43
|
+
// powermetrics, this just fails fast and we fall back to the
|
|
44
|
+
// thermal-state string instead of a temperature.
|
|
45
|
+
const pm = (
|
|
46
|
+
await run(
|
|
47
|
+
'sudo -n powermetrics --samplers smc,cpu_power -n 1 --format text 2>/dev/null',
|
|
48
|
+
''
|
|
49
|
+
)
|
|
50
|
+
).trim();
|
|
51
|
+
|
|
52
|
+
if (pm) {
|
|
53
|
+
const cpuTemp = pm.match(/CPU die temperature:\s*([\d.]+)/i);
|
|
54
|
+
if (cpuTemp) result.temps['cpu_die'] = parseFloat(cpuTemp[1]);
|
|
55
|
+
|
|
56
|
+
const gpuTemp = pm.match(/GPU die temperature:\s*([\d.]+)/i);
|
|
57
|
+
if (gpuTemp) result.temps['gpu_die'] = parseFloat(gpuTemp[1]);
|
|
58
|
+
|
|
59
|
+
const smcTemp = pm.match(/SMC die temperature:\s*([\d.]+)/i);
|
|
60
|
+
if (smcTemp) result.temps['smc_die'] = parseFloat(smcTemp[1]);
|
|
61
|
+
|
|
62
|
+
const cpuPower = pm.match(/CPU Power:\s*([\d.]+)\s*mW/i);
|
|
63
|
+
if (cpuPower) result.power.cpu = parseFloat(cpuPower[1]) / 1000;
|
|
64
|
+
|
|
65
|
+
const gpuPower = pm.match(/GPU Power:\s*([\d.]+)\s*mW/i);
|
|
66
|
+
if (gpuPower) result.power.gpu = parseFloat(gpuPower[1]) / 1000;
|
|
67
|
+
|
|
68
|
+
const combinedPower = pm.match(/Combined Power \(CPU \+ GPU.*?\):\s*([\d.]+)\s*mW/i);
|
|
69
|
+
if (combinedPower) result.power.combined = parseFloat(combinedPower[1]) / 1000;
|
|
70
|
+
|
|
71
|
+
// Apple Silicon reports active clock speed per core cluster
|
|
72
|
+
// rather than a single number — grab whichever cluster(s)
|
|
73
|
+
// this chip has.
|
|
74
|
+
const eFreq = pm.match(/E-Cluster HW active frequency:\s*([\d.]+)/i);
|
|
75
|
+
if (eFreq) result.freq['e_cluster'] = parseFloat(eFreq[1]);
|
|
76
|
+
|
|
77
|
+
const pFreq = pm.match(/P-Cluster HW active frequency:\s*([\d.]+)/i);
|
|
78
|
+
if (pFreq) result.freq['p_cluster'] = parseFloat(pFreq[1]);
|
|
79
|
+
|
|
80
|
+
// Some single-cluster/older chips report just "CPU HW active frequency".
|
|
81
|
+
const cpuFreq = pm.match(/CPU HW active frequency:\s*([\d.]+)/i);
|
|
82
|
+
if (cpuFreq) result.freq['cpu'] = parseFloat(cpuFreq[1]);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// powermetrics unavailable/unauthorized — leave temps/power/freq empty,
|
|
86
|
+
// the rest of the app degrades gracefully (thermal state string still
|
|
87
|
+
// shows, and CPU frequency falls back to 0/"unavailable").
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
smcCache = { data: result, ts: now };
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse a size suffix (K, M, G) from `vm.swapusage` output into
|
|
96
|
+
* its byte multiplier. Case-insensitive; defaults to 1 (no
|
|
97
|
+
* suffix).
|
|
98
|
+
*/
|
|
99
|
+
export function parseSuffix(s: string): number {
|
|
100
|
+
return s.toUpperCase() === 'K'
|
|
101
|
+
? 1024
|
|
102
|
+
: s.toUpperCase() === 'M'
|
|
103
|
+
? 1024 * 1024
|
|
104
|
+
: s.toUpperCase() === 'G'
|
|
105
|
+
? 1024 * 1024 * 1024
|
|
106
|
+
: 1;
|
|
107
|
+
}
|