pyre-cli 7.1.0 → 8.1.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 +47 -20
- package/dist/anomaliesCmd-TO52YY2B.js +117 -0
- package/dist/brewHealth-6NE37NAU.js +74 -0
- package/dist/chunk-GLH7EKAX.js +15 -0
- package/dist/chunk-KBKXU2ZV.js +859 -0
- package/dist/chunk-RVGKEJXT.js +121 -0
- package/dist/collectors-4VYRJVRO.js +33 -0
- package/dist/completions-VQMHQCXE.js +122 -0
- package/dist/config-4ZLF4HTS.js +22 -0
- package/dist/doctor-LJQAABZ4.js +184 -0
- package/dist/extensions-QCFSVCL5.js +74 -0
- package/dist/index.js +1279 -1191
- package/dist/updateCheck-52X6LJG5.js +84 -0
- package/package.json +1 -1
- package/parity.md +16 -9
- package/src/anomaliesCmd.ts +151 -0
- package/src/brewHealth.ts +88 -0
- package/src/doctor.ts +77 -1
- package/src/easy_features.test.ts +43 -0
- package/src/extensions.ts +83 -0
- package/src/formatters/html_template.ts +655 -0
- package/src/formatters/index.ts +1 -0
- package/src/formatters/render.ts +2 -2
- package/src/index.ts +304 -26
- package/src/live/input.ts +12 -0
- package/src/monitors/collectors.ts +110 -5
- package/src/monitors/types.ts +6 -0
- package/src/sparkline.ts +63 -2
- package/src/state/config.ts +36 -2
- package/src/updateCheck.ts +94 -0
package/src/formatters/render.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* all layout-related code is co-located.
|
|
11
11
|
*/
|
|
12
12
|
import chalk from 'chalk';
|
|
13
|
-
import { sparkline, multiRowSparkline, brailleGraph } from '../sparkline.js';
|
|
13
|
+
import { sparkline, multiRowSparkline, brailleGraph, multiRowBrailleGraph } from '../sparkline.js';
|
|
14
14
|
import type { History } from '../history.js';
|
|
15
15
|
import type { StatsData, VisibleItems, TableOptions, AnomalyAlert } from './types.js';
|
|
16
16
|
import { THEMES, type ThemeName, type ThemeColors } from './themes.js';
|
|
@@ -730,7 +730,7 @@ function btopCpuBox(data: StatsData, width: number, theme: ThemeColors, opts: Ta
|
|
|
730
730
|
|
|
731
731
|
const cpuHistory = opts.history?.cpuUsage || [];
|
|
732
732
|
if (cpuHistory.length > 1) {
|
|
733
|
-
const graphRows =
|
|
733
|
+
const graphRows = multiRowBrailleGraph(cpuHistory, 3, { min: 0, max: 100 });
|
|
734
734
|
for (const gr of graphRows) {
|
|
735
735
|
leftLines.push(theme.cpu(fitVisible(gr, leftW)));
|
|
736
736
|
}
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import readline from 'node:readline';
|
|
|
13
13
|
import { Command } from 'commander';
|
|
14
14
|
import chalk from 'chalk';
|
|
15
15
|
import { collectAll } from './monitors/index.js';
|
|
16
|
-
import { formatTable, formatJson, formatCsv, formatTsv, formatHtml, formatMarkdown, formatBytes } from './formatters/index.js';
|
|
16
|
+
import { formatTable, formatJson, formatCsv, formatTsv, formatHtml, getDashboardHtml, formatMarkdown, formatBytes } from './formatters/index.js';
|
|
17
17
|
import { startLive, stopLive } from './live/index.js';
|
|
18
18
|
import { showSplash } from './splash.js';
|
|
19
19
|
import { P2PServer, P2PClient } from './p2p/index.js';
|
|
@@ -67,14 +67,45 @@ program
|
|
|
67
67
|
.option('--p2p-deny <ips>', 'Comma-separated list of denied IPs')
|
|
68
68
|
.option('--p2p-audit-log <dir>', 'Directory for P2P audit logs')
|
|
69
69
|
.option('--p2p-hmac-key <key>', 'HMAC key for message signing (default: derived from password)')
|
|
70
|
+
.option('--since <range>', 'Time range for anomalies digest (e.g. 1d, 7d, yesterday)')
|
|
71
|
+
.option('--plain', 'Output in plain accessible text without ANSI colors or line-drawing characters')
|
|
72
|
+
.option('--a11y', 'Alias for --plain')
|
|
70
73
|
.option('--webhook-url <url>', 'URL to POST to on alert')
|
|
71
74
|
.option('--alert-cmd <cmd>', 'Command to execute on alert')
|
|
72
75
|
.option('--port <port>', 'Port number for web server mode', '3000');
|
|
73
76
|
|
|
77
|
+
program.addHelpText('after', \`
|
|
78
|
+
Commands:
|
|
79
|
+
live Interactive live dashboard
|
|
80
|
+
ui Launch the native macOS UI dashboard
|
|
81
|
+
web Serve an auto-refreshing live web portal
|
|
82
|
+
ssh <host> Stream live stats from a remote Mac over SSH
|
|
83
|
+
fleet <host1> [host2]... Multi-host live dashboard monitoring multiple Macs
|
|
84
|
+
bench <cmd> Log CPU, memory, network, power draw, and estimate kWh cost
|
|
85
|
+
anomalies Compute z-score anomalies and print plain-language digest
|
|
86
|
+
doctor Run system diagnostics
|
|
87
|
+
extensions System Extensions inspector
|
|
88
|
+
brew Homebrew health panel
|
|
89
|
+
update Check for pyre-cli updates
|
|
90
|
+
profile <save|load|list> Atomic configuration profile management
|
|
91
|
+
config <show|reset> View or reset persistent configuration file
|
|
92
|
+
history Graph historical resource trends from CSV logs
|
|
93
|
+
diff <file1> <file2> Compare two saved snapshot files side-by-side
|
|
94
|
+
info Concise hardware summary
|
|
95
|
+
completions <shell> Generate shell auto-completion scripts
|
|
96
|
+
xbar Generate an xbar / SwiftBar menu bar plugin script
|
|
97
|
+
p2p <server|connect> Start a P2P server or connect to one
|
|
98
|
+
server Print commands for starting P2P server/client
|
|
99
|
+
\`);
|
|
100
|
+
|
|
74
101
|
program.parse(process.argv);
|
|
75
102
|
|
|
76
103
|
const opts = program.opts();
|
|
77
104
|
|
|
105
|
+
if (opts.plain || opts.a11y) {
|
|
106
|
+
chalk.level = 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
78
109
|
function isExportMode() {
|
|
79
110
|
return opts.json || opts.csv || opts.tsv || opts.html || opts.md;
|
|
80
111
|
}
|
|
@@ -145,6 +176,61 @@ async function runServerCommand(): Promise<void> {
|
|
|
145
176
|
async function main() {
|
|
146
177
|
const cmd = program.args[0];
|
|
147
178
|
|
|
179
|
+
if (cmd === 'anomalies') {
|
|
180
|
+
const { runAnomaliesDigest } = await import('./anomaliesCmd.js');
|
|
181
|
+
runAnomaliesDigest({ since: opts.since || '7d', dir: opts.exportDir });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (cmd === 'extensions') {
|
|
186
|
+
const { printExtensionsReport } = await import('./extensions.js');
|
|
187
|
+
await printExtensionsReport();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (cmd === 'brew') {
|
|
192
|
+
const { printBrewHealthReport } = await import('./brewHealth.js');
|
|
193
|
+
await printBrewHealthReport();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (cmd === 'update') {
|
|
198
|
+
const { printUpdateReport } = await import('./updateCheck.js');
|
|
199
|
+
await printUpdateReport(pkg.version);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (cmd === 'profile') {
|
|
204
|
+
const sub = program.args[1];
|
|
205
|
+
const name = program.args[2];
|
|
206
|
+
const { saveProfile, loadProfile, listProfiles } = await import('./state/config.js');
|
|
207
|
+
if (sub === 'save' && name) {
|
|
208
|
+
saveProfile(name);
|
|
209
|
+
console.log(chalk.green(` ✔ Profile '${name}' saved successfully.`));
|
|
210
|
+
} else if (sub === 'load' && name) {
|
|
211
|
+
try {
|
|
212
|
+
loadProfile(name);
|
|
213
|
+
console.log(chalk.green(` ✔ Profile '${name}' loaded successfully.`));
|
|
214
|
+
} catch (err: any) {
|
|
215
|
+
console.log(chalk.red(` ✖ ${err.message}`));
|
|
216
|
+
}
|
|
217
|
+
} else if (sub === 'list') {
|
|
218
|
+
const profiles = listProfiles();
|
|
219
|
+
console.log(chalk.bold('\n pyre Profiles:\n'));
|
|
220
|
+
if (profiles.length === 0) {
|
|
221
|
+
console.log(chalk.dim(' No saved profiles found. Save one with `pyre profile save <name>`.'));
|
|
222
|
+
} else {
|
|
223
|
+
for (const p of profiles) {
|
|
224
|
+
console.log(` • ${p}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
console.log();
|
|
228
|
+
} else {
|
|
229
|
+
console.log('Usage: pyre profile <save|load|list> [name]');
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
148
234
|
if (cmd === 'history') {
|
|
149
235
|
const daysIdx = program.args.indexOf('--days');
|
|
150
236
|
const days = daysIdx !== -1 ? parseInt(program.args[daysIdx + 1], 10) || 7 : 7;
|
|
@@ -178,6 +264,11 @@ async function main() {
|
|
|
178
264
|
return;
|
|
179
265
|
}
|
|
180
266
|
|
|
267
|
+
if (cmd === 'ui') {
|
|
268
|
+
await runUiCommand();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
181
272
|
if (cmd === 'bench') {
|
|
182
273
|
const benchCmd = program.args.slice(1).join(' ');
|
|
183
274
|
if (!benchCmd) {
|
|
@@ -212,6 +303,25 @@ async function main() {
|
|
|
212
303
|
return;
|
|
213
304
|
}
|
|
214
305
|
|
|
306
|
+
if (cmd === 'doctor') {
|
|
307
|
+
const { runDoctor, printDoctorReport } = await import('./doctor.js');
|
|
308
|
+
const checks = await runDoctor();
|
|
309
|
+
printDoctorReport(checks);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (cmd === 'info') {
|
|
314
|
+
await runInfoCommand();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (cmd === 'completions') {
|
|
319
|
+
const { printCompletions } = await import('./completions.js');
|
|
320
|
+
const shell = program.args[1] || 'zsh';
|
|
321
|
+
printCompletions(shell);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
215
325
|
if (cmd === 'fleet') {
|
|
216
326
|
const hosts = program.args.slice(1);
|
|
217
327
|
if (hosts.length === 0) {
|
|
@@ -492,8 +602,12 @@ async function runP2PConnect(): Promise<void> {
|
|
|
492
602
|
|
|
493
603
|
async function runInfoCommand(): Promise<void> {
|
|
494
604
|
const data = await collectAll({ detailed: true });
|
|
605
|
+
const { getDisplayInfo, getTimeMachineStatus } = await import('./monitors/collectors.js');
|
|
606
|
+
const displays = await getDisplayInfo();
|
|
607
|
+
const tmStatus = await getTimeMachineStatus();
|
|
608
|
+
|
|
495
609
|
const lines: string[] = [];
|
|
496
|
-
lines.push(chalk.bold(' pyre info'));
|
|
610
|
+
lines.push(chalk.bold(' pyre info — Mac System Information'));
|
|
497
611
|
lines.push('');
|
|
498
612
|
lines.push(` Hostname: ${data.header.hostname}`);
|
|
499
613
|
lines.push(` OS: ${data.header.os}`);
|
|
@@ -501,13 +615,34 @@ async function runInfoCommand(): Promise<void> {
|
|
|
501
615
|
lines.push(` CPU: ${data.cpu.brand}`);
|
|
502
616
|
lines.push(` Cores: ${data.cpu.physicalCores}/${data.cpu.cores} (phys/log)`);
|
|
503
617
|
lines.push(` Frequency: ${data.cpu.frequency} MHz`);
|
|
504
|
-
lines.push(` Memory: ${formatBytes(data.memory.total)}`);
|
|
618
|
+
lines.push(` Memory: ${formatBytes(data.memory.total)}${data.memory.pressureLevel ? ` (Pressure: ${data.memory.pressureLevel})` : ''}`);
|
|
619
|
+
if (data.memory.wiredBytes !== undefined && data.memory.compressedBytes !== undefined) {
|
|
620
|
+
lines.push(` Wired: ${formatBytes(data.memory.wiredBytes)} | Compressed: ${formatBytes(data.memory.compressedBytes)}`);
|
|
621
|
+
}
|
|
505
622
|
if (data.gpu) lines.push(` GPU: ${data.gpu.model} (${formatBytes(data.gpu.memory)})`);
|
|
506
623
|
if (data.battery) {
|
|
507
624
|
lines.push(` Battery: ${data.battery.level}% (${data.battery.state})`);
|
|
508
625
|
lines.push(` Health: ${data.battery.health}`);
|
|
509
626
|
}
|
|
510
627
|
lines.push(` Thermal: ${data.thermal.state}`);
|
|
628
|
+
|
|
629
|
+
if (displays.length > 0) {
|
|
630
|
+
lines.push('');
|
|
631
|
+
lines.push(chalk.cyan.bold(' 🖥 Displays:'));
|
|
632
|
+
for (const d of displays) {
|
|
633
|
+
lines.push(` • ${d.name}: ${d.resolution}${d.isMain ? ' (Main)' : ''}${d.connectionType ? ` [${d.connectionType}]` : ''}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
lines.push('');
|
|
638
|
+
lines.push(chalk.cyan.bold(' ⏱ Time Machine Backup:'));
|
|
639
|
+
if (!tmStatus.configured) {
|
|
640
|
+
lines.push(' • Not configured');
|
|
641
|
+
} else {
|
|
642
|
+
lines.push(` • Status: ${tmStatus.running ? chalk.yellow(`Backup in progress (${tmStatus.percent || 0}%)`) : chalk.green('Idle')}`);
|
|
643
|
+
lines.push(` • Latest Backup: ${tmStatus.latestBackup}`);
|
|
644
|
+
}
|
|
645
|
+
|
|
511
646
|
console.log(lines.join('\n'));
|
|
512
647
|
}
|
|
513
648
|
|
|
@@ -541,9 +676,9 @@ async function runSshCommand(host: string): Promise<void> {
|
|
|
541
676
|
});
|
|
542
677
|
}
|
|
543
678
|
|
|
544
|
-
async function runWebCommand(): Promise<
|
|
679
|
+
async function runWebCommand(): Promise<number> {
|
|
545
680
|
const http = await import('node:http');
|
|
546
|
-
const port = parseInt(opts.port || process.env.PORT || '3000', 10) || 3000;
|
|
681
|
+
const port = parseInt(opts.port || process.env.PORT || '3000', 10) || (opts.port === '0' ? 0 : 3000);
|
|
547
682
|
|
|
548
683
|
const server = http.createServer(async (req, res) => {
|
|
549
684
|
const url = req.url || '/';
|
|
@@ -585,17 +720,14 @@ async function runWebCommand(): Promise<void> {
|
|
|
585
720
|
// 4. Static/Dynamic HTML live dashboard & API snapshot endpoints
|
|
586
721
|
if (url === '/' || url === '/index.html') {
|
|
587
722
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
588
|
-
|
|
589
|
-
const htmlContent = formatHtml(data);
|
|
590
|
-
res.end(htmlContent);
|
|
723
|
+
res.end(getDashboardHtml());
|
|
591
724
|
} else if (url === '/setup') {
|
|
592
725
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
593
726
|
const indexPath = path.join(process.cwd(), 'index.html');
|
|
594
727
|
if (fs.existsSync(indexPath)) {
|
|
595
728
|
res.end(fs.readFileSync(indexPath, 'utf-8'));
|
|
596
729
|
} else {
|
|
597
|
-
|
|
598
|
-
res.end(formatHtml(data));
|
|
730
|
+
res.end(getDashboardHtml());
|
|
599
731
|
}
|
|
600
732
|
} else if (url === '/api' || url === '/api/stats' || url === '/data') {
|
|
601
733
|
const data = await collectAll({ detailed: true });
|
|
@@ -625,20 +757,145 @@ async function runWebCommand(): Promise<void> {
|
|
|
625
757
|
|
|
626
758
|
const ip = detectLocalIP() || 'localhost';
|
|
627
759
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
760
|
+
return new Promise((resolve) => {
|
|
761
|
+
server.listen(port, '0.0.0.0', () => {
|
|
762
|
+
const address = server.address();
|
|
763
|
+
const actualPort = typeof address === 'object' && address ? address.port : port;
|
|
764
|
+
|
|
765
|
+
console.log(chalk.bold(`\n pyre web server running on port ${actualPort}`));
|
|
766
|
+
console.log(chalk.dim(` Local URL: http://localhost:${actualPort}/`));
|
|
767
|
+
console.log(chalk.hex('#ff6a39').bold(` Network URL: http://${ip}:${actualPort}/ (Accessible on same Wi-Fi / LAN)`));
|
|
768
|
+
console.log(chalk.dim(` API: http://${ip}:${actualPort}/api`));
|
|
769
|
+
console.log(chalk.dim(` SSE Stream: http://${ip}:${actualPort}/api/stream`));
|
|
770
|
+
|
|
771
|
+
resolve(actualPort);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
process.once('SIGINT', () => {
|
|
775
|
+
server.close();
|
|
776
|
+
process.exit(0);
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function runUiCommand(): Promise<void> {
|
|
782
|
+
// Use a random ephemeral port to avoid collisions
|
|
783
|
+
opts.port = '0';
|
|
784
|
+
const port = await runWebCommand();
|
|
785
|
+
|
|
786
|
+
const swiftScript = `
|
|
787
|
+
import Cocoa
|
|
788
|
+
import WebKit
|
|
789
|
+
|
|
790
|
+
// WKWebView subclass that allows the window to be dragged from any pixel
|
|
791
|
+
class DraggableWebView: WKWebView {
|
|
792
|
+
override var mouseDownCanMoveWindow: Bool { return true }
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
796
|
+
var window: NSWindow!
|
|
797
|
+
var webView: DraggableWebView!
|
|
798
|
+
var dataTimer: Timer?
|
|
799
|
+
let serverPort = ${port}
|
|
800
|
+
|
|
801
|
+
func applicationDidFinishLaunching(_ n: Notification) {
|
|
802
|
+
// Window
|
|
803
|
+
let screen = NSScreen.main?.visibleFrame ?? NSRect(x:0, y:0, width:1280, height:800)
|
|
804
|
+
let W = min(1180.0, screen.width - 80)
|
|
805
|
+
let H = min(820.0, screen.height - 80)
|
|
806
|
+
let rect = NSRect(x: screen.minX + (screen.width - W) / 2,
|
|
807
|
+
y: screen.minY + (screen.height - H) / 2,
|
|
808
|
+
width: W, height: H)
|
|
809
|
+
|
|
810
|
+
window = NSWindow(contentRect: rect,
|
|
811
|
+
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
812
|
+
backing: .buffered, defer: false)
|
|
813
|
+
window.title = "Pyre"
|
|
814
|
+
window.minSize = NSSize(width: 800, height: 600)
|
|
815
|
+
window.appearance = NSAppearance(named: .darkAqua)
|
|
816
|
+
window.backgroundColor = NSColor(white: 0, alpha: 1)
|
|
817
|
+
window.isReleasedWhenClosed = false
|
|
818
|
+
window.delegate = self
|
|
819
|
+
|
|
820
|
+
// WebView
|
|
821
|
+
let cfg = WKWebViewConfiguration()
|
|
822
|
+
webView = DraggableWebView(frame: window.contentView!.bounds, configuration: cfg)
|
|
823
|
+
webView.autoresizingMask = [.width, .height]
|
|
824
|
+
webView.setValue(NSColor(white: 0, alpha: 1),
|
|
825
|
+
forKey: "backgroundColor")
|
|
826
|
+
|
|
827
|
+
// Load the dashboard HTML from the local server
|
|
828
|
+
var req = URLRequest(url: URL(string: "http://localhost:\\(serverPort)/")!)
|
|
829
|
+
req.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
|
830
|
+
webView.load(req)
|
|
831
|
+
|
|
832
|
+
window.contentView?.addSubview(webView)
|
|
833
|
+
window.makeKeyAndOrderFront(nil)
|
|
834
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
835
|
+
|
|
836
|
+
// Start native data timer (URLSession has full network access — no sandbox issues)
|
|
837
|
+
// We wait 1.5 s for the page to load, then start pushing data every 2 s.
|
|
838
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
|
839
|
+
self?.fetchAndPush()
|
|
840
|
+
self?.dataTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
|
|
841
|
+
self?.fetchAndPush()
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// URLSession fetch (runs in the main app process — not sandboxed like WebContent)
|
|
847
|
+
func fetchAndPush() {
|
|
848
|
+
guard let url = URL(string: "http://localhost:\\(serverPort)/api/stats") else { return }
|
|
849
|
+
URLSession.shared.dataTask(with: url) { [weak self] data, _, err in
|
|
850
|
+
if let err = err {
|
|
851
|
+
print("Swift UI Fetch Error: \\(err)")
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
guard let data = data else {
|
|
855
|
+
print("Swift UI Fetch Error: No data")
|
|
856
|
+
return
|
|
857
|
+
}
|
|
858
|
+
let b64 = data.base64EncodedString()
|
|
859
|
+
let js = "if(window.__pyreUpdate){window.__pyreUpdate(decodeURIComponent(escape(atob('\\(b64)'))))}"
|
|
860
|
+
DispatchQueue.main.async {
|
|
861
|
+
self?.webView.evaluateJavaScript(js) { res, err in
|
|
862
|
+
if let err = err {
|
|
863
|
+
print("Swift UI JS Eval Error: \\(err)")
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}.resume()
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
func windowWillClose(_ n: Notification) {
|
|
871
|
+
dataTimer?.invalidate()
|
|
872
|
+
NSApplication.shared.terminate(self)
|
|
873
|
+
}
|
|
874
|
+
func applicationShouldTerminateAfterLastWindowClosed(_ s: NSApplication) -> Bool { true }
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
let app = NSApplication.shared
|
|
878
|
+
app.setActivationPolicy(.regular)
|
|
879
|
+
let delegate = AppDelegate()
|
|
880
|
+
app.delegate = delegate
|
|
881
|
+
app.run()
|
|
882
|
+
`;
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
const scriptPath = path.join(os.tmpdir(), 'pyre-ui.swift');
|
|
886
|
+
fs.writeFileSync(scriptPath, swiftScript);
|
|
887
|
+
|
|
888
|
+
const { spawn } = await import('node:child_process');
|
|
889
|
+
console.log(chalk.cyan(`\n Launching native UI window...`));
|
|
890
|
+
const ui = spawn('swift', [scriptPath], { stdio: 'inherit' });
|
|
891
|
+
|
|
892
|
+
ui.on('close', () => {
|
|
893
|
+
console.log(chalk.dim('UI window closed, shutting down server...'));
|
|
894
|
+
process.exit(0);
|
|
638
895
|
});
|
|
639
896
|
|
|
640
897
|
process.once('SIGINT', () => {
|
|
641
|
-
|
|
898
|
+
ui.kill();
|
|
642
899
|
process.exit(0);
|
|
643
900
|
});
|
|
644
901
|
}
|
|
@@ -650,12 +907,16 @@ async function runBenchCommand(benchCmd: string): Promise<void> {
|
|
|
650
907
|
|
|
651
908
|
if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
|
|
652
909
|
const stream = fs.createWriteStream(file, { flags: 'a' });
|
|
653
|
-
stream.write('timestamp,cpu_usage,mem_usage_percent,temp_c,net_rx_bytes,net_tx_bytes,net_rx_packets,net_tx_packets,connections,thermal_state\n');
|
|
910
|
+
stream.write('timestamp,cpu_usage,mem_usage_percent,temp_c,net_rx_bytes,net_tx_bytes,net_rx_packets,net_tx_packets,connections,thermal_state,power_watts\n');
|
|
654
911
|
|
|
655
912
|
console.log(chalk.bold(`\n pyre bench`));
|
|
656
913
|
console.log(chalk.dim(` Command: ${benchCmd}`));
|
|
657
914
|
console.log(chalk.dim(` Log: ${file}\n`));
|
|
658
915
|
|
|
916
|
+
const startTime = Date.now();
|
|
917
|
+
let accumulatedWattSeconds = 0;
|
|
918
|
+
let powerSamplesCount = 0;
|
|
919
|
+
|
|
659
920
|
const child = spawn('sh', ['-c', benchCmd]);
|
|
660
921
|
child.stdout.on('data', d => process.stdout.write(d));
|
|
661
922
|
child.stderr.on('data', d => process.stdout.write(d));
|
|
@@ -668,23 +929,40 @@ async function runBenchCommand(benchCmd: string): Promise<void> {
|
|
|
668
929
|
const rxPackets = data.network.rxPackets ?? 0;
|
|
669
930
|
const txPackets = data.network.txPackets ?? 0;
|
|
670
931
|
const connections = data.network.connections ?? 0;
|
|
671
|
-
|
|
932
|
+
const watts = data.power?.combinedWatts ?? data.power?.cpuWatts;
|
|
933
|
+
if (watts !== undefined && watts > 0) {
|
|
934
|
+
accumulatedWattSeconds += watts * interval;
|
|
935
|
+
powerSamplesCount++;
|
|
936
|
+
}
|
|
937
|
+
stream.write(`${data.timestamp},${data.cpu.usage},${data.memory.usagePercent},${temp},${data.network.rxBytes},${data.network.txBytes},${rxPackets},${txPackets},${connections},${data.thermal.state},${watts ?? ''}\n`);
|
|
672
938
|
} catch {
|
|
673
939
|
// skip bad tick
|
|
674
940
|
}
|
|
675
941
|
}, interval * 1000);
|
|
676
942
|
|
|
677
|
-
|
|
943
|
+
const printSummary = () => {
|
|
678
944
|
clearInterval(handle);
|
|
679
945
|
stream.end();
|
|
680
|
-
|
|
946
|
+
const durationSec = Math.max(0.1, (Date.now() - startTime) / 1000);
|
|
947
|
+
console.log(chalk.green(`\n ✔ Bench complete (${durationSec.toFixed(2)}s). Log saved to ${file}`));
|
|
948
|
+
|
|
949
|
+
if (powerSamplesCount > 0 && accumulatedWattSeconds > 0) {
|
|
950
|
+
const kWh = accumulatedWattSeconds / (3600 * 1000);
|
|
951
|
+
const rate = config.electricityRateKwH ?? 0.15;
|
|
952
|
+
const cost = kWh * rate;
|
|
953
|
+
console.log(chalk.cyan(` ⚡ Energy Consumption: ${kWh.toFixed(6)} kWh | Est. Cost: $${cost.toFixed(6)} (@ $${rate}/kWh)`));
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
child.on('close', () => {
|
|
958
|
+
printSummary();
|
|
681
959
|
process.exit(0);
|
|
682
960
|
});
|
|
683
961
|
|
|
684
962
|
process.once('SIGINT', () => {
|
|
685
963
|
clearInterval(handle);
|
|
686
964
|
child.kill('SIGTERM');
|
|
687
|
-
|
|
965
|
+
printSummary();
|
|
688
966
|
process.exit(0);
|
|
689
967
|
});
|
|
690
968
|
}
|
package/src/live/input.ts
CHANGED
|
@@ -207,12 +207,24 @@ function handleInputModeKey(str: string, key: readline.Key) {
|
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
let confirmKillPid: number | null = null;
|
|
211
|
+
|
|
210
212
|
function killProcess(pidStr: string, signal: string = 'SIGTERM') {
|
|
211
213
|
const pid = parseInt(pidStr, 10);
|
|
212
214
|
if (!pid || pid <= 0) {
|
|
213
215
|
setStatus(`Invalid PID: ${pidStr}`);
|
|
214
216
|
return;
|
|
215
217
|
}
|
|
218
|
+
|
|
219
|
+
const isProtected = pid === 1 || pid === process.pid || pid === process.ppid;
|
|
220
|
+
if (isProtected && confirmKillPid !== pid) {
|
|
221
|
+
confirmKillPid = pid;
|
|
222
|
+
const name = pid === 1 ? 'launchd (system init)' : (pid === process.pid ? 'pyre itself' : 'parent process');
|
|
223
|
+
setStatus(`⚠️ PROTECTED PID ${pid} (${name})! Re-enter PID ${pid} to confirm termination.`, 8000);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
confirmKillPid = null;
|
|
216
228
|
try {
|
|
217
229
|
process.kill(pid, signal as NodeJS.Signals);
|
|
218
230
|
setStatus(`Sent ${signal} to PID ${pid}`);
|
|
@@ -466,6 +466,14 @@ export async function collectMemory(): Promise<MemoryData> {
|
|
|
466
466
|
const wiredPages = getPageCount('wired down');
|
|
467
467
|
const compressedPages = getPageCount('occupied by compressor');
|
|
468
468
|
|
|
469
|
+
const purgeablePages = getPageCount('purgeable');
|
|
470
|
+
const getStatCount = (label: string) => {
|
|
471
|
+
const m = vmStat.match(new RegExp(`${label}:\\s+(\\d+)\\.`));
|
|
472
|
+
return m ? parseInt(m[1]) : 0;
|
|
473
|
+
};
|
|
474
|
+
const swapIns = getStatCount('Swapins') || getStatCount('Pageins');
|
|
475
|
+
const swapOuts = getStatCount('Swapouts') || getStatCount('Pageouts');
|
|
476
|
+
|
|
469
477
|
const free = freePages * pageSize;
|
|
470
478
|
const used = (activePages + inactivePages + wiredPages + compressedPages) * pageSize;
|
|
471
479
|
const usagePercent = totalBytes > 0 ? Math.round((used / totalBytes) * 100) : 0;
|
|
@@ -475,11 +483,6 @@ export async function collectMemory(): Promise<MemoryData> {
|
|
|
475
483
|
let swapFree = 0;
|
|
476
484
|
try {
|
|
477
485
|
const swapRaw = (await run('sysctl -n vm.swapusage')).trim();
|
|
478
|
-
// Real output is "total = 3072.00M used = 1497.75M free = 1574.25M
|
|
479
|
-
// (encrypted)" — the parentheses wrap the trailing "(encrypted)" note,
|
|
480
|
-
// not the used value. The old regex required a literal "(" right before
|
|
481
|
-
// "used", which never appears, so this never matched and swap stayed at
|
|
482
|
-
// 0 forever instead of updating.
|
|
483
486
|
const swapMatch = swapRaw.match(
|
|
484
487
|
/total\s*=\s*([\d.]+)([KMGkmg])\s+used\s*=\s*([\d.]+)([KMGkmg])\s+free\s*=\s*([\d.]+)([KMGkmg])/
|
|
485
488
|
);
|
|
@@ -492,6 +495,18 @@ export async function collectMemory(): Promise<MemoryData> {
|
|
|
492
495
|
// ignore
|
|
493
496
|
}
|
|
494
497
|
|
|
498
|
+
let pressureLevel = 'Normal';
|
|
499
|
+
try {
|
|
500
|
+
const pressVal = (await run('sysctl -n kern.memorystatus_vm_pressure_level')).trim();
|
|
501
|
+
const pressNum = parseInt(pressVal, 10);
|
|
502
|
+
if (pressNum === 2) pressureLevel = 'Warning';
|
|
503
|
+
else if (pressNum >= 4) pressureLevel = 'Critical';
|
|
504
|
+
else if (!isNaN(pressNum)) pressureLevel = 'Normal';
|
|
505
|
+
else pressureLevel = pressVal || 'Normal';
|
|
506
|
+
} catch {
|
|
507
|
+
pressureLevel = 'Normal';
|
|
508
|
+
}
|
|
509
|
+
|
|
495
510
|
return {
|
|
496
511
|
total: totalBytes,
|
|
497
512
|
used,
|
|
@@ -501,6 +516,12 @@ export async function collectMemory(): Promise<MemoryData> {
|
|
|
501
516
|
swapFree,
|
|
502
517
|
pageSize,
|
|
503
518
|
usagePercent,
|
|
519
|
+
wiredBytes: wiredPages * pageSize,
|
|
520
|
+
compressedBytes: compressedPages * pageSize,
|
|
521
|
+
purgeableBytes: purgeablePages * pageSize,
|
|
522
|
+
swapIns,
|
|
523
|
+
swapOuts,
|
|
524
|
+
pressureLevel,
|
|
504
525
|
};
|
|
505
526
|
}
|
|
506
527
|
|
|
@@ -855,7 +876,91 @@ export async function collectTasks(limit = 12): Promise<TaskData[]> {
|
|
|
855
876
|
return { pid: parseInt(parts[1]), user: parts[2], cpu: parseFloat(parts[3]), mem: parseFloat(parts[4]), state: parts[5], runtime: runtimeSec, command: parts[7] };
|
|
856
877
|
})
|
|
857
878
|
.filter((t): t is TaskData => t !== null && t.pid > 0);
|
|
879
|
+
} catch {
|
|
880
|
+
return [];
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export interface DisplayInfo {
|
|
885
|
+
name: string;
|
|
886
|
+
resolution: string;
|
|
887
|
+
connectionType?: string;
|
|
888
|
+
isMain?: boolean;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export async function getDisplayInfo(): Promise<DisplayInfo[]> {
|
|
892
|
+
try {
|
|
893
|
+
const raw = await run('system_profiler SPDisplaysDataType 2>&1', '');
|
|
894
|
+
const displays: DisplayInfo[] = [];
|
|
895
|
+
const lines = raw.split('\n');
|
|
896
|
+
let currentName = '';
|
|
897
|
+
let currentRes = '';
|
|
898
|
+
let isMain = false;
|
|
899
|
+
let connType = '';
|
|
900
|
+
|
|
901
|
+
for (const line of lines) {
|
|
902
|
+
const trimmed = line.trim();
|
|
903
|
+
if (trimmed.endsWith(':') && !trimmed.startsWith('Chipset') && !trimmed.startsWith('Displays') && !trimmed.startsWith('Graphics')) {
|
|
904
|
+
if (currentName && currentRes) {
|
|
905
|
+
displays.push({ name: currentName, resolution: currentRes, isMain, connectionType: connType || undefined });
|
|
906
|
+
currentRes = '';
|
|
907
|
+
isMain = false;
|
|
908
|
+
connType = '';
|
|
909
|
+
}
|
|
910
|
+
currentName = trimmed.slice(0, -1);
|
|
911
|
+
} else if (trimmed.startsWith('Resolution:')) {
|
|
912
|
+
currentRes = trimmed.replace('Resolution:', '').trim();
|
|
913
|
+
} else if (trimmed.startsWith('Main Display: Yes')) {
|
|
914
|
+
isMain = true;
|
|
915
|
+
} else if (trimmed.startsWith('Connection Type:')) {
|
|
916
|
+
connType = trimmed.replace('Connection Type:', '').trim();
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (currentName && currentRes) {
|
|
920
|
+
displays.push({ name: currentName, resolution: currentRes, isMain, connectionType: connType || undefined });
|
|
921
|
+
}
|
|
922
|
+
return displays;
|
|
858
923
|
} catch {
|
|
859
924
|
return [];
|
|
860
925
|
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export interface TimeMachineStatus {
|
|
929
|
+
configured: boolean;
|
|
930
|
+
running: boolean;
|
|
931
|
+
percent?: number;
|
|
932
|
+
latestBackup?: string;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
export async function getTimeMachineStatus(): Promise<TimeMachineStatus> {
|
|
936
|
+
try {
|
|
937
|
+
const statusRaw = await run('tmutil status 2>&1', '');
|
|
938
|
+
if (statusRaw.includes('No Time Machine destination') || statusRaw.includes('not configured')) {
|
|
939
|
+
return { configured: false, running: false };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const running = statusRaw.includes('Running = 1;');
|
|
943
|
+
let percent: number | undefined = undefined;
|
|
944
|
+
const pctMatch = statusRaw.match(/Percent\s*=\s*"?(0\.\d+|\d+)"?/i);
|
|
945
|
+
if (pctMatch) {
|
|
946
|
+
percent = Math.round(parseFloat(pctMatch[1]) * 100);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
let latestBackup = 'Unknown';
|
|
950
|
+
try {
|
|
951
|
+
const latestRaw = await run('tmutil latestbackup 2>&1', '');
|
|
952
|
+
if (latestRaw.trim() && !latestRaw.includes('error') && !latestRaw.includes('No backups')) {
|
|
953
|
+
const basename = latestRaw.trim().split('/').pop() || latestRaw.trim();
|
|
954
|
+
latestBackup = basename.replace('.previous', '').replace('.backup', '');
|
|
955
|
+
} else {
|
|
956
|
+
latestBackup = 'No backup found / Unmounted';
|
|
957
|
+
}
|
|
958
|
+
} catch {
|
|
959
|
+
latestBackup = 'Not mounted';
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return { configured: true, running, percent, latestBackup };
|
|
963
|
+
} catch {
|
|
964
|
+
return { configured: false, running: false };
|
|
965
|
+
}
|
|
861
966
|
}
|
package/src/monitors/types.ts
CHANGED
|
@@ -80,6 +80,12 @@ export interface MemoryData {
|
|
|
80
80
|
swapFree: number;
|
|
81
81
|
pageSize: number;
|
|
82
82
|
usagePercent: number;
|
|
83
|
+
wiredBytes?: number;
|
|
84
|
+
compressedBytes?: number;
|
|
85
|
+
purgeableBytes?: number;
|
|
86
|
+
swapIns?: number;
|
|
87
|
+
swapOuts?: number;
|
|
88
|
+
pressureLevel?: string;
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
export interface ThermalData {
|