pyre-cli 6.0.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.
@@ -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
  }
@@ -714,7 +753,7 @@ function btopCpuBox(data: StatsData, width: number, theme: ThemeColors, opts: Ta
714
753
  if (data.cpu.coreUsage && data.cpu.coreUsage.length) {
715
754
  const coreColW = Math.floor((rightW - 1) / 2);
716
755
  const half = Math.ceil(data.cpu.coreUsage.length / 2);
717
- for (let i = 0; i < Math.min(2, half); i++) {
756
+ for (let i = 0; i < half; i++) {
718
757
  const u1 = data.cpu.coreUsage[i];
719
758
  const u2 = data.cpu.coreUsage[i + half];
720
759
  const c1BarW = Math.max(3, coreColW - 8);
@@ -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,46 +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[] = [];
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)));
832
+ }
748
833
 
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
- diskLines.push(`${mountShort} ${d.used}/${d.size} [${gaugeBar(capNum, Math.max(3, contentWidth - 18))}] ${d.capacity}`);
767
- }
768
- out.push(...panel('0 disk io', diskLines, width, theme.disk, theme.border, diskLines.length));
769
- }
770
-
771
- // --- Box 3: 7 net ---
772
- const netLines: string[] = [];
773
- const rxRate = opts.history?.netRxRate.length ? opts.history.netRxRate[opts.history.netRxRate.length - 1] : 0;
774
- const txRate = opts.history?.netTxRate.length ? opts.history.netTxRate[opts.history.netTxRate.length - 1] : 0;
775
-
776
- netLines.push(`down ▼ ${formatBytes(rxRate)}/s (${formatBytes(data.network.rxBytes)})`);
777
- if (opts.history?.netRxRate.length) {
778
- netLines.push(chalk.cyan(fitVisible(sparkline(opts.history.netRxRate, {}), contentWidth)));
779
- }
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
+ }
780
840
 
781
- netLines.push(`up ▲ ${formatBytes(txRate)}/s (${formatBytes(data.network.txBytes)})`);
782
- if (opts.history?.netTxRate.length) {
783
- 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
+ }
784
843
  }
785
844
 
786
- out.push(...panel('7 net', netLines, width, theme.network, theme.border, netLines.length, `${data.network.interface}`));
787
-
788
845
  return out;
789
846
  }
790
847
 
@@ -8,7 +8,11 @@
8
8
  */
9
9
  import chalk from 'chalk';
10
10
 
11
- export type ThemeName = 'default' | 'dracula' | 'cyberpunk' | 'monochrome' | 'nord' | 'gruvbox';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ export type ThemeName = string;
12
16
 
