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.
- package/README.md +28 -13
- package/dist/index.js +778 -224
- package/package.json +1 -1
- package/src/anomalies.ts +1 -0
- package/src/completions.ts +118 -0
- package/src/diffCmd.ts +36 -0
- package/src/doctor.ts +123 -0
- package/src/fleet.ts +31 -0
- package/src/formatters/output.ts +107 -10
- package/src/formatters/render.ts +113 -56
- package/src/formatters/themes.ts +47 -12
- package/src/formatters/types.ts +6 -1
- package/src/historyCmd.ts +71 -0
- package/src/index.ts +126 -15
- package/src/live/input.ts +8 -0
- package/src/live/render.ts +112 -57
- package/src/live/state.ts +18 -8
- package/src/monitors/collectors.ts +41 -2
- package/src/monitors/types.ts +2 -0
- package/src/state/config.ts +12 -1
- package/src/xbar.ts +53 -0
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
|
}
|
|
@@ -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 <
|
|
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
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
782
|
-
|
|
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
|
|
package/src/formatters/themes.ts
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
|
|
11
|
-
|
|
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();
|
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
|
}
|
|
@@ -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 {
|
|
21
|
-
import {
|
|
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 === '
|
|
127
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
595
|
+
res.end(fs.readFileSync(indexPath, 'utf-8'));
|
|
503
596
|
} else {
|
|
504
597
|
const data = await collectAll({ detailed: true });
|
|
505
|
-
|
|
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(`
|
|
525
|
-
console.log(chalk.
|
|
526
|
-
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`));
|
|
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
|
}
|