pyre-cli 6.1.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -13
- package/dist/index.js +479 -184
- package/package.json +1 -1
- package/src/anomalies.ts +1 -0
- package/src/fleet.ts +31 -0
- package/src/formatters/output.ts +107 -10
- package/src/formatters/render.ts +111 -63
- package/src/formatters/types.ts +6 -1
- package/src/index.ts +73 -47
- package/src/live/render.ts +30 -2
- package/src/live/state.ts +15 -8
- package/src/state/config.ts +6 -0
- package/src/xbar.ts +53 -0
package/package.json
CHANGED
package/src/anomalies.ts
CHANGED
package/src/fleet.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { formatTable } from './formatters/render.js';
|
|
4
|
+
import type { StatsData } from './monitors/types.js';
|
|
5
|
+
|
|
6
|
+
export function runFleetCommand(hosts: string[]) {
|
|
7
|
+
console.log(chalk.cyan(`Fetching data from ${hosts.length} hosts...`));
|
|
8
|
+
const results: { host: string; data?: StatsData; error?: string }[] = [];
|
|
9
|
+
|
|
10
|
+
for (const host of hosts) {
|
|
11
|
+
try {
|
|
12
|
+
// Connect to each host via ssh and run pyre
|
|
13
|
+
const out = execSync(`ssh ${host} "pyre --once --json"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
|
|
14
|
+
const data = JSON.parse(out) as StatsData;
|
|
15
|
+
results.push({ host, data });
|
|
16
|
+
} catch (e: any) {
|
|
17
|
+
results.push({ host, error: e.message });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Very basic grid layout for fleet
|
|
22
|
+
for (const result of results) {
|
|
23
|
+
console.log(chalk.bold.blue(`\n=== ${result.host} ===\n`));
|
|
24
|
+
if (result.error) {
|
|
25
|
+
console.log(chalk.red(`Error connecting to host: ${result.error}`));
|
|
26
|
+
} else if (result.data) {
|
|
27
|
+
// Print a compact table for each host
|
|
28
|
+
console.log(formatTable(result.data, { width: process.stdout.columns || 80, activePanel: 'grid' }));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/formatters/output.ts
CHANGED
|
@@ -159,21 +159,118 @@ export function formatHtml(data: StatsData): string {
|
|
|
159
159
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
160
160
|
<title>pyre — ${data.header.hostname}</title>
|
|
161
161
|
<style>
|
|
162
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #
|
|
163
|
-
h1 { color: #ff6a39; margin-bottom: 4px; }
|
|
164
|
-
.meta { color: #
|
|
165
|
-
h2 { color: #
|
|
166
|
-
table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
|
|
167
|
-
td { padding:
|
|
168
|
-
td.label { color: #
|
|
169
|
-
td.value { color: #
|
|
162
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0908; color: #efe9dc; margin: 0; padding: 24px; }
|
|
163
|
+
h1 { color: #ff6a39; margin-bottom: 4px; font-weight: 800; letter-spacing: -0.02em; }
|
|
164
|
+
.meta { color: #9c9384; margin-bottom: 24px; font-size: 0.9rem; }
|
|
165
|
+
h2 { color: #ffb130; margin-top: 28px; border-bottom: 1px solid #211d18; padding-bottom: 6px; font-size: 1.1rem; }
|
|
166
|
+
table { width: 100%; border-collapse: collapse; margin-bottom: 16px; font-family: monospace; }
|
|
167
|
+
td { padding: 8px 12px; border-bottom: 1px solid #1a1712; vertical-align: top; font-size: 0.9rem; }
|
|
168
|
+
td.label { color: #9c9384; width: 22%; font-weight: 600; }
|
|
169
|
+
td.value { color: #efe9dc; }
|
|
170
170
|
tr:last-child td { border-bottom: none; }
|
|
171
171
|
</style>
|
|
172
172
|
</head>
|
|
173
173
|
<body>
|
|
174
|
-
<h1>PYRE</h1>
|
|
175
|
-
<div class="meta">${escapeHtml(data.header.hostname)} · ${escapeHtml(data.header.os)} · up ${escapeHtml(data.header.uptime)} ·
|
|
174
|
+
<h1>PYRE <span style="font-size:0.5em; color:#82c774; font-weight:normal;">● LIVE PORTAL</span></h1>
|
|
175
|
+
<div class="meta" id="header-meta">${escapeHtml(data.header.hostname)} · ${escapeHtml(data.header.os)} · up ${escapeHtml(data.header.uptime)} · <span id="timestamp-val">${escapeHtml(data.timestamp)}</span></div>
|
|
176
|
+
<div id="portal-content">
|
|
176
177
|
${sections.join('\n ')}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<script>
|
|
181
|
+
function renderStats(data) {
|
|
182
|
+
if (!data) return;
|
|
183
|
+
const tsEl = document.getElementById('timestamp-val');
|
|
184
|
+
if (tsEl && data.timestamp) tsEl.textContent = data.timestamp;
|
|
185
|
+
|
|
186
|
+
function formatBytes(bytes) {
|
|
187
|
+
if (!bytes || bytes < 0) return '0 B';
|
|
188
|
+
if (bytes < 1024) return bytes + ' B';
|
|
189
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
190
|
+
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
191
|
+
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function row(label, val) {
|
|
195
|
+
return '<tr><td class="label">' + label + '</td><td class="value">' + val + '</td></tr>';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let html = '';
|
|
199
|
+
|
|
200
|
+
// CPU & Memory
|
|
201
|
+
html += '<h2>CPU & Memory</h2><table>';
|
|
202
|
+
if (data.cpu) {
|
|
203
|
+
html += row('Brand', data.cpu.brand || 'Unknown');
|
|
204
|
+
html += row('Cores', (data.cpu.physicalCores || '') + '/' + (data.cpu.cores || '') + ' (phys/log)');
|
|
205
|
+
html += row('Usage', (data.cpu.usage || 0) + '%');
|
|
206
|
+
if (data.cpu.loadAvg) html += row('Load Avg', data.cpu.loadAvg.map(l => l.toFixed(2)).join(' '));
|
|
207
|
+
if (data.cpu.temperature) html += row('Temperature', data.cpu.temperature.toFixed(1) + '°C');
|
|
208
|
+
}
|
|
209
|
+
if (data.memory) {
|
|
210
|
+
html += row('Memory Usage', data.memory.usagePercent + '% (' + formatBytes(data.memory.used) + ' / ' + formatBytes(data.memory.total) + ')');
|
|
211
|
+
html += row('Swap Used', formatBytes(data.memory.swapUsed) + ' / ' + formatBytes(data.memory.swapTotal));
|
|
212
|
+
}
|
|
213
|
+
html += '</table>';
|
|
214
|
+
|
|
215
|
+
// GPU
|
|
216
|
+
if (data.gpu) {
|
|
217
|
+
html += '<h2>GPU</h2><table>';
|
|
218
|
+
html += row('Model', data.gpu.model || '');
|
|
219
|
+
html += row('Utilization', (data.gpu.utilization || 0) + '%');
|
|
220
|
+
html += row('Memory', formatBytes(data.gpu.memory));
|
|
221
|
+
if (data.gpu.temperature) html += row('Temperature', data.gpu.temperature.toFixed(1) + '°C');
|
|
222
|
+
html += '</table>';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Network
|
|
226
|
+
if (data.network) {
|
|
227
|
+
html += '<h2>Network</h2><table>';
|
|
228
|
+
html += row('Interface', (data.network.interface || '') + ' (' + (data.network.ip || '') + ')');
|
|
229
|
+
html += row('RX', formatBytes(data.network.rxBytes) + ' / ' + (data.network.rxPackets || 0) + ' pkt');
|
|
230
|
+
html += row('TX', formatBytes(data.network.txBytes) + ' / ' + (data.network.txPackets || 0) + ' pkt');
|
|
231
|
+
html += '</table>';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Disk
|
|
235
|
+
if (data.disk && data.disk.length) {
|
|
236
|
+
html += '<h2>Disk</h2><table>';
|
|
237
|
+
data.disk.forEach(d => {
|
|
238
|
+
let io = '';
|
|
239
|
+
if (d.readBytesSec !== undefined || d.writeBytesSec !== undefined) {
|
|
240
|
+
io = ' | R: ' + formatBytes(d.readBytesSec || 0) + '/s W: ' + formatBytes(d.writeBytesSec || 0) + '/s';
|
|
241
|
+
}
|
|
242
|
+
html += row(d.mountpoint, d.used + ' / ' + d.size + ' (' + d.capacity + ')' + io);
|
|
243
|
+
});
|
|
244
|
+
html += '</table>';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Processes
|
|
248
|
+
if (data.processes && data.processes.length) {
|
|
249
|
+
html += '<h2>Processes (Top ' + Math.min(50, data.processes.length) + ')</h2><table>';
|
|
250
|
+
data.processes.slice(0, 50).forEach(p => {
|
|
251
|
+
html += row(String(p.pid), p.user + ' CPU: ' + p.cpu.toFixed(1) + '% MEM: ' + p.mem.toFixed(1) + '% ' + p.command);
|
|
252
|
+
});
|
|
253
|
+
html += '</table>';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const contentEl = document.getElementById('portal-content');
|
|
257
|
+
if (contentEl) contentEl.innerHTML = html;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (window.EventSource) {
|
|
261
|
+
const evtSource = new EventSource('/api/stream');
|
|
262
|
+
evtSource.onmessage = function(event) {
|
|
263
|
+
try {
|
|
264
|
+
const data = JSON.parse(event.data);
|
|
265
|
+
renderStats(data);
|
|
266
|
+
} catch(e) {}
|
|
267
|
+
};
|
|
268
|
+
} else {
|
|
269
|
+
setInterval(function() {
|
|
270
|
+
fetch('/api').then(r => r.json()).then(renderStats).catch(function(){});
|
|
271
|
+
}, 2000);
|
|
272
|
+
}
|
|
273
|
+
</script>
|
|
177
274
|
</body>
|
|
178
275
|
</html>`;
|
|
179
276
|
}
|
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 } from '../sparkline.js';
|
|
13
|
+
import { sparkline, multiRowSparkline, brailleGraph } 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';
|
|
@@ -90,8 +90,8 @@ export function panel(
|
|
|
90
90
|
} else if (visLen(topInner) < innerW) {
|
|
91
91
|
topInner = topInner + '─'.repeat(innerW - visLen(topInner));
|
|
92
92
|
}
|
|
93
|
-
const top = borderAccent('
|
|
94
|
-
const bottom = borderAccent('
|
|
93
|
+
const top = borderAccent('╭' + topInner + '╮');
|
|
94
|
+
const bottom = borderAccent('╰' + '─'.repeat(innerW) + '╯');
|
|
95
95
|
const body: string[] = [];
|
|
96
96
|
for (let i = 0; i < h; i++) {
|
|
97
97
|
const line = lines[i] ?? '';
|
|
@@ -103,12 +103,19 @@ export function panel(
|
|
|
103
103
|
export function gaugeBar(percent: number, width = 12): string {
|
|
104
104
|
const w = Math.max(1, width);
|
|
105
105
|
const pct = Math.min(100, Math.max(0, percent));
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
const
|
|
106
|
+
const totalEighths = Math.round((pct / 100) * w * 8);
|
|
107
|
+
const fullBlocks = Math.floor(totalEighths / 8);
|
|
108
|
+
const remainder = totalEighths % 8;
|
|
109
|
+
const blocks = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
|
|
110
|
+
|
|
111
|
+
let str = '█'.repeat(fullBlocks);
|
|
112
|
+
if (fullBlocks < w && remainder > 0) {
|
|
113
|
+
str += blocks[remainder];
|
|
114
|
+
}
|
|
115
|
+
const empty = Math.max(0, w - visLen(str));
|
|
116
|
+
|
|
110
117
|
const color = pct > 85 ? chalk.red : pct > 65 ? chalk.yellow : chalk.cyan;
|
|
111
|
-
return color(
|
|
118
|
+
return color(str) + chalk.dim('─'.repeat(empty));
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
/** Join equal-or-uneven-height panel blocks side by side with a gap. */
|
|
@@ -141,23 +148,40 @@ function statRow(label: string, value: string, width = 9): string {
|
|
|
141
148
|
}
|
|
142
149
|
|
|
143
150
|
function bar(percent: number, width = 20): string {
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const
|
|
151
|
+
const w = Math.max(1, width);
|
|
152
|
+
const pct = Math.min(100, Math.max(0, percent));
|
|
153
|
+
const totalEighths = Math.round((pct / 100) * w * 8);
|
|
154
|
+
const fullBlocks = Math.floor(totalEighths / 8);
|
|
155
|
+
const remainder = totalEighths % 8;
|
|
156
|
+
const blocks = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
|
|
157
|
+
|
|
158
|
+
let str = '█'.repeat(fullBlocks);
|
|
159
|
+
if (fullBlocks < w && remainder > 0) {
|
|
160
|
+
str += blocks[remainder];
|
|
161
|
+
}
|
|
162
|
+
const empty = Math.max(0, w - visLen(str));
|
|
148
163
|
const color = percent > 90 ? chalk.red : percent > 70 ? chalk.yellow : chalk.green;
|
|
149
|
-
return color(
|
|
164
|
+
return color(str) + chalk.dim('─'.repeat(empty));
|
|
150
165
|
}
|
|
151
166
|
|
|
152
167
|
function gaugeRow(label: string, percent: number, contentWidth: number): string {
|
|
153
168
|
const labelW = 9;
|
|
154
169
|
const pctText = `${Math.round(percent)}%`;
|
|
155
170
|
const barW = Math.max(6, contentWidth - labelW - pctText.length - 1);
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
171
|
+
const pct = Math.min(100, Math.max(0, percent));
|
|
172
|
+
|
|
173
|
+
const totalEighths = Math.round((pct / 100) * barW * 8);
|
|
174
|
+
const fullBlocks = Math.floor(totalEighths / 8);
|
|
175
|
+
const remainder = totalEighths % 8;
|
|
176
|
+
const blocks = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
|
|
177
|
+
|
|
178
|
+
let str = '█'.repeat(fullBlocks);
|
|
179
|
+
if (fullBlocks < barW && remainder > 0) {
|
|
180
|
+
str += blocks[remainder];
|
|
181
|
+
}
|
|
182
|
+
const empty = Math.max(0, barW - visLen(str));
|
|
183
|
+
const color = pct > 90 ? chalk.red : pct > 70 ? chalk.yellow : chalk.green;
|
|
184
|
+
return `${chalk.dim(label.padEnd(labelW))}${color(str)}${chalk.dim('─'.repeat(empty))} ${pctText}`;
|
|
161
185
|
}
|
|
162
186
|
|
|
163
187
|
export function thermalColor(pressureLevel: number) {
|
|
@@ -360,6 +384,7 @@ export const TAB_DEFS: { id: string; label: string; key: string }[] = [
|
|
|
360
384
|
{ id: 'disk', label: 'Disk', key: '0' },
|
|
361
385
|
{ id: 'process', label: 'Process', key: 'P' },
|
|
362
386
|
{ id: 'p2p', label: 'P2P', key: 'R' },
|
|
387
|
+
{ id: 'anomalies', label: 'Anomalies', key: 'A' },
|
|
363
388
|
];
|
|
364
389
|
|
|
365
390
|
/** The number of leading columns of indent before the tab bar's first character (see `tabBar`). */
|
|
@@ -662,6 +687,20 @@ function activeDetailLines(data: StatsData, activePanel: string, width: number,
|
|
|
662
687
|
}
|
|
663
688
|
return processTableLines(limited, width - 4, theme.border);
|
|
664
689
|
}
|
|
690
|
+
case 'anomalies': {
|
|
691
|
+
if (!opts.anomalyHistory || opts.anomalyHistory.length === 0) return ['No anomalies detected yet.'];
|
|
692
|
+
const lines: string[] = [];
|
|
693
|
+
// Sort by timestamp descending
|
|
694
|
+
const sorted = [...opts.anomalyHistory].sort((a, b) => (b.timestamp?.getTime() || 0) - (a.timestamp?.getTime() || 0));
|
|
695
|
+
for (const a of sorted) {
|
|
696
|
+
const color = a.severity === 'critical' ? chalk.red.bold : chalk.yellow;
|
|
697
|
+
const direction = a.zScore > 0 ? '↑ spike' : '↓ drop';
|
|
698
|
+
const valStr = a.metric.toLowerCase().includes('net') ? `${formatBytes(a.value)}/s` : `${a.value.toFixed(1)}%`;
|
|
699
|
+
const timeStr = a.timestamp ? a.timestamp.toLocaleTimeString() : '';
|
|
700
|
+
lines.push(color(` ${timeStr.padEnd(10)} ⚠ ${a.metric.padEnd(8)}: ${valStr.padEnd(12)} (${direction}, σ=${a.zScore.toFixed(1)})`));
|
|
701
|
+
}
|
|
702
|
+
return lines;
|
|
703
|
+
}
|
|
665
704
|
default:
|
|
666
705
|
return null;
|
|
667
706
|
}
|
|
@@ -735,7 +774,9 @@ function btopCpuBox(data: StatsData, width: number, theme: ThemeColors, opts: Ta
|
|
|
735
774
|
lines.push(chalk.bold(truncatePlain(data.cpu.brand, contentWidth)));
|
|
736
775
|
lines.push(`CPU ${gaugeBar(data.cpu.usage, Math.max(4, contentWidth - 12))} ${data.cpu.usage.toFixed(0)}%`);
|
|
737
776
|
if (opts.history?.cpuUsage.length) {
|
|
738
|
-
|
|
777
|
+
const gMode = opts.graphMode || 'spark';
|
|
778
|
+
const gStr = gMode === 'bar' ? sparkline(opts.history.cpuUsage, { min: 0, max: 100 }) : brailleGraph(opts.history.cpuUsage, { min: 0, max: 100 });
|
|
779
|
+
lines.push(theme.cpu(gStr));
|
|
739
780
|
}
|
|
740
781
|
lines.push(`Load: ${data.cpu.loadAvg.map(l => l.toFixed(2)).join(' ')}`);
|
|
741
782
|
return panel('1 cpu', lines, width, theme.cpu, theme.border, lines.length, rightTitle);
|
|
@@ -745,55 +786,62 @@ function btopCpuBox(data: StatsData, width: number, theme: ThemeColors, opts: Ta
|
|
|
745
786
|
function btopLeftColumn(data: StatsData, width: number, theme: ThemeColors, opts: TableOptions): string[] {
|
|
746
787
|
const contentWidth = width - 4;
|
|
747
788
|
const out: string[] = [];
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
const
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
789
|
+
const layout = opts.panelLayout || ['mem', 'disk', 'net'];
|
|
790
|
+
|
|
791
|
+
for (const panelName of layout) {
|
|
792
|
+
if (panelName === 'mem') {
|
|
793
|
+
// --- Box 1: 2 mem ---
|
|
794
|
+
const memLines: string[] = [];
|
|
795
|
+
memLines.push(`RAM [${gaugeBar(data.memory.usagePercent, Math.max(4, contentWidth - 12))}] ${data.memory.usagePercent}%`);
|
|
796
|
+
memLines.push(`Used: ${formatBytes(data.memory.used)} / ${formatBytes(data.memory.total)}`);
|
|
797
|
+
memLines.push(`Free: ${formatBytes(data.memory.free)} · Cache: ${formatBytes(data.memory.total - data.memory.free - data.memory.used)}`);
|
|
798
|
+
if (data.memory.swapTotal > 0) {
|
|
799
|
+
const swapPct = Math.round((data.memory.swapUsed / data.memory.swapTotal) * 100);
|
|
800
|
+
memLines.push(`Swap [${gaugeBar(swapPct, Math.max(4, contentWidth - 12))}] ${swapPct}%`);
|
|
801
|
+
}
|
|
802
|
+
out.push(...panel('2 mem', memLines, width, theme.mem, theme.border, memLines.length, `${formatBytes(data.memory.used)}`));
|
|
803
|
+
} else if (panelName === 'disk' && data.disk.length) {
|
|
804
|
+
// --- Box 2: 0 disk ---
|
|
805
|
+
const diskLines: string[] = [];
|
|
806
|
+
for (const d of data.disk.slice(0, 2)) {
|
|
807
|
+
const capNum = parseInt(d.capacity, 10) || 0;
|
|
808
|
+
const mountShort = truncatePlain(d.mountpoint, 8);
|
|
809
|
+
let ioStr = '';
|
|
810
|
+
if (d.readBytesSec !== undefined || d.writeBytesSec !== undefined) {
|
|
811
|
+
const rStr = formatBytes(d.readBytesSec || 0);
|
|
812
|
+
const wStr = formatBytes(d.writeBytesSec || 0);
|
|
813
|
+
ioStr = ` R:${rStr}/s W:${wStr}/s`;
|
|
814
|
+
}
|
|
815
|
+
diskLines.push(`${mountShort} ${d.used}/${d.size} [${gaugeBar(capNum, Math.max(3, contentWidth - 18 - ioStr.length))}] ${d.capacity}${ioStr}`);
|
|
816
|
+
}
|
|
817
|
+
const totalRead = data.disk.reduce((acc, d) => acc + (d.readBytesSec || 0), 0);
|
|
818
|
+
const totalWrite = data.disk.reduce((acc, d) => acc + (d.writeBytesSec || 0), 0);
|
|
819
|
+
const diskHeaderRight = `R:${formatBytes(totalRead)}/s W:${formatBytes(totalWrite)}/s`;
|
|
820
|
+
out.push(...panel('0 disk io', diskLines, width, theme.disk, theme.border, diskLines.length, diskHeaderRight));
|
|
821
|
+
} else if (panelName === 'net') {
|
|
822
|
+
// --- Box 3: 7 net ---
|
|
823
|
+
const netLines: string[] = [];
|
|
824
|
+
const rxRate = opts.history?.netRxRate.length ? opts.history.netRxRate[opts.history.netRxRate.length - 1] : 0;
|
|
825
|
+
const txRate = opts.history?.netTxRate.length ? opts.history.netTxRate[opts.history.netTxRate.length - 1] : 0;
|
|
826
|
+
|
|
827
|
+
netLines.push(`down ▼ ${formatBytes(rxRate)}/s (${formatBytes(data.network.rxBytes)})`);
|
|
828
|
+
if (opts.history?.netRxRate.length) {
|
|
829
|
+
const gMode = opts.graphMode || 'spark';
|
|
830
|
+
const gStr = gMode === 'bar' ? sparkline(opts.history.netRxRate, {}) : brailleGraph(opts.history.netRxRate, {});
|
|
831
|
+
netLines.push(chalk.cyan(fitVisible(gStr, contentWidth)));
|
|
771
832
|
}
|
|
772
|
-
diskLines.push(`${mountShort} ${d.used}/${d.size} [${gaugeBar(capNum, Math.max(3, contentWidth - 18 - ioStr.length))}] ${d.capacity}${ioStr}`);
|
|
773
|
-
}
|
|
774
|
-
const totalRead = data.disk.reduce((acc, d) => acc + (d.readBytesSec || 0), 0);
|
|
775
|
-
const totalWrite = data.disk.reduce((acc, d) => acc + (d.writeBytesSec || 0), 0);
|
|
776
|
-
const diskHeaderRight = `R:${formatBytes(totalRead)}/s W:${formatBytes(totalWrite)}/s`;
|
|
777
|
-
out.push(...panel('0 disk io', diskLines, width, theme.disk, theme.border, diskLines.length, diskHeaderRight));
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// --- Box 3: 7 net ---
|
|
781
|
-
const netLines: string[] = [];
|
|
782
|
-
const rxRate = opts.history?.netRxRate.length ? opts.history.netRxRate[opts.history.netRxRate.length - 1] : 0;
|
|
783
|
-
const txRate = opts.history?.netTxRate.length ? opts.history.netTxRate[opts.history.netTxRate.length - 1] : 0;
|
|
784
833
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
834
|
+
netLines.push(`up ▲ ${formatBytes(txRate)}/s (${formatBytes(data.network.txBytes)})`);
|
|
835
|
+
if (opts.history?.netTxRate.length) {
|
|
836
|
+
const gMode = opts.graphMode || 'spark';
|
|
837
|
+
const gStr = gMode === 'bar' ? sparkline(opts.history.netTxRate, {}) : brailleGraph(opts.history.netTxRate, {});
|
|
838
|
+
netLines.push(theme.network(fitVisible(gStr, contentWidth)));
|
|
839
|
+
}
|
|
789
840
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
netLines.push(theme.network(fitVisible(sparkline(opts.history.netTxRate, {}), contentWidth)));
|
|
841
|
+
out.push(...panel('7 net', netLines, width, theme.network, theme.border, netLines.length, `${data.network.interface}`));
|
|
842
|
+
}
|
|
793
843
|
}
|
|
794
844
|
|
|
795
|
-
out.push(...panel('7 net', netLines, width, theme.network, theme.border, netLines.length, `${data.network.interface}`));
|
|
796
|
-
|
|
797
845
|
return out;
|
|
798
846
|
}
|
|
799
847
|
|
package/src/formatters/types.ts
CHANGED
|
@@ -48,6 +48,7 @@ export interface AnomalyAlert {
|
|
|
48
48
|
value: number;
|
|
49
49
|
zScore: number;
|
|
50
50
|
severity: 'warning' | 'critical';
|
|
51
|
+
timestamp?: Date;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export interface TableOptions {
|
|
@@ -66,13 +67,17 @@ export interface TableOptions {
|
|
|
66
67
|
/** Show process tree view instead of flat list. */
|
|
67
68
|
treeView?: boolean;
|
|
68
69
|
/** Active panel for detail/focus view. 'grid' means show all panels. */
|
|
69
|
-
activePanel?: 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p';
|
|
70
|
+
activePanel?: 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p' | 'anomalies';
|
|
70
71
|
/** Active anomaly alerts from the statistical detector. */
|
|
71
72
|
anomalies?: AnomalyAlert[];
|
|
73
|
+
/** Historical anomaly alerts for the anomalies tab. */
|
|
74
|
+
anomalyHistory?: AnomalyAlert[];
|
|
72
75
|
/** Temperature display unit: 'c' or 'f'. */
|
|
73
76
|
tempUnit?: 'c' | 'f';
|
|
74
77
|
/** Rolling history instance for live graphs. */
|
|
75
78
|
history?: History;
|
|
76
79
|
/** Graph mode: 'spark' or 'bar'. */
|
|
77
80
|
graphMode?: 'spark' | 'bar';
|
|
81
|
+
/** Panel layout order. */
|
|
82
|
+
panelLayout?: string[];
|
|
78
83
|
}
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ import { generateZshCompletions, generateBashCompletions, generateFishCompletion
|
|
|
22
22
|
import { runHistoryCommand } from './historyCmd.js';
|
|
23
23
|
import { runDiffCommand } from './diffCmd.js';
|
|
24
24
|
import { readConfig, CONFIG_FILE } from './state/config.js';
|
|
25
|
+
import { generateXbarPlugin } from './xbar.js';
|
|
26
|
+
import { runFleetCommand } from './fleet.js';
|
|
25
27
|
|
|
26
28
|
const config = readConfig();
|
|
27
29
|
|
|
@@ -65,10 +67,9 @@ program
|
|
|
65
67
|
.option('--p2p-deny <ips>', 'Comma-separated list of denied IPs')
|
|
66
68
|
.option('--p2p-audit-log <dir>', 'Directory for P2P audit logs')
|
|
67
69
|
.option('--p2p-hmac-key <key>', 'HMAC key for message signing (default: derived from password)')
|
|
68
|
-
.option('--
|
|
69
|
-
.option('--
|
|
70
|
-
.option('--
|
|
71
|
-
.option('--web-pass <pass>', 'Password for web server authentication', 'pyre');
|
|
70
|
+
.option('--webhook-url <url>', 'URL to POST to on alert')
|
|
71
|
+
.option('--alert-cmd <cmd>', 'Command to execute on alert')
|
|
72
|
+
.option('--port <port>', 'Port number for web server mode', '3000');
|
|
72
73
|
|
|
73
74
|
program.parse(process.argv);
|
|
74
75
|
|
|
@@ -89,6 +90,21 @@ function sanitizeHost(host: string): string {
|
|
|
89
90
|
|
|
90
91
|
function detectLocalIP(): string | null {
|
|
91
92
|
const ifaces = os.networkInterfaces();
|
|
93
|
+
const prioritizedNames = ['en0', 'en1', 'wlan0', 'eth0'];
|
|
94
|
+
|
|
95
|
+
// 1. Try prioritized active interfaces
|
|
96
|
+
for (const name of prioritizedNames) {
|
|
97
|
+
const addrs = ifaces[name];
|
|
98
|
+
if (addrs) {
|
|
99
|
+
for (const addr of addrs) {
|
|
100
|
+
if (addr.family === 'IPv4' && !addr.internal) {
|
|
101
|
+
return addr.address;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 2. Fallback to any non-internal IPv4
|
|
92
108
|
for (const [, addrs] of Object.entries(ifaces)) {
|
|
93
109
|
if (!addrs) continue;
|
|
94
110
|
for (const addr of addrs) {
|
|
@@ -191,8 +207,26 @@ async function main() {
|
|
|
191
207
|
return;
|
|
192
208
|
}
|
|
193
209
|
|
|
194
|
-
|
|
195
|
-
|
|
210
|
+
if (cmd === 'xbar') {
|
|
211
|
+
generateXbarPlugin();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (cmd === 'fleet') {
|
|
216
|
+
const hosts = program.args.slice(1);
|
|
217
|
+
if (hosts.length === 0) {
|
|
218
|
+
console.error(chalk.red('Usage: pyre fleet <host1> [host2] ...'));
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
runFleetCommand(hosts);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (opts.alertCpu) config.cpuAlertPct = Number(opts.alertCpu);
|
|
226
|
+
if (opts.alertTemp) config.tempAlertC = Number(opts.alertTemp);
|
|
227
|
+
if (opts.webhookUrl) config.webhookUrl = opts.webhookUrl;
|
|
228
|
+
if (opts.alertCmd) config.alertCmd = opts.alertCmd;
|
|
229
|
+
if (opts.exportDir) config.exportDir = opts.exportDir;
|
|
196
230
|
const tempUnit = opts.tempUnit === 'f' ? 'f' : 'c';
|
|
197
231
|
|
|
198
232
|
if (cmd === 'live' || (!isExportMode() && !opts.once && cmd !== 'p2p')) {
|
|
@@ -215,8 +249,8 @@ async function main() {
|
|
|
215
249
|
theme: opts.theme || config.theme,
|
|
216
250
|
exportDir: opts.exportDir || config.exportDir,
|
|
217
251
|
autoLog: opts.log || config.autoLog,
|
|
218
|
-
alertCpu,
|
|
219
|
-
alertTemp,
|
|
252
|
+
alertCpu: config.cpuAlertPct,
|
|
253
|
+
alertTemp: config.tempAlertC,
|
|
220
254
|
tempUnit,
|
|
221
255
|
}, splashPromise);
|
|
222
256
|
return;
|
|
@@ -510,37 +544,11 @@ async function runSshCommand(host: string): Promise<void> {
|
|
|
510
544
|
async function runWebCommand(): Promise<void> {
|
|
511
545
|
const http = await import('node:http');
|
|
512
546
|
const port = parseInt(opts.port || process.env.PORT || '3000', 10) || 3000;
|
|
513
|
-
const authEnabled = !!opts.webAuth;
|
|
514
|
-
const authUser = opts.webUser || 'admin';
|
|
515
|
-
const authPass = opts.webPass || 'pyre';
|
|
516
547
|
|
|
517
548
|
const server = http.createServer(async (req, res) => {
|
|
518
|
-
// 1. Basic Authentication Check
|
|
519
|
-
if (authEnabled) {
|
|
520
|
-
const authHeader = req.headers.authorization;
|
|
521
|
-
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
522
|
-
res.writeHead(401, {
|
|
523
|
-
'WWW-Authenticate': 'Basic realm="pyre Web Dashboard"',
|
|
524
|
-
'Content-Type': 'text/plain',
|
|
525
|
-
});
|
|
526
|
-
res.end('401 Unauthorized');
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
const credentials = Buffer.from(authHeader.split(' ')[1], 'base64').toString('utf-8');
|
|
530
|
-
const [user, pass] = credentials.split(':');
|
|
531
|
-
if (user !== authUser || pass !== authPass) {
|
|
532
|
-
res.writeHead(401, {
|
|
533
|
-
'WWW-Authenticate': 'Basic realm="pyre Web Dashboard"',
|
|
534
|
-
'Content-Type': 'text/plain',
|
|
535
|
-
});
|
|
536
|
-
res.end('401 Unauthorized');
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
549
|
const url = req.url || '/';
|
|
542
550
|
|
|
543
|
-
//
|
|
551
|
+
// 1. Health check endpoint
|
|
544
552
|
if (url === '/health' || url === '/api/health') {
|
|
545
553
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
546
554
|
res.end(JSON.stringify({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() }));
|
|
@@ -574,18 +582,21 @@ async function runWebCommand(): Promise<void> {
|
|
|
574
582
|
return;
|
|
575
583
|
}
|
|
576
584
|
|
|
577
|
-
// 4. Static HTML dashboard & API snapshot endpoints
|
|
585
|
+
// 4. Static/Dynamic HTML live dashboard & API snapshot endpoints
|
|
578
586
|
if (url === '/' || url === '/index.html') {
|
|
587
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
588
|
+
const data = await collectAll({ detailed: true });
|
|
589
|
+
const htmlContent = formatHtml(data);
|
|
590
|
+
res.end(htmlContent);
|
|
591
|
+
} else if (url === '/setup') {
|
|
579
592
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
580
593
|
const indexPath = path.join(process.cwd(), 'index.html');
|
|
581
|
-
let htmlContent = '';
|
|
582
594
|
if (fs.existsSync(indexPath)) {
|
|
583
|
-
|
|
595
|
+
res.end(fs.readFileSync(indexPath, 'utf-8'));
|
|
584
596
|
} else {
|
|
585
597
|
const data = await collectAll({ detailed: true });
|
|
586
|
-
|
|
598
|
+
res.end(formatHtml(data));
|
|
587
599
|
}
|
|
588
|
-
res.end(htmlContent);
|
|
589
600
|
} else if (url === '/api' || url === '/api/stats' || url === '/data') {
|
|
590
601
|
const data = await collectAll({ detailed: true });
|
|
591
602
|
res.writeHead(200, {
|
|
@@ -600,15 +611,30 @@ async function runWebCommand(): Promise<void> {
|
|
|
600
611
|
}
|
|
601
612
|
});
|
|
602
613
|
|
|
614
|
+
server.on('error', (err: any) => {
|
|
615
|
+
if (err.code === 'EADDRINUSE') {
|
|
616
|
+
console.log(chalk.red(`\nError: Port ${port} is already in use.`));
|
|
617
|
+
console.log(chalk.yellow(` Try specifying a different port using --port <port>, e.g.:`));
|
|
618
|
+
console.log(chalk.dim(` npx tsx src/index.ts web --port ${port + 1}\n`));
|
|
619
|
+
process.exit(1);
|
|
620
|
+
} else {
|
|
621
|
+
console.log(chalk.red(`Server error: ${err.message}`));
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
const ip = detectLocalIP() || 'localhost';
|
|
627
|
+
|
|
603
628
|
server.listen(port, '0.0.0.0', () => {
|
|
604
629
|
console.log(chalk.bold(`\n pyre web server running on port ${port}`));
|
|
605
|
-
console.log(chalk.dim(`
|
|
606
|
-
console.log(chalk.
|
|
607
|
-
console.log(chalk.dim(`
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
console.log(chalk.dim(
|
|
630
|
+
console.log(chalk.dim(` Local URL: http://localhost:${port}/`));
|
|
631
|
+
console.log(chalk.hex('#ff6a39').bold(` Network URL: http://${ip}:${port}/ (Accessible on same Wi-Fi / LAN)`));
|
|
632
|
+
console.log(chalk.dim(` API: http://${ip}:${port}/api`));
|
|
633
|
+
console.log(chalk.dim(` SSE Stream: http://${ip}:${port}/api/stream`));
|
|
634
|
+
console.log(chalk.yellow(`\n Troubleshooting connection from phone/other devices:`));
|
|
635
|
+
console.log(chalk.dim(` 1. macOS Firewall: System Settings -> Network -> Firewall -> Allow incoming node/pyre connections`));
|
|
636
|
+
console.log(chalk.dim(` 2. Wi-Fi Router: Ensure "AP Isolation" / "Guest Mode" is OFF on your Wi-Fi router`));
|
|
637
|
+
console.log(chalk.dim(` 3. Verify phone is connected to the exact same Wi-Fi network\n`));
|
|
612
638
|
});
|
|
613
639
|
|
|
614
640
|
process.once('SIGINT', () => {
|