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