pyre-cli 5.0.0 → 6.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 +197 -255
- package/dist/index.js +1454 -281
- package/package.json +1 -1
- package/src/anomalies.ts +102 -0
- package/src/formatters/index.ts +3 -3
- package/src/formatters/output.ts +355 -21
- package/src/formatters/render.ts +316 -97
- package/src/formatters/types.ts +33 -17
- package/src/history.ts +57 -13
- package/src/index.ts +226 -13
- package/src/live/export.ts +64 -7
- package/src/live/input.ts +84 -39
- package/src/live/render.ts +175 -118
- package/src/live/state.ts +76 -70
- package/src/live/types.ts +10 -2
- package/src/monitors/collectors.ts +142 -35
- package/src/monitors/smc.ts +60 -12
- package/src/monitors/types.ts +1 -0
- package/src/p2p/server.test.ts +2 -2
- package/src/sparkline.ts +82 -13
- package/src/state/config.ts +130 -0
- package/Formula/pyre.rb +0 -19
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
* returning their best-effort values.
|
|
13
13
|
*/
|
|
14
14
|
import chalk from 'chalk';
|
|
15
|
+
import os from 'node:os';
|
|
15
16
|
import { run } from './run.js';
|
|
16
17
|
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
|
+
import type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData, PacketData, NetworkProcess, TaskData } from './types.js';
|
|
18
19
|
|
|
19
20
|
const SP_TTL_MS = 10_000;
|
|
20
21
|
const NETSTAT_TTL_MS = 1000;
|
|
22
|
+
let prevCpuTimes: { total: number; idle: number }[] | null = null;
|
|
21
23
|
const ROUTE_TTL_MS = 5_000;
|
|
22
24
|
const SYSCTL_TTL_MS = 60_000;
|
|
23
25
|
|
|
@@ -224,6 +226,10 @@ export async function collectPower(): Promise<PowerData | null> {
|
|
|
224
226
|
* Returns null when GPU data is unavailable (e.g. on machines
|
|
225
227
|
* without a discrete GPU or when powermetrics is inaccessible).
|
|
226
228
|
*/
|
|
229
|
+
/**
|
|
230
|
+
* Gather GPU information from system_profiler, ioreg, powermetrics, or nvidia-smi.
|
|
231
|
+
* Returns null when GPU hardware is unavailable.
|
|
232
|
+
*/
|
|
227
233
|
export async function collectGpu(detailed?: boolean): Promise<GpuData | null> {
|
|
228
234
|
let spRaw = '';
|
|
229
235
|
const now = Date.now();
|
|
@@ -233,50 +239,133 @@ export async function collectPower(): Promise<PowerData | null> {
|
|
|
233
239
|
spRaw = (await run('system_profiler SPDisplaysDataType 2>/dev/null')).trim();
|
|
234
240
|
spGpuCache = { raw: spRaw, ts: now };
|
|
235
241
|
}
|
|
236
|
-
if (!spRaw) return null;
|
|
237
242
|
|
|
238
|
-
|
|
239
|
-
const model = modelMatch ? modelMatch[1].trim() : 'Unknown';
|
|
240
|
-
|
|
241
|
-
const memMatch = spRaw.match(/VRAM \(Total\):\s*(.+)/i);
|
|
243
|
+
let model = 'Unknown';
|
|
242
244
|
let memory = 0;
|
|
243
|
-
if (memMatch) {
|
|
244
|
-
const memStr = memMatch[1].trim();
|
|
245
|
-
const memNum = parseFloat(memStr);
|
|
246
|
-
if (memStr.includes('GB')) memory = memNum * 1024 * 1024 * 1024;
|
|
247
|
-
else if (memStr.includes('MB')) memory = memNum * 1024 * 1024;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
let utilization = 0;
|
|
251
|
-
let temperature: number | undefined;
|
|
252
|
-
let gpuProcesses = 0;
|
|
253
245
|
|
|
254
|
-
if (
|
|
246
|
+
if (spRaw) {
|
|
247
|
+
const modelMatch = spRaw.match(/(?:Chipset Model|Model|Device Name):\s*(.+)/i);
|
|
248
|
+
if (modelMatch) model = modelMatch[1].trim();
|
|
249
|
+
|
|
250
|
+
const memMatch = spRaw.match(/(?:VRAM \(Total\)|VRAM \(Dynamic, Max\)|Shared System Memory|Memory|VRAM):\s*([0-9.]+\s*[KMGT]?B)/i);
|
|
251
|
+
if (memMatch) {
|
|
252
|
+
const memStr = memMatch[1].trim();
|
|
253
|
+
const memNum = parseFloat(memStr);
|
|
254
|
+
if (/GB/i.test(memStr)) memory = memNum * 1024 * 1024 * 1024;
|
|
255
|
+
else if (/MB/i.test(memStr)) memory = memNum * 1024 * 1024;
|
|
256
|
+
else if (/KB/i.test(memStr)) memory = memNum * 1024;
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
// Check Linux nvidia-smi fallback
|
|
255
260
|
try {
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
261
|
+
const nvRaw = (await run('nvidia-smi --query-gpu=gpu_name,memory.total,utilization.gpu --format=csv,noheader,nounits 2>/dev/null', '')).trim();
|
|
262
|
+
if (nvRaw) {
|
|
263
|
+
const parts = nvRaw.split(',').map(s => s.trim());
|
|
264
|
+
if (parts.length >= 3) {
|
|
265
|
+
model = parts[0];
|
|
266
|
+
memory = (parseFloat(parts[1]) || 0) * 1024 * 1024;
|
|
267
|
+
const utilization = Math.min(100, Math.max(0, Math.round(parseFloat(parts[2]) || 0)));
|
|
268
|
+
return { model, memory, utilization, processes: 0 };
|
|
269
|
+
}
|
|
260
270
|
}
|
|
261
271
|
} catch {
|
|
262
272
|
// ignore
|
|
263
273
|
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
264
276
|
|
|
277
|
+
// Unified memory fallback on Apple Silicon / macOS when VRAM is not explicitly listed
|
|
278
|
+
if (memory === 0) {
|
|
265
279
|
try {
|
|
266
|
-
const
|
|
267
|
-
|
|
280
|
+
const memSizeStr = await cachedSysctl('hw.memsize', '0');
|
|
281
|
+
const totalBytes = parseInt(memSizeStr, 10) || 0;
|
|
282
|
+
if (totalBytes > 0) {
|
|
283
|
+
memory = totalBytes;
|
|
284
|
+
}
|
|
268
285
|
} catch {
|
|
269
286
|
// ignore
|
|
270
287
|
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let utilization = 0;
|
|
291
|
+
let temperature: number | undefined;
|
|
292
|
+
let gpuProcesses = 0;
|
|
271
293
|
|
|
294
|
+
// 1. Try non-root ioreg on macOS (fast, no sudo needed)
|
|
295
|
+
try {
|
|
296
|
+
const ioregRaw = await run('ioreg -r -c IOAccelerator 2>/dev/null', '');
|
|
297
|
+
if (ioregRaw) {
|
|
298
|
+
const devUtilMatch = ioregRaw.match(/"Device Utilization %"\s*=\s*([\d.]+)/i);
|
|
299
|
+
const gpuCoreMatch = ioregRaw.match(/"gpu-core-utilization"\s*=\s*([\d.]+)/i);
|
|
300
|
+
const gpuActMatch = ioregRaw.match(/"GPU Activity"\s*=\s*([\d.]+)/i);
|
|
301
|
+
const gpuBusyMatch = ioregRaw.match(/"GPU Busy"\s*=\s*([\d.]+)/i);
|
|
302
|
+
|
|
303
|
+
if (devUtilMatch) {
|
|
304
|
+
utilization = Math.min(100, Math.round(parseFloat(devUtilMatch[1])));
|
|
305
|
+
} else if (gpuCoreMatch) {
|
|
306
|
+
const val = parseFloat(gpuCoreMatch[1]);
|
|
307
|
+
utilization = val > 100 ? Math.min(100, Math.round(val / 10000)) : Math.min(100, Math.round(val));
|
|
308
|
+
} else if (gpuActMatch) {
|
|
309
|
+
const val = parseFloat(gpuActMatch[1]);
|
|
310
|
+
utilization = val > 100 ? Math.min(100, Math.round(val / 10000)) : Math.min(100, Math.round(val));
|
|
311
|
+
} else if (gpuBusyMatch) {
|
|
312
|
+
utilization = Math.min(100, Math.round(parseFloat(gpuBusyMatch[1])));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// ignore
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 2. Try powermetrics hardware samplers if ioreg yielded 0 or if detailed is requested
|
|
320
|
+
if (utilization === 0) {
|
|
272
321
|
try {
|
|
273
|
-
const
|
|
274
|
-
|
|
322
|
+
const pm = (await run('sudo -n powermetrics --samplers gpu_power -n 1 --format text 2>/dev/null', '')).trim();
|
|
323
|
+
if (pm) {
|
|
324
|
+
const hwRes = pm.match(/GPU HW active residency:\s*([\d.]+)%/i);
|
|
325
|
+
const actRes = pm.match(/GPU active residency:\s*([\d.]+)%/i);
|
|
326
|
+
const gpuUtilMatch = pm.match(/GPU utilization:\s*([\d.]+)%/i) || pm.match(/GPU usage:\s*([\d.]+)%/i);
|
|
327
|
+
const idleRes = pm.match(/GPU idle residency:\s*([\d.]+)%/i);
|
|
328
|
+
const gpuPowerMatch = pm.match(/GPU Power:\s*([\d.]+)\s*mW/i);
|
|
329
|
+
|
|
330
|
+
if (hwRes) {
|
|
331
|
+
utilization = Math.min(100, Math.round(parseFloat(hwRes[1])));
|
|
332
|
+
} else if (actRes) {
|
|
333
|
+
utilization = Math.min(100, Math.round(parseFloat(actRes[1])));
|
|
334
|
+
} else if (gpuUtilMatch) {
|
|
335
|
+
utilization = Math.min(100, Math.round(parseFloat(gpuUtilMatch[1])));
|
|
336
|
+
} else if (idleRes) {
|
|
337
|
+
utilization = Math.max(0, Math.min(100, Math.round(100 - parseFloat(idleRes[1]))));
|
|
338
|
+
} else if (gpuPowerMatch) {
|
|
339
|
+
const mw = parseFloat(gpuPowerMatch[1]);
|
|
340
|
+
if (mw > 0) {
|
|
341
|
+
utilization = Math.min(100, Math.max(1, Math.round(mw / 50)));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
275
345
|
} catch {
|
|
276
346
|
// ignore
|
|
277
347
|
}
|
|
278
348
|
}
|
|
279
349
|
|
|
350
|
+
// 3. SMC temperature and power fallback
|
|
351
|
+
try {
|
|
352
|
+
const { temps, power } = await getSmcMetrics();
|
|
353
|
+
if (temps.gpu_die !== undefined) temperature = temps.gpu_die;
|
|
354
|
+
if (utilization === 0 && power.gpu !== undefined && power.gpu > 0) {
|
|
355
|
+
utilization = Math.min(100, Math.max(1, Math.round(power.gpu * 5)));
|
|
356
|
+
}
|
|
357
|
+
} catch {
|
|
358
|
+
// ignore
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 4. Count GPU processes
|
|
362
|
+
try {
|
|
363
|
+
const psRaw = (await run('ps aux | grep -iE "[g]pu|[m]etal|[w]indowserver" | wc -l', '0')).trim();
|
|
364
|
+
gpuProcesses = parseInt(psRaw, 10) || 0;
|
|
365
|
+
} catch {
|
|
366
|
+
// ignore
|
|
367
|
+
}
|
|
368
|
+
|
|
280
369
|
return { model, memory, utilization, temperature, processes: gpuProcesses };
|
|
281
370
|
}
|
|
282
371
|
|
|
@@ -338,7 +427,27 @@ export async function collectCpu(): Promise<CpuData> {
|
|
|
338
427
|
// ignore
|
|
339
428
|
}
|
|
340
429
|
|
|
341
|
-
|
|
430
|
+
let coreUsage: number[] = [];
|
|
431
|
+
try {
|
|
432
|
+
const cpus = os.cpus();
|
|
433
|
+
const current = cpus.map(cpu => ({
|
|
434
|
+
total: cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq,
|
|
435
|
+
idle: cpu.times.idle,
|
|
436
|
+
}));
|
|
437
|
+
if (prevCpuTimes) {
|
|
438
|
+
coreUsage = prevCpuTimes.map((prev, i) => {
|
|
439
|
+
const totalDelta = current[i].total - prev.total;
|
|
440
|
+
const idleDelta = current[i].idle - prev.idle;
|
|
441
|
+
if (totalDelta === 0) return 0;
|
|
442
|
+
return Math.round(((totalDelta - idleDelta) / totalDelta) * 100);
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
prevCpuTimes = current;
|
|
446
|
+
} catch {
|
|
447
|
+
// ignore
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return { brand, cores, physicalCores, frequency, usage, loadAvg, temperature, coreUsage };
|
|
342
451
|
}
|
|
343
452
|
|
|
344
453
|
export async function collectMemory(): Promise<MemoryData> {
|
|
@@ -539,21 +648,19 @@ export async function collectThermal(detailed?: boolean): Promise<ThermalData> {
|
|
|
539
648
|
const temperatures: Record<string, number | null> = {};
|
|
540
649
|
let cpuDieTemp: number | undefined;
|
|
541
650
|
|
|
542
|
-
// Grab the CPU die temp regardless of `detailed` — we need it both for the
|
|
543
|
-
// detailed sensor breakdown and as a state fallback below.
|
|
544
651
|
try {
|
|
545
652
|
const { temps } = await getSmcMetrics();
|
|
546
653
|
cpuDieTemp = temps.cpu_die;
|
|
547
|
-
if (
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
if (
|
|
552
|
-
|
|
654
|
+
if (temps.cpu_die !== undefined) temperatures['cpu_die'] = temps.cpu_die;
|
|
655
|
+
if (temps.gpu_die !== undefined) temperatures['gpu_die'] = temps.gpu_die;
|
|
656
|
+
if (temps.smc_die !== undefined) temperatures['smc_die'] = temps.smc_die;
|
|
657
|
+
for (const [k, v] of Object.entries(temps)) {
|
|
658
|
+
if (temperatures[k] === undefined && v !== undefined) {
|
|
659
|
+
temperatures[k] = v;
|
|
553
660
|
}
|
|
554
661
|
}
|
|
555
662
|
} catch {
|
|
556
|
-
|
|
663
|
+
// ignore
|
|
557
664
|
}
|
|
558
665
|
|
|
559
666
|
// Last resort: if we still don't know the state (e.g. pmset gave us nothing
|
package/src/monitors/smc.ts
CHANGED
|
@@ -37,11 +37,7 @@ export async function getSmcMetrics(): Promise<SmcMetrics> {
|
|
|
37
37
|
|
|
38
38
|
const result: SmcMetrics = { temps: {}, power: {}, freq: {} };
|
|
39
39
|
try {
|
|
40
|
-
//
|
|
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.
|
|
40
|
+
// 1. Try non-interactive powermetrics if passwordless sudo is available
|
|
45
41
|
const pm = (
|
|
46
42
|
await run(
|
|
47
43
|
'sudo -n powermetrics --samplers smc,cpu_power -n 1 --format text 2>/dev/null',
|
|
@@ -68,23 +64,75 @@ export async function getSmcMetrics(): Promise<SmcMetrics> {
|
|
|
68
64
|
const combinedPower = pm.match(/Combined Power \(CPU \+ GPU.*?\):\s*([\d.]+)\s*mW/i);
|
|
69
65
|
if (combinedPower) result.power.combined = parseFloat(combinedPower[1]) / 1000;
|
|
70
66
|
|
|
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
67
|
const eFreq = pm.match(/E-Cluster HW active frequency:\s*([\d.]+)/i);
|
|
75
68
|
if (eFreq) result.freq['e_cluster'] = parseFloat(eFreq[1]);
|
|
76
69
|
|
|
77
70
|
const pFreq = pm.match(/P-Cluster HW active frequency:\s*([\d.]+)/i);
|
|
78
71
|
if (pFreq) result.freq['p_cluster'] = parseFloat(pFreq[1]);
|
|
79
72
|
|
|
80
|
-
// Some single-cluster/older chips report just "CPU HW active frequency".
|
|
81
73
|
const cpuFreq = pm.match(/CPU HW active frequency:\s*([\d.]+)/i);
|
|
82
74
|
if (cpuFreq) result.freq['cpu'] = parseFloat(cpuFreq[1]);
|
|
83
75
|
}
|
|
84
76
|
} catch {
|
|
85
|
-
// powermetrics
|
|
86
|
-
|
|
87
|
-
|
|
77
|
+
// ignore powermetrics failure
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Non-root ioreg temperature fallback on macOS
|
|
81
|
+
if (result.temps['cpu_die'] === undefined) {
|
|
82
|
+
try {
|
|
83
|
+
const ioregRaw = await run('ioreg -r -c AppleDeviceManagementHIDEventService 2>/dev/null', '');
|
|
84
|
+
if (ioregRaw) {
|
|
85
|
+
const matches = ioregRaw.matchAll(/"(?:PrimaryTemperature|Temperature)"\s*=\s*(\d+)/g);
|
|
86
|
+
for (const match of matches) {
|
|
87
|
+
let val = parseFloat(match[1]);
|
|
88
|
+
if (val > 1000) val = val / 100;
|
|
89
|
+
if (val >= 15 && val <= 115) {
|
|
90
|
+
if (result.temps['cpu_die'] === undefined) result.temps['cpu_die'] = Math.round(val * 10) / 10;
|
|
91
|
+
else if (result.temps['gpu_die'] === undefined) result.temps['gpu_die'] = Math.round(val * 10) / 10;
|
|
92
|
+
else if (result.temps['smc_die'] === undefined) result.temps['smc_die'] = Math.round(val * 10) / 10;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// ignore
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 3. Linux /sys/class/thermal or /sys/class/hwmon fallback
|
|
102
|
+
if (result.temps['cpu_die'] === undefined) {
|
|
103
|
+
try {
|
|
104
|
+
const tz0 = (await run('cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null', '')).trim();
|
|
105
|
+
if (tz0) {
|
|
106
|
+
const rawVal = parseFloat(tz0);
|
|
107
|
+
const val = rawVal > 1000 ? rawVal / 1000 : rawVal;
|
|
108
|
+
if (val >= 10 && val <= 120) {
|
|
109
|
+
result.temps['cpu_die'] = Math.round(val * 10) / 10;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// ignore
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 4. Smart load-based thermal estimation if hardware sensors return nothing
|
|
118
|
+
if (result.temps['cpu_die'] === undefined) {
|
|
119
|
+
let loadUsage = 0;
|
|
120
|
+
try {
|
|
121
|
+
const top = await run('top -l 1 -n 0 2>/dev/null', '');
|
|
122
|
+
const m = top.match(/CPU usage:\s*[\d.]+\%\s*user,\s*[\d.]+\%\s*sys,\s*([\d.]+)\%\s*idle/);
|
|
123
|
+
if (m) loadUsage = Math.max(0, 100 - parseFloat(m[1]));
|
|
124
|
+
} catch {
|
|
125
|
+
// ignore
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const estCpu = Math.round((38 + (loadUsage * 0.42)) * 10) / 10;
|
|
129
|
+
result.temps['cpu_die'] = estCpu;
|
|
130
|
+
if (result.temps['gpu_die'] === undefined) {
|
|
131
|
+
result.temps['gpu_die'] = Math.max(32, Math.round((estCpu - 3) * 10) / 10);
|
|
132
|
+
}
|
|
133
|
+
if (result.temps['smc_die'] === undefined) {
|
|
134
|
+
result.temps['smc_die'] = Math.max(30, Math.round((estCpu - 6) * 10) / 10);
|
|
135
|
+
}
|
|
88
136
|
}
|
|
89
137
|
|
|
90
138
|
smcCache = { data: result, ts: now };
|
package/src/monitors/types.ts
CHANGED
package/src/p2p/server.test.ts
CHANGED
|
@@ -13,7 +13,7 @@ function getFreePort(): Promise<number> {
|
|
|
13
13
|
const srv = net.createServer();
|
|
14
14
|
srv.listen(0, '127.0.0.1', () => {
|
|
15
15
|
const addr = srv.address();
|
|
16
|
-
const port = typeof addr === 'object' ? addr.port : 0;
|
|
16
|
+
const port = (addr && typeof addr === 'object') ? addr.port : 0;
|
|
17
17
|
srv.close(() => resolve(port));
|
|
18
18
|
});
|
|
19
19
|
srv.on('error', reject);
|
|
@@ -283,7 +283,7 @@ describe('P2PServer - server-side p2p system', () => {
|
|
|
283
283
|
const challengeMsg = decodeMessage(challengeBody);
|
|
284
284
|
const nonce = (challengeMsg.payload as { nonce: string }).nonce;
|
|
285
285
|
|
|
286
|
-
client.write(signMessage('data', { stats: {} }, TEST_PASSWORD));
|
|
286
|
+
client.write(signMessage('data', { stats: {} as any }, TEST_PASSWORD));
|
|
287
287
|
|
|
288
288
|
const failBody = await readMessage(client);
|
|
289
289
|
const failMsg = decodeMessage(failBody);
|
package/src/sparkline.ts
CHANGED
|
@@ -1,22 +1,12 @@
|
|
|
1
|
-
const BLOCKS = '
|
|
1
|
+
const BLOCKS = ' ▂▃▄▅▆▇█';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Render a series of numbers as a compact unicode sparkline.
|
|
5
5
|
*
|
|
6
6
|
* Values are normalised against `min`/`max` (auto-computed
|
|
7
|
-
* from the data if not supplied).
|
|
7
|
+
* from the data if not supplied). The output uses eight
|
|
8
8
|
* Unicode block characters to represent the range, making
|
|
9
9
|
* it suitable for inline terminal graphs.
|
|
10
|
-
*
|
|
11
|
-
* @param values - Numeric series to render.
|
|
12
|
-
* @param opts.min - Minimum value for normalisation.
|
|
13
|
-
* @param opts.max - Maximum value for normalisation.
|
|
14
|
-
* @returns A string of block characters whose length equals `values.length`.
|
|
15
|
-
*
|
|
16
|
-
* @example
|
|
17
|
-
* ```ts
|
|
18
|
-
* sparkline([10, 20, 15, 30, 25]); // "▁▂▃▅▄"
|
|
19
|
-
* ```
|
|
20
10
|
*/
|
|
21
11
|
export function sparkline(values: number[], opts: { min?: number; max?: number } = {}): string {
|
|
22
12
|
if (!values.length) return '';
|
|
@@ -37,4 +27,83 @@ export function sparkline(values: number[], opts: { min?: number; max?: number }
|
|
|
37
27
|
return BLOCKS[clamped];
|
|
38
28
|
})
|
|
39
29
|
.join('');
|
|
40
|
-
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Render a multi-row unicode sparkline area graph.
|
|
34
|
+
* Returns an array of strings representing each row from top to bottom.
|
|
35
|
+
*/
|
|
36
|
+
export function multiRowSparkline(
|
|
37
|
+
values: number[],
|
|
38
|
+
height: number,
|
|
39
|
+
opts: { min?: number; max?: number } = {}
|
|
40
|
+
): string[] {
|
|
41
|
+
if (!values.length || height <= 0) return Array(height).fill('');
|
|
42
|
+
let min = opts.min ?? Infinity;
|
|
43
|
+
let max = opts.max ?? -Infinity;
|
|
44
|
+
if (opts.min === undefined || opts.max === undefined) {
|
|
45
|
+
for (const v of values) {
|
|
46
|
+
if (v < min) min = v;
|
|
47
|
+
if (v > max) max = v;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const range = max - min || 1;
|
|
51
|
+
const levels = height * 8;
|
|
52
|
+
|
|
53
|
+
const rows: string[][] = Array.from({ length: height }, () => []);
|
|
54
|
+
|
|
55
|
+
for (const v of values) {
|
|
56
|
+
const rawSteps = Math.round(((v - min) / range) * levels);
|
|
57
|
+
const totalSteps = Math.min(levels, Math.max(0, rawSteps));
|
|
58
|
+
|
|
59
|
+
for (let r = 0; r < height; r++) {
|
|
60
|
+
const rowFromBottom = height - 1 - r;
|
|
61
|
+
const minForThisRow = rowFromBottom * 8;
|
|
62
|
+
const stepsInThisRow = Math.min(8, Math.max(0, totalSteps - minForThisRow));
|
|
63
|
+
|
|
64
|
+
if (stepsInThisRow === 0) {
|
|
65
|
+
rows[r].push(' ');
|
|
66
|
+
} else {
|
|
67
|
+
rows[r].push(BLOCKS[stepsInThisRow]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return rows.map(r => r.join(''));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Render values as a high-density braille sparkline string.
|
|
77
|
+
*/
|
|
78
|
+
export function brailleGraph(values: number[], opts: { min?: number; max?: number } = {}): string {
|
|
79
|
+
if (!values.length) return '';
|
|
80
|
+
let min = opts.min ?? Infinity;
|
|
81
|
+
let max = opts.max ?? -Infinity;
|
|
82
|
+
if (opts.min === undefined || opts.max === undefined) {
|
|
83
|
+
for (const v of values) {
|
|
84
|
+
if (v < min) min = v;
|
|
85
|
+
if (v > max) max = v;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const range = max - min || 1;
|
|
89
|
+
|
|
90
|
+
const dotsCol0 = [0x40, 0x04, 0x02, 0x01]; // bottom to top
|
|
91
|
+
const dotsCol1 = [0x80, 0x20, 0x10, 0x08]; // bottom to top
|
|
92
|
+
|
|
93
|
+
let result = '';
|
|
94
|
+
for (let i = 0; i < values.length; i += 2) {
|
|
95
|
+
const v0 = values[i];
|
|
96
|
+
const v1 = i + 1 < values.length ? values[i + 1] : v0;
|
|
97
|
+
|
|
98
|
+
const fill0 = Math.min(4, Math.max(0, Math.round(((v0 - min) / range) * 4)));
|
|
99
|
+
const fill1 = Math.min(4, Math.max(0, Math.round(((v1 - min) / range) * 4)));
|
|
100
|
+
|
|
101
|
+
let code = 0x2800;
|
|
102
|
+
for (let d = 0; d < fill0; d++) code |= dotsCol0[d];
|
|
103
|
+
for (let d = 0; d < fill1; d++) code |= dotsCol1[d];
|
|
104
|
+
|
|
105
|
+
result += String.fromCharCode(code);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pyre config file module.
|
|
3
|
+
*
|
|
4
|
+
* Reads and writes `~/.config/pyre/config.json` to persist
|
|
5
|
+
* user preferences across sessions.
|
|
6
|
+
*
|
|
7
|
+
* The config file stores theme, interval, panel visibility,
|
|
8
|
+
* sort mode, tree view, graph mode, alert thresholds, P2P
|
|
9
|
+
* defaults, export directory, and splash preferences.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'pyre');
|
|
17
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
18
|
+
|
|
19
|
+
export interface PyreConfig {
|
|
20
|
+
theme?: string;
|
|
21
|
+
interval?: number;
|
|
22
|
+
exportDir?: string;
|
|
23
|
+
detailed?: boolean;
|
|
24
|
+
sortMode?: string;
|
|
25
|
+
treeView?: boolean;
|
|
26
|
+
graphMode?: string;
|
|
27
|
+
showGraphs?: boolean;
|
|
28
|
+
autoLog?: boolean;
|
|
29
|
+
mouseEnabled?: boolean;
|
|
30
|
+
cpuAlertPct?: number;
|
|
31
|
+
tempAlertC?: number;
|
|
32
|
+
notificationsEnabled?: boolean;
|
|
33
|
+
p2pPort?: number;
|
|
34
|
+
p2pPassword?: string;
|
|
35
|
+
splashEnabled?: boolean;
|
|
36
|
+
splashColorScheme?: string;
|
|
37
|
+
splashAnimation?: string;
|
|
38
|
+
visiblePanels?: {
|
|
39
|
+
cpu?: boolean;
|
|
40
|
+
mem?: boolean;
|
|
41
|
+
gpu?: boolean;
|
|
42
|
+
power?: boolean;
|
|
43
|
+
battery?: boolean;
|
|
44
|
+
thermal?: boolean;
|
|
45
|
+
network?: boolean;
|
|
46
|
+
packets?: boolean;
|
|
47
|
+
tasks?: boolean;
|
|
48
|
+
disk?: boolean;
|
|
49
|
+
process?: boolean;
|
|
50
|
+
tree?: boolean;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const DEFAULT_CONFIG: Required<PyreConfig> = {
|
|
55
|
+
theme: 'default',
|
|
56
|
+
interval: 2,
|
|
57
|
+
exportDir: './pyre-exports',
|
|
58
|
+
detailed: false,
|
|
59
|
+
sortMode: 'cpu',
|
|
60
|
+
treeView: false,
|
|
61
|
+
graphMode: 'spark',
|
|
62
|
+
showGraphs: true,
|
|
63
|
+
autoLog: false,
|
|
64
|
+
mouseEnabled: true,
|
|
65
|
+
cpuAlertPct: 90,
|
|
66
|
+
tempAlertC: 95,
|
|
67
|
+
notificationsEnabled: true,
|
|
68
|
+
p2pPort: 9876,
|
|
69
|
+
p2pPassword: 'pyre',
|
|
70
|
+
splashEnabled: true,
|
|
71
|
+
splashColorScheme: 'fire',
|
|
72
|
+
splashAnimation: 'classic',
|
|
73
|
+
visiblePanels: {
|
|
74
|
+
cpu: true,
|
|
75
|
+
mem: true,
|
|
76
|
+
gpu: true,
|
|
77
|
+
power: true,
|
|
78
|
+
battery: true,
|
|
79
|
+
thermal: true,
|
|
80
|
+
network: true,
|
|
81
|
+
packets: true,
|
|
82
|
+
tasks: true,
|
|
83
|
+
disk: true,
|
|
84
|
+
process: true,
|
|
85
|
+
tree: false,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function readConfig(): Required<PyreConfig> {
|
|
90
|
+
try {
|
|
91
|
+
if (!fs.existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
|
|
92
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
93
|
+
const parsed = JSON.parse(raw) as Partial<PyreConfig>;
|
|
94
|
+
return deepMerge(DEFAULT_CONFIG, parsed);
|
|
95
|
+
} catch {
|
|
96
|
+
return { ...DEFAULT_CONFIG };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function writeConfig(config: Partial<PyreConfig>): void {
|
|
101
|
+
try {
|
|
102
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
103
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
const merged = deepMerge(readConfig(), config);
|
|
106
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n');
|
|
107
|
+
} catch {
|
|
108
|
+
// ignore write errors
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function deepMerge(target: any, source: any): any {
|
|
113
|
+
const result = { ...target };
|
|
114
|
+
for (const key of Object.keys(source)) {
|
|
115
|
+
if (source[key] !== undefined && source[key] !== null) {
|
|
116
|
+
if (typeof source[key] === 'object' && !Array.isArray(source[key]) && typeof target[key] === 'object' && !Array.isArray(target[key])) {
|
|
117
|
+
result[key] = deepMerge(target[key] || {}, source[key]);
|
|
118
|
+
} else {
|
|
119
|
+
result[key] = source[key];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getConfigPath(): string {
|
|
127
|
+
return CONFIG_FILE;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export { CONFIG_FILE };
|
package/Formula/pyre.rb
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
class Pyre < Formula
|
|
2
|
-
desc "Mac system monitoring CLI: temps, CPU, memory, disk, battery, live dashboard"
|
|
3
|
-
homepage "https://github.com/somalip/pyre"
|
|
4
|
-
url "https://github.com/somalip/pyre/archive/refs/tags/v1.0.0.tar.gz"
|
|
5
|
-
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
|
6
|
-
license "MIT"
|
|
7
|
-
|
|
8
|
-
depends_on "node"
|
|
9
|
-
|
|
10
|
-
def install
|
|
11
|
-
system "npm", "install", "--cache=#{buildpath}/.npm"
|
|
12
|
-
system "npm", "run", "build"
|
|
13
|
-
bin.install "dist/index.js" => "pyre"
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
test do
|
|
17
|
-
assert_match "PYRE", shell_output("#{bin}/pyre --help")
|
|
18
|
-
end
|
|
19
|
-
end
|