pyre-cli 5.0.0 → 6.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": "5.0.0",
3
+ "version": "6.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",
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Anomaly / spike detection for pyre.
3
+ *
4
+ * Uses the rolling history windows in {@link History} to compute
5
+ * per-metric z-scores and flag statistically unusual behaviour
6
+ * (deviation > N sigma from the baseline).
7
+ */
8
+ import { History } from './history.js';
9
+ import type { StatsData } from './monitors/types.js';
10
+
11
+ export interface AnomalyAlert {
12
+ metric: string;
13
+ value: number;
14
+ zScore: number;
15
+ severity: 'warning' | 'critical';
16
+ }
17
+
18
+ const ZSCORE_THRESHOLD = 2.5;
19
+ const CRITICAL_ZSCORE = 3.5;
20
+ const MIN_SAMPLES = 5;
21
+
22
+ export function detectAnomalies(data: StatsData, history: History): AnomalyAlert[] {
23
+ const alerts: AnomalyAlert[] = [];
24
+
25
+ const cpuZ = History.zScore(data.cpu.usage, history.cpuUsage);
26
+ if (Math.abs(cpuZ) > ZSCORE_THRESHOLD && history.cpuUsage.length >= MIN_SAMPLES) {
27
+ alerts.push({
28
+ metric: 'CPU',
29
+ value: data.cpu.usage,
30
+ zScore: cpuZ,
31
+ severity: Math.abs(cpuZ) > CRITICAL_ZSCORE ? 'critical' : 'warning',
32
+ });
33
+ }
34
+
35
+ const memZ = History.zScore(data.memory.usagePercent, history.memUsage);
36
+ if (Math.abs(memZ) > ZSCORE_THRESHOLD && history.memUsage.length >= MIN_SAMPLES) {
37
+ alerts.push({
38
+ metric: 'Memory',
39
+ value: data.memory.usagePercent,
40
+ zScore: memZ,
41
+ severity: Math.abs(memZ) > CRITICAL_ZSCORE ? 'critical' : 'warning',
42
+ });
43
+ }
44
+
45
+ if (data.cpu.temperature !== undefined) {
46
+ const tempZ = History.zScore(data.cpu.temperature, history.temp);
47
+ if (Math.abs(tempZ) > ZSCORE_THRESHOLD && history.temp.length >= MIN_SAMPLES) {
48
+ alerts.push({
49
+ metric: 'Temp',
50
+ value: data.cpu.temperature,
51
+ zScore: tempZ,
52
+ severity: Math.abs(tempZ) > CRITICAL_ZSCORE ? 'critical' : 'warning',
53
+ });
54
+ }
55
+ }
56
+
57
+ const netRxZ = History.zScore(data.network.rxBytes, history.netRxRate);
58
+ if (Math.abs(netRxZ) > ZSCORE_THRESHOLD && history.netRxRate.length >= MIN_SAMPLES) {
59
+ alerts.push({
60
+ metric: 'Net RX',
61
+ value: data.network.rxBytes,
62
+ zScore: netRxZ,
63
+ severity: Math.abs(netRxZ) > CRITICAL_ZSCORE ? 'critical' : 'warning',
64
+ });
65
+ }
66
+
67
+ const netTxZ = History.zScore(data.network.txBytes, history.netTxRate);
68
+ if (Math.abs(netTxZ) > ZSCORE_THRESHOLD && history.netTxRate.length >= MIN_SAMPLES) {
69
+ alerts.push({
70
+ metric: 'Net TX',
71
+ value: data.network.txBytes,
72
+ zScore: netTxZ,
73
+ severity: Math.abs(netTxZ) > CRITICAL_ZSCORE ? 'critical' : 'warning',
74
+ });
75
+ }
76
+
77
+ if (data.power?.combinedWatts !== undefined) {
78
+ const powerZ = History.zScore(data.power.combinedWatts, history.powerWatts);
79
+ if (Math.abs(powerZ) > ZSCORE_THRESHOLD && history.powerWatts.length >= MIN_SAMPLES) {
80
+ alerts.push({
81
+ metric: 'Power',
82
+ value: data.power.combinedWatts,
83
+ zScore: powerZ,
84
+ severity: Math.abs(powerZ) > CRITICAL_ZSCORE ? 'critical' : 'warning',
85
+ });
86
+ }
87
+ }
88
+
89
+ if (data.gpu) {
90
+ const gpuZ = History.zScore(data.gpu.utilization, history.gpuUtil);
91
+ if (Math.abs(gpuZ) > ZSCORE_THRESHOLD && history.gpuUtil.length >= MIN_SAMPLES) {
92
+ alerts.push({
93
+ metric: 'GPU',
94
+ value: data.gpu.utilization,
95
+ zScore: gpuZ,
96
+ severity: Math.abs(gpuZ) > CRITICAL_ZSCORE ? 'critical' : 'warning',
97
+ });
98
+ }
99
+ }
100
+
101
+ return alerts;
102
+ }
@@ -11,7 +11,7 @@
11
11
  * - `render.ts` — dashboard layout, cards, tables, and the main `formatTable`