13
17
  export interface ThemeColors {
14
18
  border: (s: string) => string;
@@ -24,16 +28,7 @@ export interface ThemeColors {
24
28
  process: (s: string) => string;
25
29
  }
26
30
 
27
- /**
28
- * Built-in colour themes.
29
- *
30
- * - **default** — muted greys and cyan accents, safe for any terminal.
31
- * - **dracula** — the popular Dracula dark palette.
32
- * - **cyberpunk** — high-contrast neon on black.
33
- * - **monochrome** — white-only, useful for colour-blind users or
34
- * terminals without 256-colour support.
35
- */
36
- export const THEMES: Record<ThemeName, ThemeColors> = {
31
+ export const BUILTIN_THEMES: Record<string, ThemeColors> = {
37
32
  default: {
38
33
  border: chalk.hex('#4b5563'),
39
34
  cpu: chalk.hex('#22d3ee'),
@@ -112,4 +107,44 @@ export const THEMES: Record<ThemeName, ThemeColors> = {
112
107
  graphs: chalk.hex('#fb4934'),
113
108
  process: chalk.hex('#ebdbb2'),
114
109
  },
115
- };
110
+ };
111
+
112
+ export function loadCustomThemes(): Record<string, ThemeColors> {
113
+ const themes = { ...BUILTIN_THEMES };
114
+ const themesDir = path.join(os.homedir(), '.config', 'pyre', 'themes');
115
+ try {
116
+ if (fs.existsSync(themesDir)) {
117
+ const files = fs.readdirSync(themesDir);
118
+ for (const file of files) {
119
+ if (file.endsWith('.json')) {
120
+ const name = path.basename(file, '.json');
121
+ try {
122
+ const raw = fs.readFileSync(path.join(themesDir, file), 'utf-8');
123
+ const data = JSON.parse(raw);
124
+ const buildColor = (val?: string) => (val ? (val.startsWith('#') ? chalk.hex(val) : (chalk as any)[val] || chalk.white) : chalk.white);
125
+ themes[name] = {
126
+ border: buildColor(data.border),
127
+ cpu: buildColor(data.cpu),
128
+ mem: buildColor(data.mem),
129
+ gpu: buildColor(data.gpu),
130
+ power: buildColor(data.power),
131
+ battery: buildColor(data.battery),
132
+ thermal: buildColor(data.thermal),
133
+ network: buildColor(data.network),
134
+ disk: buildColor(data.disk),
135
+ graphs: buildColor(data.graphs),
136
+ process: buildColor(data.process),
137
+ };
138
+ } catch {
139
+ // ignore bad theme file
140
+ }
141
+ }
142
+ }
143
+ }
144
+ } catch {
145
+ // ignore directory read error
146
+ }
147
+ return themes;
148
+ }
149
+
150
+ export const THEMES: Record<string, ThemeColors> = loadCustomThemes();
@@ -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
  }
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { sparkline } from './sparkline.js';
5
+
6
+ export interface HistoryOptions {
7
+ days?: number;
8
+ dir?: string;
9
+ }
10
+
11
+ export function runHistoryCommand(opts: HistoryOptions = {}): void {
12
+ const days = opts.days || 7;
13
+ const dir = opts.dir || './pyre-exports';
14
+
15
+ console.log(chalk.bold(`\n pyre history — Past ${days} Days Trend\n`));
16
+
17
+ if (!fs.existsSync(dir)) {
18
+ console.log(chalk.yellow(` No logs directory found at ${dir}`));
19
+ return;
20
+ }
21
+
22
+ const files = fs.readdirSync(dir).filter(f => f.startsWith('pyre-log-') && f.endsWith('.csv'));
23
+ if (files.length === 0) {
24
+ console.log(chalk.yellow(` No CSV logs found in ${dir}`));
25
+ return;
26
+ }
27
+
28
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
29
+ const cpuPoints: number[] = [];
30
+ const memPoints: number[] = [];
31
+ const tempPoints: number[] = [];
32
+
33
+ for (const file of files.sort()) {
34
+ const fullPath = path.join(dir, file);
35
+ try {
36
+ const content = fs.readFileSync(fullPath, 'utf-8');
37
+ const lines = content.split('\n').slice(1);
38
+ for (const line of lines) {
39
+ if (!line.trim()) continue;
40
+ const parts = line.split(',');
41
+ if (parts.length >= 4) {
42
+ const tsStr = parts[0];
43
+ const ts = new Date(tsStr).getTime();
44
+ if (!isNaN(ts) && ts >= cutoff) {
45
+ const cpu = parseFloat(parts[1]);
46
+ const mem = parseFloat(parts[2]);
47
+ const temp = parseFloat(parts[3]);
48
+ if (!isNaN(cpu)) cpuPoints.push(cpu);
49
+ if (!isNaN(mem)) memPoints.push(mem);
50
+ if (!isNaN(temp)) tempPoints.push(temp);
51
+ }
52
+ }
53
+ }
54
+ } catch {
55
+ // ignore
56
+ }
57
+ }
58
+
59
+ if (cpuPoints.length === 0) {
60
+ console.log(chalk.yellow(` No historical entries recorded within the last ${days} days.`));
61
+ return;
62
+ }
63
+
64
+ console.log(` Data Points: ${cpuPoints.length} logged snapshots`);
65
+ console.log(` CPU Usage: ${sparkline(cpuPoints, { min: 0, max: 100 })} (${(cpuPoints.reduce((a, b) => a + b, 0) / cpuPoints.length).toFixed(1)}% avg)`);
66
+ console.log(` Memory: ${sparkline(memPoints, { min: 0, max: 100 })} (${(memPoints.reduce((a, b) => a + b, 0) / memPoints.length).toFixed(1)}% avg)`);
67
+ if (tempPoints.length) {
68
+ console.log(` Temperature: ${sparkline(tempPoints)} (${(tempPoints.reduce((a, b) => a + b, 0) / tempPoints.length).toFixed(1)}°C avg)`);
69
+ }
70
+ console.log();
71
+ }
package/src/index.ts CHANGED
@@ -17,8 +17,13 @@ import { formatTable, formatJson, formatCsv, formatTsv, formatHtml, formatMarkdo
17
17
  import { startLive, stopLive } from './live/index.js';
18
18
  import { showSplash } from './splash.js';
19
19
  import { P2PServer, P2PClient } from './p2p/index.js';
20
- import { readConfig } from './state/config.js';
21
- import { CONFIG_FILE } from './state/config.js';
20
+ import { runDoctor, printDoctorReport } from './doctor.js';
21
+ import { generateZshCompletions, generateBashCompletions, generateFishCompletions } from './completions.js';
22
+ import { runHistoryCommand } from './historyCmd.js';
23
+ import { runDiffCommand } from './diffCmd.js';
24
+ import { readConfig, CONFIG_FILE } from './state/config.js';
25
+ import { generateXbarPlugin } from './xbar.js';
26
+ import { runFleetCommand } from './fleet.js';
22
27
 
23
28
  const config = readConfig();
24
29
 
@@ -62,6 +67,8 @@ program
62
67
  .option('--p2p-deny <ips>', 'Comma-separated list of denied IPs')
63
68
  .option('--p2p-audit-log <dir>', 'Directory for P2P audit logs')
64
69
  .option('--p2p-hmac-key <key>', 'HMAC key for message signing (default: derived from password)')
70
+ .option('--webhook-url <url>', 'URL to POST to on alert')
71
+ .option('--alert-cmd <cmd>', 'Command to execute on alert')
65
72
  .option('--port <port>', 'Port number for web server mode', '3000');
66
73
 
67
74
  program.parse(process.argv);
@@ -83,6 +90,21 @@ function sanitizeHost(host: string): string {
83
90
 
84
91
  function detectLocalIP(): string | null {
85
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
86
108
  for (const [, addrs] of Object.entries(ifaces)) {
87
109
  if (!addrs) continue;
88
110
  for (const addr of addrs) {
@@ -123,8 +145,21 @@ async function runServerCommand(): Promise<void> {
123
145
  async function main() {
124
146
  const cmd = program.args[0];
125
147
 
126
- if (cmd === 'info') {
127
- await runInfoCommand();
148
+ if (cmd === 'history') {
149
+ const daysIdx = program.args.indexOf('--days');
150
+ const days = daysIdx !== -1 ? parseInt(program.args[daysIdx + 1], 10) || 7 : 7;
151
+ runHistoryCommand({ days });
152
+ return;
153
+ }
154
+
155
+ if (cmd === 'diff') {
156
+ const file1 = program.args[1];
157
+ const file2 = program.args[2];
158
+ if (!file1 || !file2) {
159
+ console.log(chalk.red('Usage: pyre diff <snapshot1.json> <snapshot2.json>'));
160
+ process.exit(1);
161
+ }
162
+ runDiffCommand(file1, file2);
128
163
  return;
129
164
  }
130
165
 
@@ -172,8 +207,26 @@ async function main() {
172
207
  return;
173
208
  }
174
209
 
175
- const alertCpu = opts.alertCpu ? parseInt(opts.alertCpu, 10) : undefined;
176
- 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;
177
230
  const tempUnit = opts.tempUnit === 'f' ? 'f' : 'c';
178
231
 
179
232
  if (cmd === 'live' || (!isExportMode() && !opts.once && cmd !== 'p2p')) {
@@ -196,8 +249,8 @@ async function main() {
196
249
  theme: opts.theme || config.theme,
197
250
  exportDir: opts.exportDir || config.exportDir,
198
251
  autoLog: opts.log || config.autoLog,
199
- alertCpu,
200
- alertTemp,
252
+ alertCpu: config.cpuAlertPct,
253
+ alertTemp: config.tempAlertC,
201
254
  tempUnit,
202
255
  }, splashPromise);
203
256
  return;
@@ -494,17 +547,56 @@ async function runWebCommand(): Promise<void> {
494
547
 
495
548
  const server = http.createServer(async (req, res) => {
496
549
  const url = req.url || '/';
550
+
551
+ // 1. Health check endpoint
552
+ if (url === '/health' || url === '/api/health') {
553
+ res.writeHead(200, { 'Content-Type': 'application/json' });
554
+ res.end(JSON.stringify({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() }));
555
+ return;
556
+ }
557
+
558
+ // 3. Real-time Server-Sent Events (SSE) stream endpoint
559
+ if (url === '/api/stream') {
560
+ res.writeHead(200, {
561
+ 'Content-Type': 'text/event-stream',
562
+ 'Cache-Control': 'no-cache',
563
+ 'Connection': 'keep-alive',
564
+ 'Access-Control-Allow-Origin': '*',
565
+ });
566
+
567
+ const sendStats = async () => {
568
+ try {
569
+ const data = await collectAll({ detailed: true });
570
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
571
+ } catch {
572
+ // ignore stream collect error
573
+ }
574
+ };
575
+
576
+ sendStats();
577
+ const intervalId = setInterval(sendStats, 2000);
578
+
579
+ req.on('close', () => {
580
+ clearInterval(intervalId);
581
+ });
582
+ return;
583
+ }
584
+
585
+ // 4. Static/Dynamic HTML live dashboard & API snapshot endpoints
497
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') {
498
592
  res.writeHead(200, { 'Content-Type': 'text/html' });
499
593
  const indexPath = path.join(process.cwd(), 'index.html');
500
- let htmlContent = '';
501
594
  if (fs.existsSync(indexPath)) {
502
- htmlContent = fs.readFileSync(indexPath, 'utf-8');
595
+ res.end(fs.readFileSync(indexPath, 'utf-8'));
503
596
  } else {
504
597
  const data = await collectAll({ detailed: true });
505
- htmlContent = formatHtml(data);
598
+ res.end(formatHtml(data));
506
599
  }
507
- res.end(htmlContent);
508
600
  } else if (url === '/api' || url === '/api/stats' || url === '/data') {
509
601
  const data = await collectAll({ detailed: true });
510
602
  res.writeHead(200, {
@@ -519,11 +611,30 @@ async function runWebCommand(): Promise<void> {
519
611
  }
520
612
  });
521
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
+
522
628
  server.listen(port, '0.0.0.0', () => {
523
629
  console.log(chalk.bold(`\n pyre web server running on port ${port}`));
524
- console.log(chalk.dim(` Dashboard: http://0.0.0.0:${port}/`));
525
- console.log(chalk.dim(` API: http://0.0.0.0:${port}/api`));
526
- 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`));
527
638
  });
528
639
 
529
640
  process.once('SIGINT', () => {
package/src/live/input.ts CHANGED
@@ -189,12 +189,20 @@ function handleInputModeKey(str: string, key: readline.Key) {
189
189
 
190
190
  if (key.name === 'backspace') {
191
191
  state.inputBuffer = state.inputBuffer.slice(0, -1);
192
+ if (state.inputMode === 'filter') {
193
+ state.processFilter = state.inputBuffer;
194
+ invalidateTableCache();
195
+ }
192
196
  render();
193
197
  return;
194
198
  }
195
199
 
196
200
  if (str && str.length === 1 && !key.ctrl && !key.meta) {
197
201
  state.inputBuffer += str;
202
+ if (state.inputMode === 'filter') {
203
+ state.processFilter = state.inputBuffer;
204
+ invalidateTableCache();
205
+ }
198
206
  render();
199
207
  }
200
208
  }