pyre-cli 1.3.0 → 2.0.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 +278 -17
- package/dist/index.js +1763 -350
- package/package.json +6 -3
- package/parity.md +542 -0
- package/src/formatters/index.ts +17 -0
- package/src/formatters/output.ts +165 -0
- package/src/formatters/render.ts +659 -0
- package/src/formatters/themes.ts +115 -0
- package/src/formatters/types.ts +62 -0
- package/src/history.ts +153 -0
- package/src/index.ts +201 -0
- package/src/live/export.ts +76 -0
- package/src/live/index.ts +16 -0
- package/src/live/input.ts +443 -0
- package/src/live/render.ts +146 -0
- package/src/live/state.ts +103 -0
- package/src/live/types.ts +23 -0
- package/src/monitors/collectors.ts +687 -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 +148 -0
- package/src/p2p/client.ts +197 -0
- package/src/p2p/index.ts +3 -0
- package/src/p2p/protocol.ts +71 -0
- package/src/p2p/server.ts +219 -0
- package/src/p2p/types.ts +71 -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,687 @@
|
|
|
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, PacketData, NetworkProcess } from './types.js';
|
|
18
|
+
|
|
19
|
+
// Retain samples over a *time* window rather than a fixed tick count. At the
|
|
20
|
+
// default 2s refresh interval the old 10-sample window only spanned ~20s,
|
|
21
|
+
// but battery level only moves in whole-percent steps that typically take
|
|
22
|
+
// several minutes to change — so `discharged` was almost always 0 and the
|
|
23
|
+
// estimate never populated. A 15-minute time window plus a minimum elapsed
|
|
24
|
+
// time before we trust the numbers gives enough resolution to see a real
|
|
25
|
+
// drop while still updating reasonably often.
|
|
26
|
+
const BATTERY_WINDOW_MS = 15 * 60 * 1000;
|
|
27
|
+
const MIN_ESTIMATE_WINDOW_MS = 30 * 1000;
|
|
28
|
+
|
|
29
|
+
let prevBattery: { samples: { level: number; ts: number }[] } = { samples: [] };
|
|
30
|
+
|
|
31
|
+
function estimateBatteryLife(level: number, state: string): { estimatedTimeToEmpty?: string; dischargeRatePerHour?: number; powerWatts?: number } {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
|
|
34
|
+
if (state === 'charged' || state === 'charging') {
|
|
35
|
+
// Reset the window on a state change so a stale pre-charge sample
|
|
36
|
+
// doesn't get blended with post-charge readings once discharging
|
|
37
|
+
// resumes.
|
|
38
|
+
prevBattery.samples = [];
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
prevBattery.samples.push({ level, ts: now });
|
|
43
|
+
prevBattery.samples = prevBattery.samples.filter(s => now - s.ts <= BATTERY_WINDOW_MS);
|
|
44
|
+
|
|
45
|
+
const first = prevBattery.samples[0];
|
|
46
|
+
const elapsedMs = now - first.ts;
|
|
47
|
+
if (elapsedMs < MIN_ESTIMATE_WINDOW_MS) {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const dtHours = Math.max(elapsedMs / 1000 / 3600, 0.0001);
|
|
52
|
+
const discharged = first.level - level;
|
|
53
|
+
if (discharged <= 0) {
|
|
54
|
+
// No measurable drop yet within the window — report "calculating"
|
|
55
|
+
// rather than silently omitting the field, so the UI shows progress
|
|
56
|
+
// instead of a blank card.
|
|
57
|
+
return { estimatedTimeToEmpty: 'calculating' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const dischargeRate = discharged / dtHours; // %/hour
|
|
61
|
+
const remainingLevel = Math.max(level, 0);
|
|
62
|
+
const hoursToEmpty = remainingLevel / dischargeRate;
|
|
63
|
+
const h = Math.floor(hoursToEmpty);
|
|
64
|
+
const m = Math.round((hoursToEmpty - h) * 60);
|
|
65
|
+
const estimatedTimeToEmpty = hoursToEmpty > 0 ? `${h}h ${m}m` : 'calculating';
|
|
66
|
+
|
|
67
|
+
// Rough wattage from discharge rate: %/hour * (typical battery capacity
|
|
68
|
+
// in Wh) / 100. This is only used as a last-resort estimate — collectPower()
|
|
69
|
+
// now prefers real ioreg-derived wattage when available (see below).
|
|
70
|
+
const ASSUMED_CAPACITY_WH = 55;
|
|
71
|
+
const powerWatts = Math.round(dischargeRate * (ASSUMED_CAPACITY_WH / 100) * 100) / 100;
|
|
72
|
+
|
|
73
|
+
return { estimatedTimeToEmpty, dischargeRatePerHour: Math.round(dischargeRate * 10) / 10, powerWatts };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Gather every metric category in parallel and return a
|
|
78
|
+
* complete {@link StatsData} snapshot.
|
|
79
|
+
*/
|
|
80
|
+
export async function collectAll(opts: { detailed?: boolean; processLimit?: number } = {}): Promise<StatsData> {
|
|
81
|
+
const [cpu, gpu, memory, disk, battery, thermal, network, processes, system, power, packets, tasks] = await Promise.all([
|
|
82
|
+
collectCpu(),
|
|
83
|
+
collectGpu(opts.detailed),
|
|
84
|
+
collectMemory(),
|
|
85
|
+
collectDisk(),
|
|
86
|
+
collectBattery(),
|
|
87
|
+
collectThermal(opts.detailed),
|
|
88
|
+
collectNetwork(),
|
|
89
|
+
collectProcesses(opts.processLimit),
|
|
90
|
+
collectSystem(),
|
|
91
|
+
collectPower(),
|
|
92
|
+
collectPackets(),
|
|
93
|
+
collectTasks(),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
header: {
|
|
98
|
+
title: chalk.hex('#ff5500').bold('PYRE'),
|
|
99
|
+
hostname: system.hostname,
|
|
100
|
+
os: system.os,
|
|
101
|
+
uptime: system.uptime,
|
|
102
|
+
},
|
|
103
|
+
cpu,
|
|
104
|
+
gpu,
|
|
105
|
+
memory,
|
|
106
|
+
disk,
|
|
107
|
+
battery,
|
|
108
|
+
thermal,
|
|
109
|
+
network,
|
|
110
|
+
processes,
|
|
111
|
+
power,
|
|
112
|
+
timestamp: new Date().toISOString(),
|
|
113
|
+
packets,
|
|
114
|
+
tasks,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Read power-draw metrics (CPU watts, GPU watts, combined)
|
|
120
|
+
* from the SMC via powermetrics. Returns `null` when no
|
|
121
|
+
* power data is available.
|
|
122
|
+
*/
|
|
123
|
+
export async function collectPower(): Promise<PowerData | null> {
|
|
124
|
+
try {
|
|
125
|
+
const { power } = await getSmcMetrics();
|
|
126
|
+
if (power.cpu !== undefined || power.gpu !== undefined || power.combined !== undefined) {
|
|
127
|
+
return { cpuWatts: power.cpu, gpuWatts: power.gpu, combinedWatts: power.combined };
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// ignore
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// `pmset -g batt` never actually prints a "mW" figure on any macOS
|
|
134
|
+
// version — that regex could never match, so this fallback was dead
|
|
135
|
+
// code and collectPower() silently returned null whenever powermetrics
|
|
136
|
+
// wasn't available (which is most machines without passwordless sudo
|
|
137
|
+
// configured). ioreg's AppleSmartBattery entry exposes instantaneous
|
|
138
|
+
// amperage (mA, signed: negative while discharging) and voltage (mV)
|
|
139
|
+
// without any privileges, so we can derive real wattage from that.
|
|
140
|
+
try {
|
|
141
|
+
const ioreg = (await run('ioreg -rn AppleSmartBattery 2>/dev/null')).trim();
|
|
142
|
+
const ampMatch = ioreg.match(/"InstantAmperage"\s*=\s*(-?\d+)/);
|
|
143
|
+
const voltMatch = ioreg.match(/"Voltage"\s*=\s*(\d+)/);
|
|
144
|
+
if (ampMatch && voltMatch) {
|
|
145
|
+
// Two representations show up in the wild: Intel Macs often print a
|
|
146
|
+
// literal minus sign ("-1200") — no unwrap needed. Apple Silicon
|
|
147
|
+
// instead wraps a negative reading into an *unsigned 64-bit* field
|
|
148
|
+
// (e.g. real -1200mA prints as 18446744073709550416). The previous
|
|
149
|
+
// 32-bit unwrap (`- 0x100000000`) didn't touch that at all, and
|
|
150
|
+
// `parseInt` can't even represent a number that large precisely, so
|
|
151
|
+
// it silently mangled into a huge garbage value → absurd wattage.
|
|
152
|
+
// Parse as BigInt and only unwrap when there's no explicit sign.
|
|
153
|
+
let ampsBig = BigInt(ampMatch[1]);
|
|
154
|
+
if (!ampMatch[1].startsWith('-')) {
|
|
155
|
+
const UINT64_MAX_PLUS_1 = 1n << 64n;
|
|
156
|
+
const INT64_MAX = (1n << 63n) - 1n;
|
|
157
|
+
if (ampsBig > INT64_MAX) ampsBig -= UINT64_MAX_PLUS_1;
|
|
158
|
+
}
|
|
159
|
+
const amps = Number(ampsBig);
|
|
160
|
+
const volts = parseInt(voltMatch[1], 10) / 1000;
|
|
161
|
+
const watts = Math.abs((amps / 1000) * volts);
|
|
162
|
+
// Sanity clamp — a real Mac never draws anywhere near this much;
|
|
163
|
+
// treat an out-of-range reading as "no usable data" rather than
|
|
164
|
+
// display nonsense.
|
|
165
|
+
if (Number.isFinite(watts) && watts < 500) {
|
|
166
|
+
return { combinedWatts: Math.round(watts * 100) / 100 };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// ignore
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Gather GPU information from system_profiler and powermetrics.
|
|
178
|
+
* Returns null when GPU data is unavailable (e.g. on machines
|
|
179
|
+
* without a discrete GPU or when powermetrics is inaccessible).
|
|
180
|
+
*/
|
|
181
|
+
export async function collectGpu(detailed?: boolean): Promise<GpuData | null> {
|
|
182
|
+
try {
|
|
183
|
+
const spRaw = (await run('system_profiler SPDisplaysDataType 2>/dev/null')).trim();
|
|
184
|
+
if (!spRaw) return null;
|
|
185
|
+
|
|
186
|
+
const modelMatch = spRaw.match(/Chipset Model:\s*(.+)/i);
|
|
187
|
+
const model = modelMatch ? modelMatch[1].trim() : 'Unknown';
|
|
188
|
+
|
|
189
|
+
const memMatch = spRaw.match(/VRAM \(Total\):\s*(.+)/i);
|
|
190
|
+
let memory = 0;
|
|
191
|
+
if (memMatch) {
|
|
192
|
+
const memStr = memMatch[1].trim();
|
|
193
|
+
const memNum = parseFloat(memStr);
|
|
194
|
+
if (memStr.includes('GB')) memory = memNum * 1024 * 1024 * 1024;
|
|
195
|
+
else if (memStr.includes('MB')) memory = memNum * 1024 * 1024;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let utilization = 0;
|
|
199
|
+
let temperature: number | undefined;
|
|
200
|
+
let gpuProcesses = 0;
|
|
201
|
+
|
|
202
|
+
if (detailed) {
|
|
203
|
+
try {
|
|
204
|
+
const pm = (await run('sudo -n powermetrics --samplers gpu_power -n 1 --format text 2>/dev/null', '')).trim();
|
|
205
|
+
const gpuPowerMatch = pm.match(/GPU Power:\s*([\d.]+)\s*mW/i);
|
|
206
|
+
if (gpuPowerMatch) {
|
|
207
|
+
utilization = Math.min(100, Math.round(parseFloat(gpuPowerMatch[1]) / 10));
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
// ignore
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const { temps } = await getSmcMetrics();
|
|
215
|
+
if (temps.gpu_die !== undefined) temperature = temps.gpu_die;
|
|
216
|
+
} catch {
|
|
217
|
+
// ignore
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const psRaw = (await run('ps aux | grep -i "[g]pu" | wc -l')).trim();
|
|
222
|
+
gpuProcesses = parseInt(psRaw) || 0;
|
|
223
|
+
} catch {
|
|
224
|
+
// ignore
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { model, memory, utilization, temperature, processes: gpuProcesses };
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function collectSystem(): Promise<{ hostname: string; os: string; uptime: string }> {
|
|
235
|
+
const hostname = (await run('hostname', 'Mac')).trim();
|
|
236
|
+
const osRaw = (await run('sw_vers -productVersion', 'Unknown')).trim();
|
|
237
|
+
const uptimeRaw = (await run('uptime')).trim();
|
|
238
|
+
const uptimeMatch = uptimeRaw.match(/up\s+(.*?),\s+\d+\s+users/);
|
|
239
|
+
return {
|
|
240
|
+
hostname,
|
|
241
|
+
os: `macOS ${osRaw}`,
|
|
242
|
+
uptime: uptimeMatch ? uptimeMatch[1] : uptimeRaw,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function collectCpu(): Promise<CpuData> {
|
|
247
|
+
const brand = (await run('sysctl -n machdep.cpu.brand_string', 'Unknown CPU')).trim();
|
|
248
|
+
const cores = parseInt((await run('sysctl -n hw.ncpu')).trim()) || 1;
|
|
249
|
+
const physicalCores = parseInt((await run('sysctl -n hw.physicalcpu')).trim()) || cores;
|
|
250
|
+
const frequencyHz = parseInt((await run('sysctl -n hw.cpufrequency')).trim());
|
|
251
|
+
let frequency = frequencyHz > 0 ? Math.round(frequencyHz / 1_000_000) : 0;
|
|
252
|
+
|
|
253
|
+
if (frequency === 0) {
|
|
254
|
+
// hw.cpufrequency is an Intel-era sysctl. On Apple Silicon it's either
|
|
255
|
+
// absent or reads back 0, so frequency was silently stuck at 0 MHz on
|
|
256
|
+
// every M-series Mac. powermetrics reports the actual active clock speed
|
|
257
|
+
// per core cluster (E/P-cluster) instead — reuse the same cached call
|
|
258
|
+
// already used for temperature/power rather than shelling out again.
|
|
259
|
+
try {
|
|
260
|
+
const { freq } = await getSmcMetrics();
|
|
261
|
+
const values = Object.values(freq);
|
|
262
|
+
if (values.length) {
|
|
263
|
+
// powermetrics reports in Hz, system profiler reports in MHz.
|
|
264
|
+
// We need MHz for the display.
|
|
265
|
+
frequency = Math.round(Math.max(...values) / 1_000_000);
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
// ignore — frequency stays 0, meaning "unavailable" rather than wrong
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let usage = 0;
|
|
273
|
+
try {
|
|
274
|
+
const top = (await run('top -l 1 -n 0')).trim();
|
|
275
|
+
// Real `top -l 1 -n 0` output is comma-separated and newline-terminated,
|
|
276
|
+
// e.g. "CPU usage: 5.26% user, 10.52% sys, 84.21% idle" — there is no
|
|
277
|
+
// semicolon anywhere on that line. The old regex required one to
|
|
278
|
+
// terminate the match, so it never matched and usage stayed at 0.
|
|
279
|
+
const cpuLine = top.match(/CPU usage:\s*[\d.]+%\s*user,\s*[\d.]+%\s*sys,\s*([\d.]+)%\s*idle/);
|
|
280
|
+
if (cpuLine) {
|
|
281
|
+
const idle = parseFloat(cpuLine[1]);
|
|
282
|
+
usage = Math.round(100 - idle);
|
|
283
|
+
}
|
|
284
|
+
} catch {
|
|
285
|
+
// ignore
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const loadAvgStr = (await run('sysctl -n vm.loadavg', '0.00 0.00 0.00')).trim();
|
|
289
|
+
const loadAvg = loadAvgStr.match(/\{ (.+?) \}/)?.[1].split(' ').map(Number) || [0, 0, 0];
|
|
290
|
+
|
|
291
|
+
let temperature: number | undefined;
|
|
292
|
+
try {
|
|
293
|
+
const { temps } = await getSmcMetrics();
|
|
294
|
+
if (temps.cpu_die !== undefined) temperature = temps.cpu_die;
|
|
295
|
+
} catch {
|
|
296
|
+
// ignore
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { brand, cores, physicalCores, frequency, usage, loadAvg, temperature };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function collectMemory(): Promise<MemoryData> {
|
|
303
|
+
const totalBytes = parseInt((await run('sysctl -n hw.memsize')).trim()) || 0;
|
|
304
|
+
const pageSize = parseInt((await run('sysctl -n hw.pagesize', '4096')).trim()) || 4096;
|
|
305
|
+
|
|
306
|
+
const vmStat = (await run('vm_stat')).trim();
|
|
307
|
+
const getPageCount = (label: string) => {
|
|
308
|
+
const m = vmStat.match(new RegExp(`Pages\\s+${label}:\\s+(\\d+)\\.`));
|
|
309
|
+
return m ? parseInt(m[1]) : 0;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const freePages = getPageCount('free');
|
|
313
|
+
const activePages = getPageCount('active');
|
|
314
|
+
const inactivePages = getPageCount('inactive');
|
|
315
|
+
const wiredPages = getPageCount('wired down');
|
|
316
|
+
const compressedPages = getPageCount('occupied by compressor');
|
|
317
|
+
|
|
318
|
+
const free = freePages * pageSize;
|
|
319
|
+
const used = (activePages + inactivePages + wiredPages + compressedPages) * pageSize;
|
|
320
|
+
const usagePercent = totalBytes > 0 ? Math.round((used / totalBytes) * 100) : 0;
|
|
321
|
+
|
|
322
|
+
let swapTotal = 0;
|
|
323
|
+
let swapUsed = 0;
|
|
324
|
+
let swapFree = 0;
|
|
325
|
+
try {
|
|
326
|
+
const swapRaw = (await run('sysctl -n vm.swapusage')).trim();
|
|
327
|
+
// Real output is "total = 3072.00M used = 1497.75M free = 1574.25M
|
|
328
|
+
// (encrypted)" — the parentheses wrap the trailing "(encrypted)" note,
|
|
329
|
+
// not the used value. The old regex required a literal "(" right before
|
|
330
|
+
// "used", which never appears, so this never matched and swap stayed at
|
|
331
|
+
// 0 forever instead of updating.
|
|
332
|
+
const swapMatch = swapRaw.match(
|
|
333
|
+
/total\s*=\s*([\d.]+)([KMGkmg])\s+used\s*=\s*([\d.]+)([KMGkmg])\s+free\s*=\s*([\d.]+)([KMGkmg])/
|
|
334
|
+
);
|
|
335
|
+
if (swapMatch) {
|
|
336
|
+
swapTotal = parseFloat(swapMatch[1]) * parseSuffix(swapMatch[2]);
|
|
337
|
+
swapUsed = parseFloat(swapMatch[3]) * parseSuffix(swapMatch[4]);
|
|
338
|
+
swapFree = parseFloat(swapMatch[5]) * parseSuffix(swapMatch[6]);
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
// ignore
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
total: totalBytes,
|
|
346
|
+
used,
|
|
347
|
+
free,
|
|
348
|
+
swapTotal,
|
|
349
|
+
swapUsed,
|
|
350
|
+
swapFree,
|
|
351
|
+
pageSize,
|
|
352
|
+
usagePercent,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export async function collectDisk(): Promise<DiskData[]> {
|
|
357
|
+
const raw = (await run('df -h')).trim();
|
|
358
|
+
const lines = raw.split('\n').slice(1);
|
|
359
|
+
return lines
|
|
360
|
+
.map(line => {
|
|
361
|
+
const parts = line.split(/\s+/);
|
|
362
|
+
return {
|
|
363
|
+
filesystem: parts[0],
|
|
364
|
+
size: parts[1],
|
|
365
|
+
used: parts[2],
|
|
366
|
+
available: parts[3],
|
|
367
|
+
capacity: parts[4],
|
|
368
|
+
mountpoint: parts[5] || parts[6] || '/',
|
|
369
|
+
};
|
|
370
|
+
})
|
|
371
|
+
.filter(d => d.filesystem.includes('/') || d.size.includes('G') || d.size.includes('T'));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function collectBattery(): Promise<BatteryData | null> {
|
|
375
|
+
try {
|
|
376
|
+
const raw = (await run('pmset -g batt')).trim();
|
|
377
|
+
if (!raw.includes('Battery Power') && !raw.includes('AC Power')) return null;
|
|
378
|
+
|
|
379
|
+
const levelMatch = raw.match(/(\d+)%/);
|
|
380
|
+
const stateMatch = raw.match(/;\s*(charging|discharging|charged|finishing charge|attached to charger)/i);
|
|
381
|
+
const timeMatch = raw.match(/:\s*([\d:]+)\s+remaining/i) || raw.match(/(\d+:\d+)\s+remaining/i);
|
|
382
|
+
const powerPlugged = raw.includes('AC Power') || raw.includes('attached to charger');
|
|
383
|
+
|
|
384
|
+
const level = levelMatch ? parseInt(levelMatch[1]) : 0;
|
|
385
|
+
const state = powerPlugged ? 'charged' : (stateMatch ? stateMatch[1].toLowerCase() : 'discharging');
|
|
386
|
+
const timeRemaining = timeMatch ? timeMatch[1] : (powerPlugged ? '∞' : 'calculating');
|
|
387
|
+
|
|
388
|
+
let cycles: number | undefined;
|
|
389
|
+
let condition: string | undefined;
|
|
390
|
+
let maxCapacityPercent: number | undefined;
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const battInfo = (await run('system_profiler SPPowerDataType 2>/dev/null')).trim();
|
|
394
|
+
|
|
395
|
+
const cycleMatch = battInfo.match(/Cycle Count:\s+(\d+)/);
|
|
396
|
+
if (cycleMatch) cycles = parseInt(cycleMatch[1]);
|
|
397
|
+
|
|
398
|
+
const conditionMatch = battInfo.match(/Condition:\s+(.+)/);
|
|
399
|
+
if (conditionMatch) condition = conditionMatch[1].trim();
|
|
400
|
+
|
|
401
|
+
const maxCapMatch = battInfo.match(/Maximum Capacity:\s+(\d+)%/);
|
|
402
|
+
if (maxCapMatch) {
|
|
403
|
+
maxCapacityPercent = parseInt(maxCapMatch[1]);
|
|
404
|
+
} else {
|
|
405
|
+
const fullMatch = battInfo.match(/Full Charge Capacity[^\d]*(\d+)/i);
|
|
406
|
+
const designMatch = battInfo.match(/Design Capacity[^\d]*(\d+)/i);
|
|
407
|
+
if (fullMatch && designMatch) {
|
|
408
|
+
const full = parseInt(fullMatch[1]);
|
|
409
|
+
const design = parseInt(designMatch[1]);
|
|
410
|
+
if (design > 0) maxCapacityPercent = Math.round((full / design) * 100);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
// health stays unknown
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const healthParts: string[] = [];
|
|
418
|
+
if (condition) healthParts.push(condition);
|
|
419
|
+
if (maxCapacityPercent !== undefined) healthParts.push(`${maxCapacityPercent}% capacity`);
|
|
420
|
+
if (cycles !== undefined) healthParts.push(`${cycles} cycles`);
|
|
421
|
+
const health = healthParts.length ? healthParts.join(', ') : 'unknown';
|
|
422
|
+
|
|
423
|
+
const estimate = estimateBatteryLife(level, state);
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
level,
|
|
427
|
+
state,
|
|
428
|
+
timeRemaining,
|
|
429
|
+
health,
|
|
430
|
+
powerSource: powerPlugged ? 'AC' : 'Battery',
|
|
431
|
+
cycles,
|
|
432
|
+
condition,
|
|
433
|
+
maxCapacityPercent,
|
|
434
|
+
...estimate,
|
|
435
|
+
};
|
|
436
|
+
} catch {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function pressureFromState(state: string): number {
|
|
442
|
+
const s = state.toLowerCase();
|
|
443
|
+
if (s.includes('critical')) return 3;
|
|
444
|
+
if (s.includes('serious') || s.includes('heavy')) return 2;
|
|
445
|
+
if (s.includes('fair') || s.includes('moderate')) return 1;
|
|
446
|
+
return 0; // nominal / normal / unknown
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function collectThermal(detailed?: boolean): Promise<ThermalData> {
|
|
450
|
+
try {
|
|
451
|
+
const therm = (await run('pmset -g therm', '')).trim();
|
|
452
|
+
|
|
453
|
+
let state = 'Unknown';
|
|
454
|
+
let detail = therm || 'No pmset thermal data';
|
|
455
|
+
|
|
456
|
+
const stateMatch = therm.match(/Thermal state:\s*(.+)/i);
|
|
457
|
+
|
|
458
|
+
if (stateMatch) {
|
|
459
|
+
const raw = stateMatch[1].trim();
|
|
460
|
+
state = /0|no warning|normal/i.test(raw) ? 'Nominal' : raw;
|
|
461
|
+
detail = state;
|
|
462
|
+
} else {
|
|
463
|
+
const limitMatch = therm.match(
|
|
464
|
+
/CPU_(?:Speed|Scheduler)_Limit\s*(?:=|:)\s*([\d.]+)/i
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
if (limitMatch) {
|
|
468
|
+
const limit = parseFloat(limitMatch[1]);
|
|
469
|
+
|
|
470
|
+
if (limit >= 100) state = 'Nominal';
|
|
471
|
+
else if (limit >= 85) state = 'Fair';
|
|
472
|
+
else if (limit >= 50) state = 'Serious';
|
|
473
|
+
else state = 'Critical';
|
|
474
|
+
|
|
475
|
+
detail = `${state} (CPU limit ${limit}%)`;
|
|
476
|
+
} else if (/no thermal warning level has been recorded/i.test(therm)) {
|
|
477
|
+
// The most common real-world case: pmset hasn't recorded any warning
|
|
478
|
+
// and never emits a CPU_Speed_Limit line at all when nothing has ever
|
|
479
|
+
// throttled. That's not "unknown" — it's the system telling us
|
|
480
|
+
// everything is nominal — but the old code fell through to
|
|
481
|
+
// 'Unknown' here with no other way to recover, since the
|
|
482
|
+
// powermetrics fallback below almost always fails without
|
|
483
|
+
// passwordless sudo configured. That's the bug: thermal state read
|
|
484
|
+
// as "Unknown" on effectively every normal, non-throttling Mac.
|
|
485
|
+
state = 'Nominal';
|
|
486
|
+
detail = 'Nominal (no thermal warning recorded)';
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const temperatures: Record<string, number | null> = {};
|
|
491
|
+
let cpuDieTemp: number | undefined;
|
|
492
|
+
|
|
493
|
+
// Grab the CPU die temp regardless of `detailed` — we need it both for the
|
|
494
|
+
// detailed sensor breakdown and as a state fallback below.
|
|
495
|
+
try {
|
|
496
|
+
const { temps } = await getSmcMetrics();
|
|
497
|
+
cpuDieTemp = temps.cpu_die;
|
|
498
|
+
if (detailed) {
|
|
499
|
+
if (temps.cpu_die !== undefined) temperatures['cpu_die'] = temps.cpu_die;
|
|
500
|
+
if (temps.gpu_die !== undefined) temperatures['gpu_die'] = temps.gpu_die;
|
|
501
|
+
if (temps.smc_die !== undefined) temperatures['smc_die'] = temps.smc_die;
|
|
502
|
+
if (!Object.keys(temps).length) {
|
|
503
|
+
detail += ' (sensor data needs powermetrics privileges)';
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
} catch {
|
|
507
|
+
if (detailed) detail += ' (powermetrics unavailable)';
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Last resort: if we still don't know the state (e.g. pmset gave us nothing
|
|
511
|
+
// usable) but we do have a die temperature, estimate state from that.
|
|
512
|
+
if (state === 'Unknown' && cpuDieTemp !== undefined) {
|
|
513
|
+
if (cpuDieTemp >= 100) state = 'Critical';
|
|
514
|
+
else if (cpuDieTemp >= 90) state = 'Serious';
|
|
515
|
+
else if (cpuDieTemp >= 80) state = 'Fair';
|
|
516
|
+
else state = 'Nominal';
|
|
517
|
+
detail = `${state} (estimated from ${cpuDieTemp}°C)`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
state,
|
|
522
|
+
detail,
|
|
523
|
+
pressureLevel: pressureFromState(state),
|
|
524
|
+
temperatures: Object.keys(temperatures).length ? temperatures : undefined,
|
|
525
|
+
};
|
|
526
|
+
} catch {
|
|
527
|
+
return { state: 'Unknown', pressureLevel: 0, error: 'Thermal info unavailable on this system' };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export async function collectNetwork(): Promise<NetworkData> {
|
|
532
|
+
try {
|
|
533
|
+
const primary = (await run('route -n get default')).trim();
|
|
534
|
+
const ifaceMatch = primary.match(/interface:\s+(\S+)/);
|
|
535
|
+
const iface = ifaceMatch?.[1] || 'en0';
|
|
536
|
+
|
|
537
|
+
let ip = '0.0.0.0';
|
|
538
|
+
try {
|
|
539
|
+
const ifconfig = (await run(`ifconfig ${iface}`)).trim();
|
|
540
|
+
const ipMatch = ifconfig.match(/inet\s+([\d.]+)\s+netmask/);
|
|
541
|
+
if (ipMatch) ip = ipMatch[1];
|
|
542
|
+
} catch {
|
|
543
|
+
// ignore
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const netStat = (await run('netstat -ib')).trim();
|
|
547
|
+
let rxBytes = 0;
|
|
548
|
+
let txBytes = 0;
|
|
549
|
+
let rxPackets = 0;
|
|
550
|
+
let txPackets = 0;
|
|
551
|
+
|
|
552
|
+
for (const line of netStat.split('\n')) {
|
|
553
|
+
if (line.startsWith(iface) || line.startsWith(iface + ':')) {
|
|
554
|
+
const parts = line.split(/\s+/);
|
|
555
|
+
if (parts.length >= 9) {
|
|
556
|
+
rxPackets += parseInt(parts[4]) || 0;
|
|
557
|
+
rxBytes += parseInt(parts[5]) || 0;
|
|
558
|
+
txPackets += parseInt(parts[6]) || 0;
|
|
559
|
+
txBytes += parseInt(parts[7]) || 0;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return { interface: iface, ip, rxBytes, txBytes, rxPackets, txPackets };
|
|
565
|
+
} catch {
|
|
566
|
+
return { interface: 'unknown', ip: '0.0.0.0', rxBytes: 0, txBytes: 0, rxPackets: 0, txPackets: 0 };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export async function collectProcesses(limit?: number): Promise<ProcessData[]> {
|
|
571
|
+
try {
|
|
572
|
+
const isMac = process.platform === 'darwin';
|
|
573
|
+
const sortArg = isMac ? '-r' : '--sort=-pcpu';
|
|
574
|
+
const threadsArg = isMac ? 'threads' : 'nlwp';
|
|
575
|
+
const headClause = limit && limit > 0 ? ` | head -n ${limit + 1}` : '';
|
|
576
|
+
const raw = (await run(`ps -eo pid,ppid,user,pcpu,pmem,state,${threadsArg},time,comm ${sortArg}${headClause}`)).trim();
|
|
577
|
+
const lines = raw.split('\n').slice(1);
|
|
578
|
+
return lines
|
|
579
|
+
.map(line => {
|
|
580
|
+
const parts = line.match(/\s*(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(\d+)\s+([\d:]+)\s+(.+)/);
|
|
581
|
+
if (!parts) return null;
|
|
582
|
+
const runtimeSec = parts[8].split(':').reduce((acc, val, idx) => acc + parseInt(val) * Math.pow(60, 2 - idx), 0);
|
|
583
|
+
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] };
|
|
584
|
+
})
|
|
585
|
+
.filter((p): p is ProcessData => p !== null && p.pid > 0);
|
|
586
|
+
} catch {
|
|
587
|
+
return [];
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export async function collectPackets(): Promise<PacketData | null> {
|
|
592
|
+
try {
|
|
593
|
+
const netStat = (await run('netstat -ib')).trim();
|
|
594
|
+
const lines = netStat.split('\n').slice(1);
|
|
595
|
+
let totalRxPackets = 0;
|
|
596
|
+
let totalTxPackets = 0;
|
|
597
|
+
let totalRxBytes = 0;
|
|
598
|
+
let totalTxBytes = 0;
|
|
599
|
+
const ifaceStats: { iface: string; rxPackets: number; txPackets: number; rxBytes: number; txBytes: number }[] = [];
|
|
600
|
+
|
|
601
|
+
for (const line of lines) {
|
|
602
|
+
const parts = line.split(/\s+/);
|
|
603
|
+
if (parts.length >= 11 && parts[0] && !parts[0].startsWith('Name')) {
|
|
604
|
+
const iface = parts[0];
|
|
605
|
+
const rxPackets = parseInt(parts[4]) || 0;
|
|
606
|
+
const rxBytes = parseInt(parts[5]) || 0;
|
|
607
|
+
const txPackets = parseInt(parts[6]) || 0;
|
|
608
|
+
const txBytes = parseInt(parts[7]) || 0;
|
|
609
|
+
totalRxPackets += rxPackets;
|
|
610
|
+
totalTxPackets += txPackets;
|
|
611
|
+
totalRxBytes += rxBytes;
|
|
612
|
+
totalTxBytes += txBytes;
|
|
613
|
+
if (iface && !iface.includes('lo')) {
|
|
614
|
+
ifaceStats.push({ iface, rxPackets, txPackets, rxBytes, txBytes });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
let connections = 0;
|
|
620
|
+
try {
|
|
621
|
+
const tcpConns = (await run('netstat -an | grep ESTABLISHED | wc -l', '0')).trim();
|
|
622
|
+
connections = parseInt(tcpConns) || 0;
|
|
623
|
+
} catch {
|
|
624
|
+
connections = 0;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let topProcesses: { pid: number; command: string; rxBytes: number; txBytes: number }[] = [];
|
|
628
|
+
let allProcesses: { pid: number; command: string; rxBytes: number; txBytes: number; rxPackets?: number; txPackets?: number }[] = [];
|
|
629
|
+
try {
|
|
630
|
+
const lsof = (await run('lsof -i -n -P -r 1 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '')).trim();
|
|
631
|
+
const lsofLines = lsof.split('\n');
|
|
632
|
+
topProcesses = lsofLines.map(line => {
|
|
633
|
+
const parts = line.trim().split(/\s+/);
|
|
634
|
+
if (parts.length >= 3) {
|
|
635
|
+
return { pid: parseInt(parts[2]) || 0, command: parts[1], rxBytes: 0, txBytes: parseInt(parts[0]) || 0 };
|
|
636
|
+
}
|
|
637
|
+
return { pid: 0, command: '', rxBytes: 0, txBytes: 0 };
|
|
638
|
+
}).filter(p => p.pid > 0);
|
|
639
|
+
|
|
640
|
+
const allLsof = (await run('lsof -i -n -P 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '')).trim();
|
|
641
|
+
const allLsofLines = allLsof.split('\n');
|
|
642
|
+
allProcesses = allLsofLines.map(line => {
|
|
643
|
+
const parts = line.trim().split(/\s+/);
|
|
644
|
+
if (parts.length >= 3) {
|
|
645
|
+
return { pid: parseInt(parts[2]) || 0, command: parts[1], rxBytes: 0, txBytes: parseInt(parts[0]) || 0 };
|
|
646
|
+
}
|
|
647
|
+
return { pid: 0, command: '', rxBytes: 0, txBytes: 0 };
|
|
648
|
+
}).filter(p => p.pid > 0);
|
|
649
|
+
} catch {
|
|
650
|
+
topProcesses = [];
|
|
651
|
+
allProcesses = [];
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
totalPackets: totalRxPackets + totalTxPackets,
|
|
656
|
+
rxPackets: totalRxPackets,
|
|
657
|
+
txPackets: totalTxPackets,
|
|
658
|
+
rxRate: 0,
|
|
659
|
+
txRate: 0,
|
|
660
|
+
connections,
|
|
661
|
+
topProcesses,
|
|
662
|
+
interfaces: ifaceStats,
|
|
663
|
+
allProcesses,
|
|
664
|
+
};
|
|
665
|
+
} catch {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export async function collectTasks(limit = 12): Promise<TaskData[]> {
|
|
671
|
+
try {
|
|
672
|
+
const isMac = process.platform === 'darwin';
|
|
673
|
+
const sortArg = isMac ? '-r' : '--sort=-pcpu';
|
|
674
|
+
const raw = (await run(`ps -eo pid,user,pcpu,pmem,state,time,comm ${sortArg} | head -n ${limit + 1}`)).trim();
|
|
675
|
+
const lines = raw.split('\n').slice(1);
|
|
676
|
+
return lines
|
|
677
|
+
.map(line => {
|
|
678
|
+
const parts = line.match(/\s*(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+([\d:]+)\s+(.+)/);
|
|
679
|
+
if (!parts) return null;
|
|
680
|
+
const runtimeSec = parts[6].split(':').reduce((acc, val, idx) => acc + parseInt(val) * Math.pow(60, 2 - idx), 0);
|
|
681
|
+
return { pid: parseInt(parts[1]), user: parts[2], cpu: parseFloat(parts[3]), mem: parseFloat(parts[4]), state: parts[5], runtime: runtimeSec, command: parts[7] };
|
|
682
|
+
})
|
|
683
|
+
.filter((t): t is TaskData => t !== null && t.pid > 0);
|
|
684
|
+
} catch {
|
|
685
|
+
return [];
|
|
686
|
+
}
|
|
687
|
+
}
|
|
@@ -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, collectPackets, collectTasks } from './collectors.js';
|
|
14
|
+
export type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData, PacketData, NetworkProcess, TaskData } from './types.js';
|