12
12
  * - `output.ts` — JSON/CSV/TSV export and sparkline graph rendering
13
13
  */
14
- export { formatTable, clampWidth, gridColumns, thermalColor, capacityColor, getTabHitboxes, TAB_BAR_ROW, TAB_DEFS, panel } from './render.js';
15
- export { formatJson, formatCsv, formatTsv, formatGraphs } from './output.js';
16
- export type { ThemeName, ThemeColors, VisibleItems, TableOptions } from './types.js';
14
+ export { formatTable, clampWidth, gridColumns, thermalColor, capacityColor, getTabHitboxes, TAB_BAR_ROW, TAB_DEFS, panel, formatBytes, padVisible, visLen, fitVisible } from './render.js';
15
+ export { formatJson, formatCsv, formatTsv, formatGraphs, formatHtml, formatMarkdown } from './output.js';
16
+ export type { ThemeName, ThemeColors, VisibleItems, TableOptions, AnomalyAlert } from './types.js';
17
17
  export { THEMES } from './themes.js';
@@ -10,13 +10,343 @@ import { sparkline } from '../sparkline.js';
10
10
  import type { History } from '../history.js';
11
11
  import type { StatsData } from './types.js';
12
12
  import { THEMES, type ThemeName } from './themes.js';
13
- import { formatBytes, celsiusToFahrenheit, formatTemp } from './render.js';
14
- import { panel } from './render.js';
13
+ import { formatBytes, celsiusToFahrenheit, formatTemp, panel, fitVisible } from './render.js';
15
14
 
16
15
  export function formatJson(data: StatsData): string {
17
16
  return JSON.stringify(data, null, 2);
18
17
  }
19
18
 
