pyre-cli 10.1.0 → 10.3.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 +211 -494
- package/dist/{checkCmd-RKW6QGDR.js → checkCmd-WQAHAUWO.js} +1 -1
- package/dist/{chunk-GHRDW646.js → chunk-23YWCI3O.js} +375 -135
- package/dist/{service-Q74BBILO.js → chunk-KFPTD266.js} +43 -3
- package/dist/{doctor-GYHFTNZQ.js → chunk-KVUMOA6C.js} +7 -6
- package/dist/{chunk-53O2RGSL.js → chunk-QM5L43EK.js} +19 -2
- package/dist/{chunk-6UHZP4QP.js → chunk-XTOOEKLM.js} +24 -12
- package/dist/{collectors-H43PN4PS.js → collectors-JDN5Y7EV.js} +1 -1
- package/dist/{completions-JKYEJNGR.js → completions-ZVTNE52B.js} +15 -11
- package/dist/{config-GYGJXU2Q.js → config-J4YMADEF.js} +3 -1
- package/dist/doctor-LHFW4UJP.js +10 -0
- package/dist/firebaseSync-ON3RZ6KX.js +91 -0
- package/dist/index.js +4245 -736
- package/dist/monitors/platform/windows-ui.ps1 +42 -0
- package/dist/{netUsageCmd-WKBTW2YG.js → netUsageCmd-67IBZZYN.js} +2 -2
- package/dist/{pipeCmd-7REDBLBU.js → pipeCmd-2N4JPKYE.js} +1 -1
- package/dist/{prometheusCmd-PL64BLN5.js → prometheusCmd-LEUM5WCZ.js} +1 -1
- package/dist/serveCmd-L7LVJOZN.js +353 -0
- package/dist/service-7GIX3YNC.js +16 -0
- package/dist/syncCmd-42IRF633.js +246 -0
- package/dist/{topoCmd-DU2HHZNH.js → topoCmd-DZ3YS5IL.js} +1 -1
- package/dist/{watchdogCmd-UF53JPZB.js → watchdogCmd-6GR4UN6B.js} +7 -7
- package/dist/{wizard-TD7BGX6O.js → wizard-JQMQWFAW.js} +1 -1
- package/package.json +4 -3
- package/parity.md +2 -0
- package/src/completions.ts +15 -11
- package/src/easy_features.test.ts +149 -0
- package/src/firebaseSync.ts +157 -0
- package/src/formatters/html_template.ts +3086 -598
- package/src/formatters/render.ts +25 -12
- package/src/index.ts +518 -24
- package/src/live/input.ts +501 -148
- package/src/live/render.ts +163 -18
- package/src/live/state.ts +52 -2
- package/src/live/types.ts +1 -1
- package/src/monitors/collectors.ts +3 -10
- package/src/monitors/platform/windows-ui.ps1 +42 -0
- package/src/monitors/platform/windows.ts +442 -147
- package/src/monitors/types.ts +8 -0
- package/src/serveCmd.ts +17 -2
- package/src/service.ts +49 -0
- package/src/state/config.ts +48 -0
- package/src/syncCmd.ts +159 -11
- package/src/web/dashboard.ts +205 -0
- package/dist/serveCmd-2H5VLF3P.js +0 -153
- package/dist/syncCmd-A2W3QB3S.js +0 -126
package/src/index.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
12
14
|
import readline from 'node:readline';
|
|
13
15
|
import { Command } from 'commander';
|
|
14
16
|
import chalk from 'chalk';
|
|
@@ -21,7 +23,9 @@ import { runDoctor, printDoctorReport } from './doctor.js';
|
|
|
21
23
|
import { generateZshCompletions, generateBashCompletions, generateFishCompletions, generatePowerShellCompletions } from './completions.js';
|
|
22
24
|
import { runHistoryCommand } from './historyCmd.js';
|
|
23
25
|
import { runDiffCommand } from './diffCmd.js';
|
|
24
|
-
import { readConfig, CONFIG_FILE } from './state/config.js';
|
|
26
|
+
import { readConfig, writeConfig, resetConfig, CONFIG_FILE } from './state/config.js';
|
|
27
|
+
import { getServiceStatus, installService, uninstallService } from './service.js';
|
|
28
|
+
import { sendTestAlert } from './alertChannels.js';
|
|
25
29
|
import { generateXbarPlugin } from './xbar.js';
|
|
26
30
|
import { runFleetCommand } from './fleet.js';
|
|
27
31
|
|
|
@@ -80,6 +84,9 @@ program
|
|
|
80
84
|
.option('--install', 'Install pyre web as a background launchd agent')
|
|
81
85
|
.option('--uninstall', 'Uninstall pyre web launchd agent')
|
|
82
86
|
.option('--port <port>', 'Port number for web server mode', '3000')
|
|
87
|
+
.option('--host <host>', 'Host address to bind web/UI server (e.g. 127.0.0.1 or 0.0.0.0)')
|
|
88
|
+
.option('--lan', 'Expose web/UI server to local network (Accessible on same Wi-Fi / LAN)')
|
|
89
|
+
.option('--no-lan', 'Do not expose server to local network (bind to localhost only)')
|
|
83
90
|
.option('--prometheus-port <port>', 'Port number for Prometheus exporter (default: 9090)', '9090')
|
|
84
91
|
.option('--speed <n>', 'Playback speed multiplier for pyre replay (default: 1)', '1')
|
|
85
92
|
.option('--llm-backend <backend>', 'AI backend: builtin (local zero-setup), ollama, or openai (default: builtin)', 'builtin')
|
|
@@ -103,7 +110,7 @@ program.addHelpText('after', `
|
|
|
103
110
|
check One-line plain-English health summary
|
|
104
111
|
pipe Continuous newline-delimited JSON stream for scripting
|
|
105
112
|
stress Synthetic CPU/GPU load generator
|
|
106
|
-
ui
|
|
113
|
+
ui, launch ui Launch the native Activity Monitor UI dashboard window
|
|
107
114
|
web Serve an auto-refreshing live web portal
|
|
108
115
|
ssh <host> Stream live stats from a remote host over SSH
|
|
109
116
|
fleet <host1> [host2]... Multi-host live dashboard monitoring multiple hosts
|
|
@@ -196,7 +203,7 @@ async function runServerCommand(): Promise<void> {
|
|
|
196
203
|
}
|
|
197
204
|
|
|
198
205
|
const port = 9876;
|
|
199
|
-
const password = '
|
|
206
|
+
const password = crypto.randomBytes(12).toString('hex');
|
|
200
207
|
|
|
201
208
|
console.log(chalk.bold(`\n pyre server`));
|
|
202
209
|
console.log(chalk.dim(` Detected IP: ${ip}`));
|
|
@@ -527,11 +534,16 @@ async function main() {
|
|
|
527
534
|
return;
|
|
528
535
|
}
|
|
529
536
|
|
|
530
|
-
if (cmd === 'ui') {
|
|
537
|
+
if (cmd === 'ui' || (cmd === 'launch' && (!program.args[1] || program.args[1] === 'ui'))) {
|
|
531
538
|
await runUiCommand();
|
|
532
539
|
return;
|
|
533
540
|
}
|
|
534
541
|
|
|
542
|
+
if (cmd === 'launch' && program.args[1] === 'web') {
|
|
543
|
+
await runWebCommand();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
535
547
|
if (cmd === 'bench') {
|
|
536
548
|
const benchCmd = program.args.slice(1).join(' ');
|
|
537
549
|
if (!benchCmd) {
|
|
@@ -953,10 +965,76 @@ async function runSshCommand(host: string): Promise<void> {
|
|
|
953
965
|
});
|
|
954
966
|
}
|
|
955
967
|
|
|
956
|
-
async function runWebCommand(): Promise<number> {
|
|
968
|
+
async function runWebCommand(bindHost?: string): Promise<number> {
|
|
957
969
|
const http = await import('node:http');
|
|
958
970
|
const port = parseInt(opts.port || process.env.PORT || '3000', 10) || (opts.port === '0' ? 0 : 3000);
|
|
959
971
|
|
|
972
|
+
const exportDir = opts.exportDir || './pyre-exports';
|
|
973
|
+
const webLogSession = {
|
|
974
|
+
recording: false,
|
|
975
|
+
filePath: '',
|
|
976
|
+
fileName: '',
|
|
977
|
+
stream: null as any,
|
|
978
|
+
samples: 0,
|
|
979
|
+
startedAt: 0,
|
|
980
|
+
timer: null as any,
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
function writeCsvSample(data: any) {
|
|
984
|
+
if (!webLogSession.recording || !webLogSession.stream) return;
|
|
985
|
+
try {
|
|
986
|
+
const temp = data.cpu?.temperature ?? data.thermal?.temperatures?.cpu_die ?? '';
|
|
987
|
+
const rxPackets = data.network?.rxPackets ?? 0;
|
|
988
|
+
const txPackets = data.network?.txPackets ?? 0;
|
|
989
|
+
const connections = data.network?.connections ?? 0;
|
|
990
|
+
webLogSession.stream.write(
|
|
991
|
+
`${data.timestamp || new Date().toISOString()},${data.cpu?.usage ?? 0},${data.memory?.usagePercent ?? 0},${temp},${data.network?.rxBytes ?? 0},${data.network?.txBytes ?? 0},${rxPackets},${txPackets},${connections},${data.thermal?.state ?? 'Nominal'}\n`
|
|
992
|
+
);
|
|
993
|
+
webLogSession.samples++;
|
|
994
|
+
} catch {
|
|
995
|
+
// ignore write error
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function startWebLogging() {
|
|
1000
|
+
if (webLogSession.recording) return;
|
|
1001
|
+
if (!fs.existsSync(exportDir)) {
|
|
1002
|
+
fs.mkdirSync(exportDir, { recursive: true });
|
|
1003
|
+
}
|
|
1004
|
+
const tsStr = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1005
|
+
webLogSession.fileName = `pyre-log-${tsStr}.csv`;
|
|
1006
|
+
webLogSession.filePath = path.join(exportDir, webLogSession.fileName);
|
|
1007
|
+
webLogSession.stream = fs.createWriteStream(webLogSession.filePath, { flags: 'a' });
|
|
1008
|
+
webLogSession.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');
|
|
1009
|
+
webLogSession.recording = true;
|
|
1010
|
+
webLogSession.samples = 0;
|
|
1011
|
+
webLogSession.startedAt = Date.now();
|
|
1012
|
+
|
|
1013
|
+
if (webLogSession.timer) clearInterval(webLogSession.timer);
|
|
1014
|
+
webLogSession.timer = setInterval(async () => {
|
|
1015
|
+
if (!webLogSession.recording) return;
|
|
1016
|
+
try {
|
|
1017
|
+
const d = await collectAll({ detailed: true });
|
|
1018
|
+
writeCsvSample(d);
|
|
1019
|
+
} catch {
|
|
1020
|
+
// ignore
|
|
1021
|
+
}
|
|
1022
|
+
}, 2000);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function stopWebLogging() {
|
|
1026
|
+
if (!webLogSession.recording) return;
|
|
1027
|
+
webLogSession.recording = false;
|
|
1028
|
+
if (webLogSession.timer) {
|
|
1029
|
+
clearInterval(webLogSession.timer);
|
|
1030
|
+
webLogSession.timer = null;
|
|
1031
|
+
}
|
|
1032
|
+
if (webLogSession.stream) {
|
|
1033
|
+
webLogSession.stream.end();
|
|
1034
|
+
webLogSession.stream = null;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
960
1038
|
const server = http.createServer(async (req, res) => {
|
|
961
1039
|
const url = req.url || '/';
|
|
962
1040
|
|
|
@@ -988,6 +1066,9 @@ async function runWebCommand(): Promise<number> {
|
|
|
988
1066
|
const sendStats = async () => {
|
|
989
1067
|
try {
|
|
990
1068
|
const data = await collectAll({ detailed: true });
|
|
1069
|
+
if (webLogSession.recording && !webLogSession.timer) {
|
|
1070
|
+
writeCsvSample(data);
|
|
1071
|
+
}
|
|
991
1072
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
992
1073
|
} catch {
|
|
993
1074
|
// ignore stream collect error
|
|
@@ -1023,6 +1104,245 @@ async function runWebCommand(): Promise<number> {
|
|
|
1023
1104
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
1024
1105
|
});
|
|
1025
1106
|
res.end(JSON.stringify(data, null, 2));
|
|
1107
|
+
} else if (url.startsWith('/api/logging/status')) {
|
|
1108
|
+
let fileSize = 0;
|
|
1109
|
+
if (webLogSession.filePath && fs.existsSync(webLogSession.filePath)) {
|
|
1110
|
+
try { fileSize = fs.statSync(webLogSession.filePath).size; } catch {}
|
|
1111
|
+
}
|
|
1112
|
+
res.writeHead(200, {
|
|
1113
|
+
'Content-Type': 'application/json',
|
|
1114
|
+
'Access-Control-Allow-Origin': '*',
|
|
1115
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
1116
|
+
});
|
|
1117
|
+
res.end(JSON.stringify({
|
|
1118
|
+
recording: webLogSession.recording,
|
|
1119
|
+
file: webLogSession.fileName,
|
|
1120
|
+
path: webLogSession.filePath,
|
|
1121
|
+
samples: webLogSession.samples,
|
|
1122
|
+
durationSec: webLogSession.recording ? Math.round((Date.now() - webLogSession.startedAt) / 1000) : 0,
|
|
1123
|
+
sizeBytes: fileSize,
|
|
1124
|
+
exportDir,
|
|
1125
|
+
}));
|
|
1126
|
+
} else if (url.startsWith('/api/logging/start')) {
|
|
1127
|
+
startWebLogging();
|
|
1128
|
+
res.writeHead(200, {
|
|
1129
|
+
'Content-Type': 'application/json',
|
|
1130
|
+
'Access-Control-Allow-Origin': '*',
|
|
1131
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
1132
|
+
});
|
|
1133
|
+
res.end(JSON.stringify({
|
|
1134
|
+
success: true,
|
|
1135
|
+
recording: true,
|
|
1136
|
+
file: webLogSession.fileName,
|
|
1137
|
+
path: webLogSession.filePath,
|
|
1138
|
+
}));
|
|
1139
|
+
} else if (url.startsWith('/api/logging/stop')) {
|
|
1140
|
+
const summary = {
|
|
1141
|
+
success: true,
|
|
1142
|
+
recording: false,
|
|
1143
|
+
file: webLogSession.fileName,
|
|
1144
|
+
path: webLogSession.filePath,
|
|
1145
|
+
samples: webLogSession.samples,
|
|
1146
|
+
durationSec: webLogSession.startedAt ? Math.round((Date.now() - webLogSession.startedAt) / 1000) : 0,
|
|
1147
|
+
};
|
|
1148
|
+
stopWebLogging();
|
|
1149
|
+
res.writeHead(200, {
|
|
1150
|
+
'Content-Type': 'application/json',
|
|
1151
|
+
'Access-Control-Allow-Origin': '*',
|
|
1152
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
1153
|
+
});
|
|
1154
|
+
res.end(JSON.stringify(summary));
|
|
1155
|
+
} else if (url.startsWith('/api/logging/history')) {
|
|
1156
|
+
res.writeHead(200, {
|
|
1157
|
+
'Content-Type': 'application/json',
|
|
1158
|
+
'Access-Control-Allow-Origin': '*',
|
|
1159
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
1160
|
+
});
|
|
1161
|
+
const files: any[] = [];
|
|
1162
|
+
if (fs.existsSync(exportDir)) {
|
|
1163
|
+
try {
|
|
1164
|
+
const logFiles = fs.readdirSync(exportDir)
|
|
1165
|
+
.filter(f => f.startsWith('pyre-log-') && f.endsWith('.csv'))
|
|
1166
|
+
.sort().reverse();
|
|
1167
|
+
for (const lf of logFiles) {
|
|
1168
|
+
const p = path.join(exportDir, lf);
|
|
1169
|
+
const st = fs.statSync(p);
|
|
1170
|
+
files.push({
|
|
1171
|
+
name: lf,
|
|
1172
|
+
sizeBytes: st.size,
|
|
1173
|
+
mtime: st.mtime.toISOString(),
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
} catch {}
|
|
1177
|
+
}
|
|
1178
|
+
res.end(JSON.stringify({ exportDir, files }));
|
|
1179
|
+
} else if (url.startsWith('/api/logging/download')) {
|
|
1180
|
+
const parsedUrl = new URL(url, 'http://localhost');
|
|
1181
|
+
const filename = path.basename(parsedUrl.searchParams.get('file') || '');
|
|
1182
|
+
const filePath = path.join(exportDir, filename);
|
|
1183
|
+
if (!filename || !fs.existsSync(filePath)) {
|
|
1184
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1185
|
+
res.end('File not found');
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
res.writeHead(200, {
|
|
1189
|
+
'Content-Type': 'text/csv',
|
|
1190
|
+
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
1191
|
+
'Access-Control-Allow-Origin': '*',
|
|
1192
|
+
});
|
|
1193
|
+
fs.createReadStream(filePath).pipe(res);
|
|
1194
|
+
} else if (url.startsWith('/api/export')) {
|
|
1195
|
+
const parsedUrl = new URL(url, 'http://localhost');
|
|
1196
|
+
const fmt = (parsedUrl.searchParams.get('format') || 'json').toLowerCase();
|
|
1197
|
+
const data = await collectAll({ detailed: true });
|
|
1198
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1199
|
+
let body: string;
|
|
1200
|
+
let ctype = 'text/plain';
|
|
1201
|
+
let ext = 'txt';
|
|
1202
|
+
if (fmt === 'csv') {
|
|
1203
|
+
body = formatCsv(data);
|
|
1204
|
+
ctype = 'text/csv';
|
|
1205
|
+
ext = 'csv';
|
|
1206
|
+
} else if (fmt === 'tsv') {
|
|
1207
|
+
body = formatTsv(data);
|
|
1208
|
+
ctype = 'text/tab-separated-values';
|
|
1209
|
+
ext = 'tsv';
|
|
1210
|
+
} else if (fmt === 'md' || fmt === 'markdown') {
|
|
1211
|
+
body = formatMarkdown(data);
|
|
1212
|
+
ctype = 'text/markdown';
|
|
1213
|
+
ext = 'md';
|
|
1214
|
+
} else if (fmt === 'html') {
|
|
1215
|
+
body = formatHtml(data);
|
|
1216
|
+
ctype = 'text/html';
|
|
1217
|
+
ext = 'html';
|
|
1218
|
+
} else {
|
|
1219
|
+
body = formatJson(data);
|
|
1220
|
+
ctype = 'application/json';
|
|
1221
|
+
ext = 'json';
|
|
1222
|
+
}
|
|
1223
|
+
res.writeHead(200, {
|
|
1224
|
+
'Content-Type': `${ctype}; charset=utf-8`,
|
|
1225
|
+
'Content-Disposition': `attachment; filename="pyre-snapshot-${ts}.${ext}"`,
|
|
1226
|
+
'Access-Control-Allow-Origin': '*',
|
|
1227
|
+
});
|
|
1228
|
+
res.end(body);
|
|
1229
|
+
} else if (url.startsWith('/api/kill')) {
|
|
1230
|
+
const parsedUrl = new URL(url, 'http://localhost');
|
|
1231
|
+
const pidStr = parsedUrl.searchParams.get('pid');
|
|
1232
|
+
const signal = (parsedUrl.searchParams.get('signal') || 'SIGTERM').toUpperCase();
|
|
1233
|
+
const pid = parseInt(pidStr || '', 10);
|
|
1234
|
+
res.writeHead(200, {
|
|
1235
|
+
'Content-Type': 'application/json',
|
|
1236
|
+
'Access-Control-Allow-Origin': '*',
|
|
1237
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
1238
|
+
});
|
|
1239
|
+
if (!pid || isNaN(pid)) {
|
|
1240
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid PID' }));
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
try {
|
|
1244
|
+
process.kill(pid, signal as NodeJS.Signals);
|
|
1245
|
+
res.end(JSON.stringify({ success: true, pid, signal }));
|
|
1246
|
+
} catch (err: any) {
|
|
1247
|
+
res.end(JSON.stringify({ success: false, error: err.message }));
|
|
1248
|
+
}
|
|
1249
|
+
} else if (url === '/api/config' || url.startsWith('/api/config?')) {
|
|
1250
|
+
res.writeHead(200, {
|
|
1251
|
+
'Content-Type': 'application/json',
|
|
1252
|
+
'Access-Control-Allow-Origin': '*',
|
|
1253
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
1254
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
1255
|
+
});
|
|
1256
|
+
if (req.method === 'POST') {
|
|
1257
|
+
let raw = '';
|
|
1258
|
+
req.on('data', chunk => { raw += chunk; });
|
|
1259
|
+
req.on('end', () => {
|
|
1260
|
+
try {
|
|
1261
|
+
const patch = raw ? JSON.parse(raw) : {};
|
|
1262
|
+
writeConfig(patch);
|
|
1263
|
+
res.end(JSON.stringify({ success: true, config: readConfig(), configFile: CONFIG_FILE }));
|
|
1264
|
+
} catch (e: any) {
|
|
1265
|
+
res.end(JSON.stringify({ success: false, error: e.message }));
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
res.end(JSON.stringify({ success: true, config: readConfig(), configFile: CONFIG_FILE }));
|
|
1271
|
+
} else if (url === '/api/config/reset') {
|
|
1272
|
+
res.writeHead(200, {
|
|
1273
|
+
'Content-Type': 'application/json',
|
|
1274
|
+
'Access-Control-Allow-Origin': '*',
|
|
1275
|
+
});
|
|
1276
|
+
resetConfig();
|
|
1277
|
+
res.end(JSON.stringify({ success: true, config: readConfig() }));
|
|
1278
|
+
} else if (url === '/api/service/status') {
|
|
1279
|
+
res.writeHead(200, {
|
|
1280
|
+
'Content-Type': 'application/json',
|
|
1281
|
+
'Access-Control-Allow-Origin': '*',
|
|
1282
|
+
});
|
|
1283
|
+
res.end(JSON.stringify(getServiceStatus()));
|
|
1284
|
+
} else if (url === '/api/service/toggle') {
|
|
1285
|
+
res.writeHead(200, {
|
|
1286
|
+
'Content-Type': 'application/json',
|
|
1287
|
+
'Access-Control-Allow-Origin': '*',
|
|
1288
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
1289
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
1290
|
+
});
|
|
1291
|
+
let raw = '';
|
|
1292
|
+
req.on('data', chunk => { raw += chunk; });
|
|
1293
|
+
req.on('end', () => {
|
|
1294
|
+
try {
|
|
1295
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
1296
|
+
const enable = body.enabled === true;
|
|
1297
|
+
if (enable) {
|
|
1298
|
+
installService(body.port || 3000);
|
|
1299
|
+
} else {
|
|
1300
|
+
uninstallService();
|
|
1301
|
+
}
|
|
1302
|
+
res.end(JSON.stringify({ success: true, ...getServiceStatus() }));
|
|
1303
|
+
} catch (e: any) {
|
|
1304
|
+
res.end(JSON.stringify({ success: false, error: e.message, ...getServiceStatus() }));
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
return;
|
|
1308
|
+
} else if (url === '/api/alerts/test') {
|
|
1309
|
+
res.writeHead(200, {
|
|
1310
|
+
'Content-Type': 'application/json',
|
|
1311
|
+
'Access-Control-Allow-Origin': '*',
|
|
1312
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
1313
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
1314
|
+
});
|
|
1315
|
+
let raw = '';
|
|
1316
|
+
req.on('data', chunk => { raw += chunk; });
|
|
1317
|
+
req.on('end', async () => {
|
|
1318
|
+
try {
|
|
1319
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
1320
|
+
const cfg = readConfig();
|
|
1321
|
+
const channelConfig = {
|
|
1322
|
+
slackUrl: body.slackAlertUrl || cfg.slackAlertUrl || undefined,
|
|
1323
|
+
discordUrl: body.discordAlertUrl || cfg.discordAlertUrl || undefined,
|
|
1324
|
+
pushoverToken: body.pushoverToken || cfg.pushoverToken || undefined,
|
|
1325
|
+
pushoverUser: body.pushoverUser || cfg.pushoverUser || undefined,
|
|
1326
|
+
ntfyUrl: body.ntfyUrl || cfg.ntfyUrl || undefined,
|
|
1327
|
+
};
|
|
1328
|
+
await sendTestAlert(channelConfig);
|
|
1329
|
+
res.end(JSON.stringify({ success: true, message: 'Test alert dispatched' }));
|
|
1330
|
+
} catch (e: any) {
|
|
1331
|
+
res.end(JSON.stringify({ success: false, error: e.message }));
|
|
1332
|
+
}
|
|
1333
|
+
});
|
|
1334
|
+
return;
|
|
1335
|
+
} else if (url === '/api/doctor') {
|
|
1336
|
+
res.writeHead(200, {
|
|
1337
|
+
'Content-Type': 'application/json',
|
|
1338
|
+
'Access-Control-Allow-Origin': '*',
|
|
1339
|
+
});
|
|
1340
|
+
try {
|
|
1341
|
+
const results = await runDoctor();
|
|
1342
|
+
res.end(JSON.stringify({ success: true, results }));
|
|
1343
|
+
} catch (e: any) {
|
|
1344
|
+
res.end(JSON.stringify({ success: false, error: e.message }));
|
|
1345
|
+
}
|
|
1026
1346
|
} else {
|
|
1027
1347
|
res.writeHead(404);
|
|
1028
1348
|
res.end('Not found');
|
|
@@ -1041,24 +1361,34 @@ async function runWebCommand(): Promise<number> {
|
|
|
1041
1361
|
}
|
|
1042
1362
|
});
|
|
1043
1363
|
|
|
1364
|
+
const host = bindHost || opts.host || (opts.lan === true ? '0.0.0.0' : (opts.lan === false || opts.noLan) ? '127.0.0.1' : '0.0.0.0');
|
|
1044
1365
|
const ip = detectLocalIP() || 'localhost';
|
|
1366
|
+
const isLan = host === '0.0.0.0' || (!['127.0.0.1', 'localhost', '::1'].includes(host));
|
|
1045
1367
|
|
|
1046
1368
|
return new Promise((resolve) => {
|
|
1047
|
-
server.listen(port,
|
|
1369
|
+
server.listen(port, host, () => {
|
|
1048
1370
|
const address = server.address();
|
|
1049
1371
|
const actualPort = typeof address === 'object' && address ? address.port : port;
|
|
1050
1372
|
|
|
1051
1373
|
console.log(chalk.bold(`\n pyre web server running on port ${actualPort}`));
|
|
1052
1374
|
console.log(chalk.dim(` Local URL: http://localhost:${actualPort}/`));
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1375
|
+
if (isLan) {
|
|
1376
|
+
console.log(chalk.hex('#ff6a39').bold(` Network URL: http://${ip}:${actualPort}/ (Accessible on same Wi-Fi / LAN)`));
|
|
1377
|
+
console.log(chalk.dim(` API: http://${ip}:${actualPort}/api`));
|
|
1378
|
+
console.log(chalk.dim(` Metrics: http://${ip}:${actualPort}/metrics`));
|
|
1379
|
+
console.log(chalk.dim(` SSE Stream: http://${ip}:${actualPort}/api/stream`));
|
|
1380
|
+
} else {
|
|
1381
|
+
console.log(chalk.dim(` Network: Disabled (localhost only for security)`));
|
|
1382
|
+
console.log(chalk.dim(` API: http://localhost:${actualPort}/api`));
|
|
1383
|
+
console.log(chalk.dim(` Metrics: http://localhost:${actualPort}/metrics`));
|
|
1384
|
+
console.log(chalk.dim(` SSE Stream: http://localhost:${actualPort}/api/stream`));
|
|
1385
|
+
}
|
|
1057
1386
|
|
|
1058
1387
|
resolve(actualPort);
|
|
1059
1388
|
});
|
|
1060
1389
|
|
|
1061
1390
|
process.once('SIGINT', () => {
|
|
1391
|
+
stopWebLogging();
|
|
1062
1392
|
server.close();
|
|
1063
1393
|
process.exit(0);
|
|
1064
1394
|
});
|
|
@@ -1078,19 +1408,147 @@ async function openBrowserUrl(url: string): Promise<void> {
|
|
|
1078
1408
|
}
|
|
1079
1409
|
|
|
1080
1410
|
async function runUiCommand(): Promise<void> {
|
|
1411
|
+
let bindHost = '127.0.0.1';
|
|
1412
|
+
|
|
1413
|
+
if (opts.lan === true || opts.host === '0.0.0.0') {
|
|
1414
|
+
bindHost = '0.0.0.0';
|
|
1415
|
+
} else if (opts.lan === false || opts.noLan || opts.host === '127.0.0.1' || opts.host === 'localhost') {
|
|
1416
|
+
bindHost = '127.0.0.1';
|
|
1417
|
+
} else if (opts.host) {
|
|
1418
|
+
bindHost = opts.host;
|
|
1419
|
+
} else if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
1420
|
+
const ip = detectLocalIP();
|
|
1421
|
+
console.log(chalk.bold('\n pyre ui'));
|
|
1422
|
+
console.log(chalk.yellow(' ⚠️ Security Notice: Exposing pyre allows any device on your Wi-Fi / LAN'));
|
|
1423
|
+
console.log(chalk.yellow(' to inspect system telemetry, export logs, and control processes.'));
|
|
1424
|
+
const netHint = ip ? ` (${ip})` : '';
|
|
1425
|
+
|
|
1426
|
+
const rl = readline.createInterface({
|
|
1427
|
+
input: process.stdin,
|
|
1428
|
+
output: process.stdout,
|
|
1429
|
+
});
|
|
1430
|
+
try {
|
|
1431
|
+
const answer = await new Promise<string>((resolve) => {
|
|
1432
|
+
rl.question(chalk.hex('#ff6a39').bold(` Expose to Network URL (Accessible on same Wi-Fi / LAN)${netHint}? [y/N]: `), (ans) => {
|
|
1433
|
+
resolve(ans.trim().toLowerCase());
|
|
1434
|
+
});
|
|
1435
|
+
rl.once('close', () => resolve(''));
|
|
1436
|
+
});
|
|
1437
|
+
if (answer === 'y' || answer === 'yes') {
|
|
1438
|
+
bindHost = '0.0.0.0';
|
|
1439
|
+
} else {
|
|
1440
|
+
bindHost = '127.0.0.1';
|
|
1441
|
+
}
|
|
1442
|
+
} finally {
|
|
1443
|
+
rl.close();
|
|
1444
|
+
}
|
|
1445
|
+
} else {
|
|
1446
|
+
bindHost = '127.0.0.1';
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1081
1449
|
// Use a random ephemeral port to avoid collisions
|
|
1082
1450
|
opts.port = '0';
|
|
1083
|
-
const port = await runWebCommand();
|
|
1451
|
+
const port = await runWebCommand(bindHost);
|
|
1084
1452
|
const url = `http://localhost:${port}`;
|
|
1085
1453
|
|
|
1086
1454
|
if (process.platform === 'darwin') {
|
|
1087
1455
|
const { run } = await import('./monitors/run.js');
|
|
1088
1456
|
const hasSwift = (await run('which swift 2>/dev/null', '')).trim().length > 0;
|
|
1089
1457
|
if (hasSwift) {
|
|
1458
|
+
const isCompact = !!opts.compact;
|
|
1459
|
+
const isAlwaysOnTop = !!opts.ontop;
|
|
1460
|
+
const widthExpr = isCompact ? '420.0' : 'min(1180.0, screen.width - 80)';
|
|
1461
|
+
const heightExpr = isCompact ? '280.0' : 'min(820.0, screen.height - 80)';
|
|
1462
|
+
const windowTitle = isCompact ? 'Pyre Widget' : 'Pyre';
|
|
1463
|
+
const minSizeExpr = isCompact ? 'NSSize(width: 320, height: 200)' : 'NSSize(width: 860, height: 560)';
|
|
1464
|
+
|
|
1090
1465
|
const swiftScript = `
|
|
1091
1466
|
import Cocoa
|
|
1092
1467
|
import WebKit
|
|
1093
1468
|
|
|
1469
|
+
ProcessInfo.processInfo.processName = "Pyre"
|
|
1470
|
+
|
|
1471
|
+
func makePyreIcon() -> NSImage? {
|
|
1472
|
+
let size = NSSize(width: 512, height: 512)
|
|
1473
|
+
let img = NSImage(size: size)
|
|
1474
|
+
img.lockFocus()
|
|
1475
|
+
|
|
1476
|
+
// 1. Apple-style squircle background
|
|
1477
|
+
let pad: CGFloat = 36.0
|
|
1478
|
+
let bgRect = NSRect(x: pad, y: pad, width: size.width - pad * 2, height: size.height - pad * 2)
|
|
1479
|
+
let squircle = NSBezierPath(roundedRect: bgRect, xRadius: 104, yRadius: 104)
|
|
1480
|
+
|
|
1481
|
+
// Dark obsidian/slate gradient (matching official Pyre liquid-glass design)
|
|
1482
|
+
if let bgGrad = NSGradient(colors: [
|
|
1483
|
+
NSColor(red: 0.114, green: 0.133, blue: 0.176, alpha: 1.0),
|
|
1484
|
+
NSColor(red: 0.039, green: 0.043, blue: 0.059, alpha: 1.0)
|
|
1485
|
+
]) {
|
|
1486
|
+
bgGrad.draw(in: squircle, angle: 270)
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// Subtle inner rim highlight
|
|
1490
|
+
NSColor(white: 1.0, alpha: 0.12).setStroke()
|
|
1491
|
+
squircle.lineWidth = 2.5
|
|
1492
|
+
squircle.stroke()
|
|
1493
|
+
|
|
1494
|
+
// 2. Official Pyre Flame Vector Path (exact SVG bezier curves from Pyre design)
|
|
1495
|
+
func pt(_ x: CGFloat, _ y: CGFloat) -> NSPoint {
|
|
1496
|
+
let s: CGFloat = 3.65
|
|
1497
|
+
let cx: CGFloat = 256.0
|
|
1498
|
+
let cy: CGFloat = 256.0
|
|
1499
|
+
let px = cx + (x - 50.0) * s
|
|
1500
|
+
let py = cy - (y - 49.0) * s
|
|
1501
|
+
return NSPoint(x: px, y: py)
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
let flame = NSBezierPath()
|
|
1505
|
+
flame.windingRule = .evenOdd
|
|
1506
|
+
|
|
1507
|
+
// Outer flame contour
|
|
1508
|
+
flame.move(to: pt(50, 5))
|
|
1509
|
+
flame.curve(to: pt(79, 60), controlPoint1: pt(53, 22), controlPoint2: pt(79, 35))
|
|
1510
|
+
flame.curve(to: pt(50, 93), controlPoint1: pt(79, 79), controlPoint2: pt(67, 93))
|
|
1511
|
+
flame.curve(to: pt(21, 60), controlPoint1: pt(33, 93), controlPoint2: pt(21, 79))
|
|
1512
|
+
flame.curve(to: pt(33, 32), controlPoint1: pt(21, 48), controlPoint2: pt(27, 40))
|
|
1513
|
+
flame.curve(to: pt(45, 47), controlPoint1: pt(35, 42), controlPoint2: pt(40, 47))
|
|
1514
|
+
flame.curve(to: pt(50, 5), controlPoint1: pt(42, 30), controlPoint2: pt(44, 16))
|
|
1515
|
+
flame.close()
|
|
1516
|
+
|
|
1517
|
+
// Inner flame cutout (even-odd winding rule cuts this out)
|
|
1518
|
+
flame.move(to: pt(50, 50))
|
|
1519
|
+
flame.curve(to: pt(64, 74), controlPoint1: pt(58, 60), controlPoint2: pt(64, 67))
|
|
1520
|
+
flame.curve(to: pt(50, 87), controlPoint1: pt(64, 82), controlPoint2: pt(58, 87))
|
|
1521
|
+
flame.curve(to: pt(36, 74), controlPoint1: pt(42, 87), controlPoint2: pt(36, 82))
|
|
1522
|
+
flame.curve(to: pt(50, 50), controlPoint1: pt(36, 67), controlPoint2: pt(42, 60))
|
|
1523
|
+
flame.close()
|
|
1524
|
+
|
|
1525
|
+
// Drop shadow under flame
|
|
1526
|
+
let ctx = NSGraphicsContext.current
|
|
1527
|
+
ctx?.saveGraphicsState()
|
|
1528
|
+
let shadow = NSShadow()
|
|
1529
|
+
shadow.shadowColor = NSColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 0.45)
|
|
1530
|
+
shadow.shadowOffset = NSSize(width: 0, height: -8)
|
|
1531
|
+
shadow.shadowBlurRadius = 16
|
|
1532
|
+
shadow.set()
|
|
1533
|
+
|
|
1534
|
+
// Silver-white gradient fill for the flame mark
|
|
1535
|
+
if let flameGrad = NSGradient(colors: [
|
|
1536
|
+
NSColor(white: 1.0, alpha: 1.0),
|
|
1537
|
+
NSColor(red: 0.60, green: 0.64, blue: 0.72, alpha: 1.0)
|
|
1538
|
+
]) {
|
|
1539
|
+
flameGrad.draw(in: flame, angle: 270)
|
|
1540
|
+
}
|
|
1541
|
+
ctx?.restoreGraphicsState()
|
|
1542
|
+
|
|
1543
|
+
// Top glint highlight
|
|
1544
|
+
let glintRect = NSRect(x: 64, y: 468, width: size.width - 128, height: 1.5)
|
|
1545
|
+
NSColor(white: 1.0, alpha: 0.22).setFill()
|
|
1546
|
+
NSBezierPath(roundedRect: glintRect, xRadius: 1, yRadius: 1).fill()
|
|
1547
|
+
|
|
1548
|
+
img.unlockFocus()
|
|
1549
|
+
return img
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1094
1552
|
// WKWebView subclass that allows the window to be dragged from any pixel
|
|
1095
1553
|
class DraggableWebView: WKWebView {
|
|
1096
1554
|
override var mouseDownCanMoveWindow: Bool { return true }
|
|
@@ -1103,12 +1561,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
1103
1561
|
let serverPort = ${port}
|
|
1104
1562
|
|
|
1105
1563
|
func applicationDidFinishLaunching(_ n: Notification) {
|
|
1564
|
+
// Main Application Menu
|
|
1565
|
+
let mainMenu = NSMenu()
|
|
1566
|
+
let appMenuItem = NSMenuItem()
|
|
1567
|
+
mainMenu.addItem(appMenuItem)
|
|
1568
|
+
let appMenu = NSMenu(title: "Pyre")
|
|
1569
|
+
appMenuItem.submenu = appMenu
|
|
1570
|
+
appMenu.addItem(withTitle: "About Pyre", action: nil, keyEquivalent: "")
|
|
1571
|
+
appMenu.addItem(NSMenuItem.separator())
|
|
1572
|
+
appMenu.addItem(withTitle: "Quit Pyre", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
|
1573
|
+
NSApp.mainMenu = mainMenu
|
|
1574
|
+
|
|
1106
1575
|
// Window
|
|
1107
|
-
let isCompact = ${!!opts.compact}
|
|
1108
|
-
let isAlwaysOnTop = ${!!opts.ontop}
|
|
1109
1576
|
let screen = NSScreen.main?.visibleFrame ?? NSRect(x:0, y:0, width:1280, height:800)
|
|
1110
|
-
let W =
|
|
1111
|
-
let H =
|
|
1577
|
+
let W = ${widthExpr}
|
|
1578
|
+
let H = ${heightExpr}
|
|
1112
1579
|
let rect = NSRect(x: screen.minX + (screen.width - W) / 2,
|
|
1113
1580
|
y: screen.minY + (screen.height - H) / 2,
|
|
1114
1581
|
width: W, height: H)
|
|
@@ -1116,12 +1583,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
1116
1583
|
window = NSWindow(contentRect: rect,
|
|
1117
1584
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
1118
1585
|
backing: .buffered, defer: false)
|
|
1119
|
-
window.title =
|
|
1120
|
-
window.minSize =
|
|
1121
|
-
|
|
1122
|
-
window.level = .floating
|
|
1123
|
-
}
|
|
1124
|
-
window.appearance = NSAppearance(named: .darkAqua)
|
|
1586
|
+
window.title = "${windowTitle}"
|
|
1587
|
+
window.minSize = ${minSizeExpr}
|
|
1588
|
+
${isAlwaysOnTop ? ' window.level = .floating\n' : ''} window.appearance = NSAppearance(named: .darkAqua)
|
|
1125
1589
|
window.backgroundColor = NSColor(white: 0, alpha: 1)
|
|
1126
1590
|
window.isReleasedWhenClosed = false
|
|
1127
1591
|
window.delegate = self
|
|
@@ -1135,12 +1599,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
1135
1599
|
|
|
1136
1600
|
// Load the dashboard HTML from the local server
|
|
1137
1601
|
var req = URLRequest(url: URL(string: "http://localhost:\\(serverPort)/")!)
|
|
1138
|
-
req.cachePolicy = .
|
|
1602
|
+
req.cachePolicy = .reloadIgnoringLocalCacheData
|
|
1139
1603
|
webView.load(req)
|
|
1140
1604
|
|
|
1141
1605
|
window.contentView?.addSubview(webView)
|
|
1142
1606
|
window.makeKeyAndOrderFront(nil)
|
|
1143
1607
|
NSApp.activate(ignoringOtherApps: true)
|
|
1608
|
+
let icon = makePyreIcon()
|
|
1609
|
+
NSApp.applicationIconImage = icon
|
|
1610
|
+
NSApp.dockTile.display()
|
|
1144
1611
|
|
|
1145
1612
|
// Start native data timer (URLSession has full network access — no sandbox issues)
|
|
1146
1613
|
// We wait 1.5 s for the page to load, then start pushing data every 2 s.
|
|
@@ -1195,7 +1662,7 @@ app.run()
|
|
|
1195
1662
|
|
|
1196
1663
|
const { spawn } = await import('node:child_process');
|
|
1197
1664
|
console.log(chalk.cyan(`\n Launching native UI window...`));
|
|
1198
|
-
const ui = spawn('swift', [scriptPath], { stdio: 'inherit' });
|
|
1665
|
+
const ui = spawn('swift', ['-suppress-warnings', scriptPath], { stdio: 'inherit' });
|
|
1199
1666
|
|
|
1200
1667
|
ui.on('close', () => {
|
|
1201
1668
|
console.log(chalk.dim('UI window closed, shutting down server...'));
|
|
@@ -1210,7 +1677,34 @@ app.run()
|
|
|
1210
1677
|
}
|
|
1211
1678
|
}
|
|
1212
1679
|
|
|
1213
|
-
|
|
1680
|
+
if (process.platform === 'win32') {
|
|
1681
|
+
const scriptPath = path.join(
|
|
1682
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
1683
|
+
'monitors',
|
|
1684
|
+
'platform',
|
|
1685
|
+
'windows-ui.ps1'
|
|
1686
|
+
);
|
|
1687
|
+
const { spawn } = await import('node:child_process');
|
|
1688
|
+
console.log(chalk.cyan(`\n Launching native UI window...`));
|
|
1689
|
+
const ui = spawn(
|
|
1690
|
+
'powershell.exe',
|
|
1691
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, '-Port', String(port)],
|
|
1692
|
+
{ stdio: 'inherit' }
|
|
1693
|
+
);
|
|
1694
|
+
|
|
1695
|
+
ui.on('close', () => {
|
|
1696
|
+
console.log(chalk.dim('UI window closed, shutting down server...'));
|
|
1697
|
+
process.exit(0);
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
process.once('SIGINT', () => {
|
|
1701
|
+
ui.kill();
|
|
1702
|
+
process.exit(0);
|
|
1703
|
+
});
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Cross-platform browser / app-mode launch for Linux, or macOS without Swift
|
|
1214
1708
|
console.log(chalk.cyan(`\n Opening live UI dashboard at ${url}...`));
|
|
1215
1709
|
await openBrowserUrl(url);
|
|
1216
1710
|
console.log(chalk.dim(' Press Ctrl+C to stop server.\n'));
|