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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pyre-cli",
3
- "version": "6.1.0",
3
+ "version": "7.0.0",
4
4
  "description": "CLI Mac monitoring tool: temps, system stats, live dashboard, packet monitor, battery predictor, export",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
package/src/anomalies.ts CHANGED
@@ -13,6 +13,7 @@ export interface AnomalyAlert {
13
13
  value: number;
14
14
  zScore: number;
15
15
  severity: 'warning' | 'critical';
16
+ timestamp?: Date;
16
17
  }
17
18
 
18
19
  const ZSCORE_THRESHOLD = 2.5;
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
+ }
@@ -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: #1e1e2e; color: #cdd6f4; margin: 0; padding: 24px; }
163
- h1 { color: #ff6a39; margin-bottom: 4px; }
164
- .meta { color: #a6adc8; margin-bottom: 24px; }
165
- h2 { color: #89b4fa; margin-top: 28px; border-bottom: 1px solid #313244; padding-bottom: 6px; }
166
- table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
167
- td { padding: 6px 10px; border-bottom: 1px solid #313244; vertical-align: top; }
168
- td.label { color: #a6adc8; width: 22%; font-weight: 500; }
169
- td.value { color: #cdd6f4; }
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)} · ${escapeHtml(data.timestamp)}</div>
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 + ' &nbsp; CPU: ' + p.cpu.toFixed(1) + '% &nbsp; MEM: ' + p.mem.toFixed(1) + '% &nbsp; ' + 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
  }
@@ -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('┌' + topInner + '┐');
94
- const bottom = borderAccent('└' + '─'.repeat(innerW) + '┘');
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 filled = Math.round((pct / 100) * w);
107
- const empty = w - filled;
108
- const filledStr = '█'.repeat(filled);
109
- const emptyStr = '░'.repeat(empty);
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(filledStr) + chalk.dim(emptyStr);
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 filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width);
145
- const empty = width - filled;
146
- const filledStr = '█'.repeat(Math.max(0, filled));
147
- const emptyStr = '░'.repeat(Math.max(0, empty));
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(filledStr + emptyStr);
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 filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * barW);
157
- const empty = barW - filled;
158
- const barStr = '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, empty));
159
- const color = percent > 90 ? chalk.red : percent > 70 ? chalk.yellow : chalk.green;
160
- return `${chalk.dim(label.padEnd(labelW))}${color(barStr)} ${pctText}`;
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
- lines.push(theme.cpu(sparkline(opts.history.cpuUsage, { min: 0, max: 100 })));
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
- // --- Box 1: 2 mem ---
750
- const memLines: string[] = [];
751
- memLines.push(`RAM [${gaugeBar(data.memory.usagePercent, Math.max(4, contentWidth - 12))}] ${data.memory.usagePercent}%`);
752
- memLines.push(`Used: ${formatBytes(data.memory.used)} / ${formatBytes(data.memory.total)}`);
753
- memLines.push(`Free: ${formatBytes(data.memory.free)} · Cache: ${formatBytes(data.memory.total - data.memory.free - data.memory.used)}`);
754
- if (data.memory.swapTotal > 0) {
755
- const swapPct = Math.round((data.memory.swapUsed / data.memory.swapTotal) * 100);
756
- memLines.push(`Swap [${gaugeBar(swapPct, Math.max(4, contentWidth - 12))}] ${swapPct}%`);
757
- }
758
- out.push(...panel('2 mem', memLines, width, theme.mem, theme.border, memLines.length, `${formatBytes(data.memory.used)}`));
759
-
760
- // --- Box 2: 0 disk ---
761
- if (data.disk.length) {
762
- const diskLines: string[] = [];
763
- for (const d of data.disk.slice(0, 2)) {
764
- const capNum = parseInt(d.capacity, 10) || 0;
765
- const mountShort = truncatePlain(d.mountpoint, 8);
766
- let ioStr = '';
767
- if (d.readBytesSec !== undefined || d.writeBytesSec !== undefined) {
768
- const rStr = formatBytes(d.readBytesSec || 0);
769
- const wStr = formatBytes(d.writeBytesSec || 0);
770
- ioStr = ` R:${rStr}/s W:${wStr}/s`;
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
- netLines.push(`down ▼ ${formatBytes(rxRate)}/s (${formatBytes(data.network.rxBytes)})`);
786
- if (opts.history?.netRxRate.length) {
787
- netLines.push(chalk.cyan(fitVisible(sparkline(opts.history.netRxRate, {}), contentWidth)));
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
- netLines.push(`up ▲ ${formatBytes(txRate)}/s (${formatBytes(data.network.txBytes)})`);
791
- if (opts.history?.netTxRate.length) {
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
 
@@ -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('--port <port>', 'Port number for web server mode', '3000')
69
- .option('--web-auth', 'Enable Basic Authentication for web server')
70
- .option('--web-user <user>', 'Username for web server authentication', 'admin')
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
- const alertCpu = opts.alertCpu ? parseInt(opts.alertCpu, 10) : undefined;
195
- const alertTemp = opts.alertTemp ? parseInt(opts.alertTemp, 10) : undefined;
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
- // 2. Health check endpoint
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
- htmlContent = fs.readFileSync(indexPath, 'utf-8');
595
+ res.end(fs.readFileSync(indexPath, 'utf-8'));
584
596
  } else {
585
597
  const data = await collectAll({ detailed: true });
586
- htmlContent = formatHtml(data);
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(` Dashboard: http://0.0.0.0:${port}/`));
606
- console.log(chalk.dim(` API: http://0.0.0.0:${port}/api`));
607
- console.log(chalk.dim(` SSE Stream: http://0.0.0.0:${port}/api/stream`));
608
- if (authEnabled) {
609
- console.log(chalk.yellow(` Authentication: Enabled (${authUser}:${authPass})`));
610
- }
611
- console.log(chalk.dim(' Press Ctrl+C to stop\n'));
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', () => {