19
+ export function formatHtml(data: StatsData): string {
20
+ const rows = (items: { label: string; value: string }[]) =>
21
+ items.map(i => `<tr><td class="label">${escapeHtml(i.label)}</td><td class="value">${escapeHtml(i.value)}</td></tr>`).join('\n');
22
+
23
+ const sections: string[] = [];
24
+
25
+ sections.push(`<h2>System</h2>
26
+ <table>
27
+ ${rows([
28
+ { label: 'Hostname', value: data.header.hostname },
29
+ { label: 'OS', value: data.header.os },
30
+ { label: 'Uptime', value: data.header.uptime },
31
+ { label: 'Timestamp', value: data.timestamp },
32
+ ]).trim()}
33
+ </table>`);
34
+
35
+ sections.push(`<h2>CPU</h2>
36
+ <table>
37
+ ${rows([
38
+ { label: 'Brand', value: data.cpu.brand },
39
+ { label: 'Cores', value: `${data.cpu.physicalCores}/${data.cpu.cores}` },
40
+ { label: 'Frequency', value: `${data.cpu.frequency} MHz` },
41
+ { label: 'Usage', value: `${data.cpu.usage}%` },
42
+ { label: 'Load Avg', value: data.cpu.loadAvg.map(l => l.toFixed(2)).join(' ') },
43
+ ...(data.cpu.temperature ? [{ label: 'Temperature', value: `${data.cpu.temperature.toFixed(1)}°C` }] : []),
44
+ ]).trim()}
45
+ </table>`);
46
+
47
+ sections.push(`<h2>Memory</h2>
48
+ <table>
49
+ ${rows([
50
+ { label: 'Total', value: formatBytes(data.memory.total) },
51
+ { label: 'Used', value: formatBytes(data.memory.used) },
52
+ { label: 'Free', value: formatBytes(data.memory.free) },
53
+ { label: 'Usage', value: `${data.memory.usagePercent}%` },
54
+ { label: 'Swap', value: `${formatBytes(data.memory.swapUsed)} / ${formatBytes(data.memory.swapTotal)}` },
55
+ ]).trim()}
56
+ </table>`);
57
+
58
+ if (data.gpu) {
59
+ sections.push(`<h2>GPU</h2>
60
+ <table>
61
+ ${rows([
62
+ { label: 'Model', value: data.gpu.model },
63
+ { label: 'Memory', value: formatBytes(data.gpu.memory) },
64
+ { label: 'Utilization', value: `${data.gpu.utilization}%` },
65
+ ...(data.gpu.temperature ? [{ label: 'Temperature', value: `${data.gpu.temperature.toFixed(1)}°C` }] : []),
66
+ { label: 'Processes', value: String(data.gpu.processes) },
67
+ ]).trim()}
68
+ </table>`);
69
+ }
70
+
71
+ if (data.power) {
72
+ const powerRows: { label: string; value: string }[] = [];
73
+ if (data.power.cpuWatts !== undefined) powerRows.push({ label: 'CPU', value: `${data.power.cpuWatts.toFixed(2)} W` });
74
+ if (data.power.gpuWatts !== undefined) powerRows.push({ label: 'GPU', value: `${data.power.gpuWatts.toFixed(2)} W` });
75
+ if (data.power.combinedWatts !== undefined) powerRows.push({ label: 'Combined', value: `${data.power.combinedWatts.toFixed(2)} W` });
76
+ if (powerRows.length) {
77
+ sections.push(`<h2>Power</h2>\n<table>\n ${rows(powerRows).trim()}\n</table>`);
78
+ }
79
+ }
80
+
81
+ if (data.battery) {
82
+ const battRows: { label: string; value: string }[] = [
83
+ { label: 'Level', value: `${data.battery.level}%` },
84
+ { label: 'State', value: data.battery.state },
85
+ { label: 'Time Remaining', value: data.battery.timeRemaining },
86
+ { label: 'Health', value: data.battery.health },
87
+ { label: 'Power Source', value: data.battery.powerSource },
88
+ ];
89
+ if (data.battery.condition) battRows.push({ label: 'Condition', value: data.battery.condition });
90
+ if (data.battery.maxCapacityPercent !== undefined) battRows.push({ label: 'Max Capacity', value: `${data.battery.maxCapacityPercent}%` });
91
+ if (data.battery.cycles !== undefined) battRows.push({ label: 'Cycles', value: String(data.battery.cycles) });
92
+ if (data.battery.estimatedTimeToEmpty) battRows.push({ label: 'Est. Empty', value: data.battery.estimatedTimeToEmpty });
93
+ if (data.battery.dischargeRatePerHour !== undefined) battRows.push({ label: 'Discharge Rate', value: `${data.battery.dischargeRatePerHour}%/h` });
94
+ sections.push(`<h2>Battery</h2>\n<table>\n ${rows(battRows).trim()}\n</table>`);
95
+ }
96
+
97
+ sections.push(`<h2>Thermal</h2>
98
+ <table>
99
+ ${rows([
100
+ { label: 'State', value: data.thermal.state },
101
+ { label: 'Pressure', value: ['Nominal', 'Fair', 'Serious', 'Critical'][data.thermal.pressureLevel] || 'Unknown' },
102
+ ...(data.thermal.temperatures
103
+ ? Object.entries(data.thermal.temperatures).flatMap(([k, v]) =>
104
+ v !== null && v !== undefined ? [{ label: k.replace(/_/g, ' '), value: `${v.toFixed(1)}°C` }] : []
105
+ )
106
+ : []),
107
+ ]).trim()}
108
+ </table>`);
109
+
110
+ sections.push(`<h2>Network</h2>
111
+ <table>
112
+ ${rows([
113
+ { label: 'Interface', value: `${data.network.interface} (${data.network.ip})` },
114
+ { label: 'RX', value: `${formatBytes(data.network.rxBytes)} / ${data.network.rxPackets} pkt` },
115
+ { label: 'TX', value: `${formatBytes(data.network.txBytes)} / ${data.network.txPackets} pkt` },
116
+ ]).trim()}
117
+ </table>`);
118
+
119
+ if (data.packets) {
120
+ sections.push(`<h2>Packets</h2>
121
+ <table>
122
+ ${rows([
123
+ { label: 'Total', value: `${data.packets.totalPackets} pkt` },
124
+ { label: 'RX', value: `${data.packets.rxPackets} pkt` },
125
+ { label: 'TX', value: `${data.packets.txPackets} pkt` },
126
+ { label: 'Connections', value: `${data.packets.connections} TCP` },
127
+ ]).trim()}
128
+ </table>`);
129
+ }
130
+
131
+ if (data.tasks.length) {
132
+ const taskRows = data.tasks.slice(0, 20).map(t => ({
133
+ label: `${t.pid}`,
134
+ value: `${t.user.padEnd(10)} ${t.cpu.toFixed(1).padStart(6)}% ${t.mem.toFixed(1).padStart(6)}% ${t.state} ${t.command}`,
135
+ }));
136
+ sections.push(`<h2>Tasks</h2>\n<table>\n ${rows(taskRows).trim()}\n</table>`);
137
+ }
138
+
139
+ if (data.disk.length) {
140
+ const diskRows = data.disk.map(d => ({
141
+ label: d.mountpoint,
142
+ value: `${d.used} / ${d.size} (${d.capacity})`,
143
+ }));
144
+ sections.push(`<h2>Disk</h2>\n<table>\n ${rows(diskRows).trim()}\n</table>`);
145
+ }
146
+
147
+ if (data.processes.length) {
148
+ const procRows = data.processes.slice(0, 50).map(p => ({
149
+ label: String(p.pid),
150
+ value: `${p.user.padEnd(10)} ${p.cpu.toFixed(1).padStart(6)}% ${p.mem.toFixed(1).padStart(6)}% ${p.state} ${p.command}`,
151
+ }));
152
+ sections.push(`<h2>Processes</h2>\n<table>\n ${rows(procRows).trim()}\n</table>`);
153
+ }
154
+
155
+ return `<!DOCTYPE html>
156
+ <html lang="en">
157
+ <head>
158
+ <meta charset="UTF-8">
159
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
160
+ <title>pyre — ${data.header.hostname}</title>
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; }
170
+ tr:last-child td { border-bottom: none; }
171
+ </style>
172
+ </head>
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>
176
+ ${sections.join('\n ')}
177
+ </body>
178
+ </html>`;
179
+ }
180
+
181
+ export function formatMarkdown(data: StatsData): string {
182
+ const lines: string[] = [];
183
+ lines.push(`# pyre — ${data.header.hostname}`);
184
+ lines.push('');
185
+ lines.push(`**${data.header.os}** · up ${data.header.uptime} · ${data.timestamp}`);
186
+ lines.push('');
187
+
188
+ lines.push('## System');
189
+ lines.push('');
190
+ lines.push('| Property | Value |');
191
+ lines.push('|---|---|');
192
+ lines.push(`| Hostname | ${mdCell(data.header.hostname)} |`);
193
+ lines.push(`| OS | ${mdCell(data.header.os)} |`);
194
+ lines.push(`| Uptime | ${mdCell(data.header.uptime)} |`);
195
+ lines.push('');
196
+
197
+ lines.push('## CPU');
198
+ lines.push('');
199
+ lines.push('| Property | Value |');
200
+ lines.push('|---|---|');
201
+ lines.push(`| Brand | ${mdCell(data.cpu.brand)} |`);
202
+ lines.push(`| Cores | ${mdCell(`${data.cpu.physicalCores}/${data.cpu.cores}`)} |`);
203
+ lines.push(`| Frequency | ${mdCell(`${data.cpu.frequency} MHz`)} |`);
204
+ lines.push(`| Usage | ${mdCell(`${data.cpu.usage}%`)} |`);
205
+ lines.push(`| Load Avg | ${mdCell(data.cpu.loadAvg.map(l => l.toFixed(2)).join(' '))} |`);
206
+ if (data.cpu.temperature) lines.push(`| Temperature | ${mdCell(`${data.cpu.temperature.toFixed(1)}°C`)} |`);
207
+ lines.push('');
208
+
209
+ lines.push('## Memory');
210
+ lines.push('');
211
+ lines.push('| Property | Value |');
212
+ lines.push('|---|---|');
213
+ lines.push(`| Total | ${mdCell(formatBytes(data.memory.total))} |`);
214
+ lines.push(`| Used | ${mdCell(formatBytes(data.memory.used))} |`);
215
+ lines.push(`| Free | ${mdCell(formatBytes(data.memory.free))} |`);
216
+ lines.push(`| Usage | ${mdCell(`${data.memory.usagePercent}%`)} |`);
217
+ lines.push(`| Swap | ${mdCell(`${formatBytes(data.memory.swapUsed)} / ${formatBytes(data.memory.swapTotal)}`)} |`);
218
+ lines.push('');
219
+
220
+ if (data.gpu) {
221
+ lines.push('## GPU');
222
+ lines.push('');
223
+ lines.push('| Property | Value |');
224
+ lines.push('|---|---|');
225
+ lines.push(`| Model | ${mdCell(data.gpu.model)} |`);
226
+ lines.push(`| Memory | ${mdCell(formatBytes(data.gpu.memory))} |`);
227
+ lines.push(`| Utilization | ${mdCell(`${data.gpu.utilization}%`)} |`);
228
+ if (data.gpu.temperature) lines.push(`| Temperature | ${mdCell(`${data.gpu.temperature.toFixed(1)}°C`)} |`);
229
+ lines.push(`| Processes | ${mdCell(String(data.gpu.processes))} |`);
230
+ lines.push('');
231
+ }
232
+
233
+ if (data.power) {
234
+ const pw: string[] = [];
235
+ if (data.power.cpuWatts !== undefined) pw.push(`CPU ${data.power.cpuWatts.toFixed(2)} W`);
236
+ if (data.power.gpuWatts !== undefined) pw.push(`GPU ${data.power.gpuWatts.toFixed(2)} W`);
237
+ if (data.power.combinedWatts !== undefined) pw.push(`Combined ${data.power.combinedWatts.toFixed(2)} W`);
238
+ if (pw.length) {
239
+ lines.push('## Power');
240
+ lines.push('');
241
+ lines.push('| Metric | Value |');
242
+ lines.push('|---|---|');
243
+ if (data.power.cpuWatts !== undefined) lines.push(`| CPU | ${mdCell(`${data.power.cpuWatts.toFixed(2)} W`)} |`);
244
+ if (data.power.gpuWatts !== undefined) lines.push(`| GPU | ${mdCell(`${data.power.gpuWatts.toFixed(2)} W`)} |`);
245
+ if (data.power.combinedWatts !== undefined) lines.push(`| Combined | ${mdCell(`${data.power.combinedWatts.toFixed(2)} W`)} |`);
246
+ lines.push('');
247
+ }
248
+ }
249
+
250
+ if (data.battery) {
251
+ lines.push('## Battery');
252
+ lines.push('');
253
+ lines.push('| Property | Value |');
254
+ lines.push('|---|---|');
255
+ lines.push(`| Level | ${mdCell(`${data.battery.level}%`)} |`);
256
+ lines.push(`| State | ${mdCell(data.battery.state)} |`);
257
+ lines.push(`| Time Remaining | ${mdCell(data.battery.timeRemaining)} |`);
258
+ lines.push(`| Health | ${mdCell(data.battery.health)} |`);
259
+ lines.push(`| Power Source | ${mdCell(data.battery.powerSource)} |`);
260
+ if (data.battery.condition) lines.push(`| Condition | ${mdCell(data.battery.condition)} |`);
261
+ if (data.battery.maxCapacityPercent !== undefined) lines.push(`| Max Capacity | ${mdCell(`${data.battery.maxCapacityPercent}%`)} |`);
262
+ if (data.battery.cycles !== undefined) lines.push(`| Cycles | ${mdCell(String(data.battery.cycles))} |`);
263
+ if (data.battery.estimatedTimeToEmpty) lines.push(`| Est. Empty | ${mdCell(data.battery.estimatedTimeToEmpty)} |`);
264
+ if (data.battery.dischargeRatePerHour !== undefined) lines.push(`| Discharge Rate | ${mdCell(`${data.battery.dischargeRatePerHour}%/h`)} |`);
265
+ lines.push('');
266
+ }
267
+
268
+ lines.push('## Thermal');
269
+ lines.push('');
270
+ lines.push('| Property | Value |');
271
+ lines.push('|---|---|');
272
+ lines.push(`| State | ${mdCell(data.thermal.state)} |`);
273
+ lines.push(`| Pressure | ${mdCell(['Nominal', 'Fair', 'Serious', 'Critical'][data.thermal.pressureLevel] || 'Unknown')} |`);
274
+ if (data.thermal.temperatures) {
275
+ for (const [k, v] of Object.entries(data.thermal.temperatures)) {
276
+ if (v !== null && v !== undefined) lines.push(`| ${mdCell(k.replace(/_/g, ' '))} | ${mdCell(`${v.toFixed(1)}°C`)} |`);
277
+ }
278
+ }
279
+ lines.push('');
280
+
281
+ lines.push('## Network');
282
+ lines.push('');
283
+ lines.push('| Property | Value |');
284
+ lines.push('|---|---|');
285
+ lines.push(`| Interface | ${mdCell(`${data.network.interface} (${data.network.ip})`)} |`);
286
+ lines.push(`| RX | ${mdCell(`${formatBytes(data.network.rxBytes)} / ${data.network.rxPackets} pkt`)} |`);
287
+ lines.push(`| TX | ${mdCell(`${formatBytes(data.network.txBytes)} / ${data.network.txPackets} pkt`)} |`);
288
+ lines.push('');
289
+
290
+ if (data.packets) {
291
+ lines.push('## Packets');
292
+ lines.push('');
293
+ lines.push('| Property | Value |');
294
+ lines.push('|---|---|');
295
+ lines.push(`| Total | ${mdCell(`${data.packets.totalPackets} pkt`)} |`);
296
+ lines.push(`| RX | ${mdCell(`${data.packets.rxPackets} pkt`)} |`);
297
+ lines.push(`| TX | ${mdCell(`${data.packets.txPackets} pkt`)} |`);
298
+ lines.push(`| Connections | ${mdCell(`${data.packets.connections} TCP`)} |`);
299
+ lines.push('');
300
+ }
301
+
302
+ if (data.tasks.length) {
303
+ lines.push('## Tasks');
304
+ lines.push('');
305
+ lines.push('| PID | User | CPU% | MEM% | State | Command |');
306
+ lines.push('|---|---|---|---|---|---|');
307
+ for (const t of data.tasks.slice(0, 20)) {
308
+ lines.push(`| ${t.pid} | ${mdCell(t.user)} | ${t.cpu.toFixed(1)} | ${t.mem.toFixed(1)} | ${t.state} | ${mdCell(t.command)} |`);
309
+ }
310
+ lines.push('');
311
+ }
312
+
313
+ if (data.disk.length) {
314
+ lines.push('## Disk');
315
+ lines.push('');
316
+ lines.push('| Mountpoint | Used / Size | Capacity |');
317
+ lines.push('|---|---|---|');
318
+ for (const d of data.disk) {
319
+ lines.push(`| ${mdCell(d.mountpoint)} | ${mdCell(`${d.used} / ${d.size}`)} | ${mdCell(d.capacity)} |`);
320
+ }
321
+ lines.push('');
322
+ }
323
+
324
+ if (data.processes.length) {
325
+ lines.push('## Processes');
326
+ lines.push('');
327
+ lines.push('| PID | User | CPU% | MEM% | State | Command |');
328
+ lines.push('|---|---|---|---|---|---|');
329
+ for (const p of data.processes.slice(0, 50)) {
330
+ lines.push(`| ${p.pid} | ${mdCell(p.user)} | ${p.cpu.toFixed(1)} | ${p.mem.toFixed(1)} | ${p.state} | ${mdCell(p.command)} |`);
331
+ }
332
+ lines.push('');
333
+ }
334
+
335
+ return lines.join('\n');
336
+ }
337
+
338
+ function escapeHtml(s: string): string {
339
+ return s
340
+ .replace(/&/g, '&amp;')
341
+ .replace(/</g, '&lt;')
342
+ .replace(/>/g, '&gt;')
343
+ .replace(/"/g, '&quot;');
344
+ }
345
+
346
+ function mdCell(s: string): string {
347
+ return String(s).replace(/\|/g, '\\|');
348
+ }
349
+
20
350
  export function formatCsv(data: StatsData): string {
21
351
  const rows: string[][] = [
22
352
  ['property', 'key', 'value'],
@@ -25,6 +355,9 @@ export function formatCsv(data: StatsData): string {
25
355
  ['cpu', 'loadAvg_1m', String(data.cpu.loadAvg[0])],
26
356
  ['cpu', 'loadAvg_5m', String(data.cpu.loadAvg[1])],
27
357
  ['cpu', 'loadAvg_15m', String(data.cpu.loadAvg[2])],
358
+ ['cpu', 'cores', String(data.cpu.cores)],
359
+ ['cpu', 'physicalCores', String(data.cpu.physicalCores)],
360
+ ['cpu', 'frequency', String(data.cpu.frequency)],
28
361
  ['memory', 'total', String(data.memory.total)],
29
362
  ['memory', 'used', String(data.memory.used)],
30
363
  ['memory', 'usagePercent', String(data.memory.usagePercent)],
@@ -32,11 +365,7 @@ export function formatCsv(data: StatsData): string {
32
365
  ['memory', 'swapTotal', String(data.memory.swapTotal)],
33
366
  ['thermal', 'state', data.thermal.state],
34
367
  ['thermal', 'cpuTempC', data.thermal.temperatures?.cpu_die ? String(data.thermal.temperatures.cpu_die) : ''],
35
- [
36
- 'thermal',
37
- 'cpuTempF',
38
- data.thermal.temperatures?.cpu_die != null ? String(celsiusToFahrenheit(data.thermal.temperatures.cpu_die)) : '',
39
- ],
368
+ ['thermal', 'cpuTempF', data.thermal.temperatures?.cpu_die != null ? String(celsiusToFahrenheit(data.thermal.temperatures.cpu_die)) : ''],
40
369
  ['network', 'rxBytes', String(data.network.rxBytes)],
41
370
  ['network', 'txBytes', String(data.network.txBytes)],
42
371
  ['network', 'rxPackets', String(data.network.rxPackets)],
@@ -54,9 +383,12 @@ export function formatCsv(data: StatsData): string {
54
383
  ['power', 'gpuWatts', data.power?.gpuWatts !== undefined ? String(data.power.gpuWatts) : ''],
55
384
  ['power', 'combinedWatts', data.power?.combinedWatts !== undefined ? String(data.power.combinedWatts) : ''],
56
385
  ['system', 'hostname', data.header.hostname],
57
- ['system', 'uptime', data.header.uptime],
58
- ['system', 'timestamp', data.timestamp],
59
- ];
386
+ ['system', 'uptime', data.header.uptime],
387
+ ['system', 'timestamp', data.timestamp],
388
+ ];
389
+ if (data.cpu.coreUsage && data.cpu.coreUsage.length) {
390
+ data.cpu.coreUsage.forEach((u, i) => rows.push(['cpu', `coreUsage_${i}`, String(u)]));
391
+ }
60
392
 
61
393
  if (data.packets) {
62
394
  rows.push(['packets', 'totalPackets', String(data.packets.totalPackets)]);
@@ -101,24 +433,24 @@ export function formatGraphs(history: History, width = 80, themeName: ThemeName
101
433
  const theme = THEMES[themeName] || THEMES.default;
102
434
  const contentWidth = width - 4;
103
435
  const sampleCount = history.cpuUsage.length;
104
- const sparkWidth = Math.max(10, contentWidth - 28);
436
+ const sparkWidth = Math.max(10, contentWidth - 24);
105
437
 
106
438
  const lines: string[] = [];
107
- lines.push(graphRow('CPU %', history.cpuUsage, { min: 0, max: 100 }, v => `${v.toFixed(0)}%`, sparkWidth, 'collecting data...', graphMode));
108
- lines.push(graphRow('Mem %', history.memUsage, { min: 0, max: 100 }, v => `${v.toFixed(0)}%`, sparkWidth, 'collecting data...', graphMode));
439
+ lines.push(graphRow('CPU %', history.cpuUsage, { min: 0, max: 100 }, v => `${v.toFixed(0)}%`, sparkWidth, 'collecting data...', graphMode, contentWidth));
440
+ lines.push(graphRow('Mem %', history.memUsage, { min: 0, max: 100 }, v => `${v.toFixed(0)}%`, sparkWidth, 'collecting data...', graphMode, contentWidth));
109
441
  lines.push(
110
- graphRow('Temp', history.temp, {}, formatTemp, sparkWidth, 'no sensor access (needs sudo powermetrics)', graphMode)
442
+ graphRow('Temp', history.temp, {}, v => `${v.toFixed(1)}°C`, sparkWidth, 'no sensor access', graphMode, contentWidth)
111
443
  );
112
- lines.push(graphRow('Net RX/s', history.netRxRate, {}, v => `${formatBytes(v)}/s`, sparkWidth, 'collecting data...', graphMode));
113
- lines.push(graphRow('Net TX/s', history.netTxRate, {}, v => `${formatBytes(v)}/s`, sparkWidth, 'collecting data...', graphMode));
444
+ lines.push(graphRow('Net RX/s', history.netRxRate, {}, v => `${formatBytes(v)}/s`, sparkWidth, 'collecting data...', graphMode, contentWidth));
445
+ lines.push(graphRow('Net TX/s', history.netTxRate, {}, v => `${formatBytes(v)}/s`, sparkWidth, 'collecting data...', graphMode, contentWidth));
114
446
  if (history.powerWatts.length) {
115
- lines.push(graphRow('Power W', history.powerWatts, {}, v => `${v.toFixed(1)} W`, sparkWidth, 'collecting data...', graphMode));
447
+ lines.push(graphRow('Power W', history.powerWatts, {}, v => `${v.toFixed(1)} W`, sparkWidth, 'collecting data...', graphMode, contentWidth));
116
448
  }
117
449
  if (history.rxPacketRate.length) {
118
- lines.push(graphRow('RX pkt/s', history.rxPacketRate, {}, v => `${v.toFixed(0)} pkt/s`, sparkWidth, 'collecting data...', graphMode));
450
+ lines.push(graphRow('RX pkt/s', history.rxPacketRate, {}, v => `${v.toFixed(0)} pkt/s`, sparkWidth, 'collecting data...', graphMode, contentWidth));
119
451
  }
120
452
  if (history.txPacketRate.length) {
121
- lines.push(graphRow('TX pkt/s', history.txPacketRate, {}, v => `${v.toFixed(0)} pkt/s`, sparkWidth, 'collecting data...', graphMode));
453
+ lines.push(graphRow('TX pkt/s', history.txPacketRate, {}, v => `${v.toFixed(0)} pkt/s`, sparkWidth, 'collecting data...', graphMode, contentWidth));
122
454
  }
123
455
 
124
456
  const title = `Graphs · mode:${graphMode} · last ${sampleCount} sample${sampleCount === 1 ? '' : 's'}`;
@@ -132,7 +464,8 @@ function graphRow(
132
464
  fmt: (v: number) => string,
133
465
  sparkWidth = 40,
134
466
  emptyMessage = 'collecting data...',
135
- mode: 'spark' | 'bar' = 'spark'
467
+ mode: 'spark' | 'bar' = 'spark',
468
+ contentWidth = 76
136
469
  ): string {
137
470
  if (!values.length) {
138
471
  return `${chalk.dim(label.padEnd(10))} ${chalk.dim(emptyMessage)}`;
@@ -147,7 +480,8 @@ function graphRow(
147
480
  color = pct > 90 ? chalk.red : pct > 70 ? chalk.yellow : chalk.green;
148
481
  }
149
482
 
150
- return `${chalk.dim(label.padEnd(10))}${color(spark)} ${chalk.bold(fmt(current))}`;
483
+ const raw = `${chalk.dim(label.padEnd(10))}${color(spark)} ${chalk.bold(fmt(current))}`;
484
+ return fitVisible(raw, contentWidth);
151
485
  }
152
486
 
153
487
  function barGraph(values: number[], bounds: { min?: number; max?: number }, width: number): string {