pyre-cli 4.17.0 → 5.1.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": "4.17.0",
3
+ "version": "5.1.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 } 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';
@@ -17,6 +17,337 @@ export function formatJson(data: StatsData): string {
17
17
  return JSON.stringify(data, null, 2);
18
18
  }
19
19
 
20
+ export function formatHtml(data: StatsData): string {
21
+ const rows = (items: { label: string; value: string }[]) =>
22
+ items.map(i => `<tr><td class="label">${escapeHtml(i.label)}</td><td class="value">${escapeHtml(i.value)}</td></tr>`).join('\n');
23
+
24
+ const sections: string[] = [];
25
+
26
+ sections.push(`<h2>System</h2>
27
+ <table>
28
+ ${rows([
29
+ { label: 'Hostname', value: data.header.hostname },
30
+ { label: 'OS', value: data.header.os },
31
+ { label: 'Uptime', value: data.header.uptime },
32
+ { label: 'Timestamp', value: data.timestamp },
33
+ ]).trim()}
34
+ </table>`);
35
+
36
+ sections.push(`<h2>CPU</h2>
37
+ <table>
38
+ ${rows([
39
+ { label: 'Brand', value: data.cpu.brand },
40
+ { label: 'Cores', value: `${data.cpu.physicalCores}/${data.cpu.cores}` },
41
+ { label: 'Frequency', value: `${data.cpu.frequency} MHz` },
42
+ { label: 'Usage', value: `${data.cpu.usage}%` },
43
+ { label: 'Load Avg', value: data.cpu.loadAvg.map(l => l.toFixed(2)).join(' ') },
44
+ ...(data.cpu.temperature ? [{ label: 'Temperature', value: `${data.cpu.temperature.toFixed(1)}°C` }] : []),
45
+ ]).trim()}
46
+ </table>`);
47
+
48
+ sections.push(`<h2>Memory</h2>
49
+ <table>
50
+ ${rows([
51
+ { label: 'Total', value: formatBytes(data.memory.total) },
52
+ { label: 'Used', value: formatBytes(data.memory.used) },
53
+ { label: 'Free', value: formatBytes(data.memory.free) },
54
+ { label: 'Usage', value: `${data.memory.usagePercent}%` },
55
+ { label: 'Swap', value: `${formatBytes(data.memory.swapUsed)} / ${formatBytes(data.memory.swapTotal)}` },
56
+ ]).trim()}
57
+ </table>`);
58
+
59
+ if (data.gpu) {
60
+ sections.push(`<h2>GPU</h2>
61
+ <table>
62
+ ${rows([
63
+ { label: 'Model', value: data.gpu.model },
64
+ { label: 'Memory', value: formatBytes(data.gpu.memory) },
65
+ { label: 'Utilization', value: `${data.gpu.utilization}%` },
66
+ ...(data.gpu.temperature ? [{ label: 'Temperature', value: `${data.gpu.temperature.toFixed(1)}°C` }] : []),
67
+ { label: 'Processes', value: String(data.gpu.processes) },
68
+ ]).trim()}
69
+ </table>`);
70
+ }
71
+
72
+ if (data.power) {
73
+ const powerRows: { label: string; value: string }[] = [];
74
+ if (data.power.cpuWatts !== undefined) powerRows.push({ label: 'CPU', value: `${data.power.cpuWatts.toFixed(2)} W` });
75
+ if (data.power.gpuWatts !== undefined) powerRows.push({ label: 'GPU', value: `${data.power.gpuWatts.toFixed(2)} W` });
76
+ if (data.power.combinedWatts !== undefined) powerRows.push({ label: 'Combined', value: `${data.power.combinedWatts.toFixed(2)} W` });
77
+ if (powerRows.length) {
78
+ sections.push(`<h2>Power</h2>\n<table>\n ${rows(powerRows).trim()}\n</table>`);
79
+ }
80
+ }
81
+
82
+ if (data.battery) {
83
+ const battRows: { label: string; value: string }[] = [
84
+ { label: 'Level', value: `${data.battery.level}%` },
85
+ { label: 'State', value: data.battery.state },
86
+ { label: 'Time Remaining', value: data.battery.timeRemaining },
87
+ { label: 'Health', value: data.battery.health },
88
+ { label: 'Power Source', value: data.battery.powerSource },
89
+ ];
90
+ if (data.battery.condition) battRows.push({ label: 'Condition', value: data.battery.condition });
91
+ if (data.battery.maxCapacityPercent !== undefined) battRows.push({ label: 'Max Capacity', value: `${data.battery.maxCapacityPercent}%` });
92
+ if (data.battery.cycles !== undefined) battRows.push({ label: 'Cycles', value: String(data.battery.cycles) });
93
+ if (data.battery.estimatedTimeToEmpty) battRows.push({ label: 'Est. Empty', value: data.battery.estimatedTimeToEmpty });
94
+ if (data.battery.dischargeRatePerHour !== undefined) battRows.push({ label: 'Discharge Rate', value: `${data.battery.dischargeRatePerHour}%/h` });
95
+ sections.push(`<h2>Battery</h2>\n<table>\n ${rows(battRows).trim()}\n</table>`);
96
+ }
97
+
98
+ sections.push(`<h2>Thermal</h2>
99
+ <table>
100
+ ${rows([
101
+ { label: 'State', value: data.thermal.state },
102
+ { label: 'Pressure', value: ['Nominal', 'Fair', 'Serious', 'Critical'][data.thermal.pressureLevel] || 'Unknown' },
103
+ ...(data.thermal.temperatures
104
+ ? Object.entries(data.thermal.temperatures).flatMap(([k, v]) =>
105
+ v !== null && v !== undefined ? [{ label: k.replace(/_/g, ' '), value: `${v.toFixed(1)}°C` }] : []
106
+ )
107
+ : []),
108
+ ]).trim()}
109
+ </table>`);
110
+
111
+ sections.push(`<h2>Network</h2>
112
+ <table>
113
+ ${rows([
114
+ { label: 'Interface', value: `${data.network.interface} (${data.network.ip})` },
115
+ { label: 'RX', value: `${formatBytes(data.network.rxBytes)} / ${data.network.rxPackets} pkt` },
116
+ { label: 'TX', value: `${formatBytes(data.network.txBytes)} / ${data.network.txPackets} pkt` },
117
+ ]).trim()}
118
+ </table>`);
119
+
120
+ if (data.packets) {
121
+ sections.push(`<h2>Packets</h2>
122
+ <table>
123
+ ${rows([
124
+ { label: 'Total', value: `${data.packets.totalPackets} pkt` },
125
+ { label: 'RX', value: `${data.packets.rxPackets} pkt` },
126
+ { label: 'TX', value: `${data.packets.txPackets} pkt` },
127
+ { label: 'Connections', value: `${data.packets.connections} TCP` },
128
+ ]).trim()}
129
+ </table>`);
130
+ }
131
+
132
+ if (data.tasks.length) {
133
+ const taskRows = data.tasks.slice(0, 20).map(t => ({
134
+ label: `${t.pid}`,
135
+ value: `${t.user.padEnd(10)} ${t.cpu.toFixed(1).padStart(6)}% ${t.mem.toFixed(1).padStart(6)}% ${t.state} ${t.command}`,
136
+ }));
137
+ sections.push(`<h2>Tasks</h2>\n<table>\n ${rows(taskRows).trim()}\n</table>`);
138
+ }
139
+
140
+ if (data.disk.length) {
141
+ const diskRows = data.disk.map(d => ({
142
+ label: d.mountpoint,
143
+ value: `${d.used} / ${d.size} (${d.capacity})`,
144
+ }));
145
+ sections.push(`<h2>Disk</h2>\n<table>\n ${rows(diskRows).trim()}\n</table>`);
146
+ }
147
+
148
+ if (data.processes.length) {
149
+ const procRows = data.processes.slice(0, 50).map(p => ({
150
+ label: String(p.pid),
151
+ value: `${p.user.padEnd(10)} ${p.cpu.toFixed(1).padStart(6)}% ${p.mem.toFixed(1).padStart(6)}% ${p.state} ${p.command}`,
152
+ }));
153
+ sections.push(`<h2>Processes</h2>\n<table>\n ${rows(procRows).trim()}\n</table>`);
154
+ }
155
+
156
+ return `<!DOCTYPE html>
157
+ <html lang="en">
158
+ <head>
159
+ <meta charset="UTF-8">
160
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
161
+ <title>pyre — ${data.header.hostname}</title>
162
+ <style>
163
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1e1e2e; color: #cdd6f4; margin: 0; padding: 24px; }
164
+ h1 { color: #ff6a39; margin-bottom: 4px; }
165
+ .meta { color: #a6adc8; margin-bottom: 24px; }
166
+ h2 { color: #89b4fa; margin-top: 28px; border-bottom: 1px solid #313244; padding-bottom: 6px; }
167
+ table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
168
+ td { padding: 6px 10px; border-bottom: 1px solid #313244; vertical-align: top; }
169
+ td.label { color: #a6adc8; width: 22%; font-weight: 500; }
170
+ td.value { color: #cdd6f4; }
171
+ tr:last-child td { border-bottom: none; }
172
+ </style>
173
+ </head>
174
+ <body>
175
+ <h1>PYRE</h1>
176
+ <div class="meta">${escapeHtml(data.header.hostname)} · ${escapeHtml(data.header.os)} · up ${escapeHtml(data.header.uptime)} · ${escapeHtml(data.timestamp)}</div>
177
+ ${sections.join('\n ')}
178
+ </body>
179
+ </html>`;
180
+ }
181
+
182
+ export function formatMarkdown(data: StatsData): string {
183
+ const lines: string[] = [];
184
+ lines.push(`# pyre — ${data.header.hostname}`);
185
+ lines.push('');
186
+ lines.push(`**${data.header.os}** · up ${data.header.uptime} · ${data.timestamp}`);
187
+ lines.push('');
188
+
189
+ lines.push('## System');
190
+ lines.push('');
191
+ lines.push('| Property | Value |');
192
+ lines.push('|---|---|');
193
+ lines.push(`| Hostname | ${mdCell(data.header.hostname)} |`);
194
+ lines.push(`| OS | ${mdCell(data.header.os)} |`);
195
+ lines.push(`| Uptime | ${mdCell(data.header.uptime)} |`);
196
+ lines.push('');
197
+
198
+ lines.push('## CPU');
199
+ lines.push('');
200
+ lines.push('| Property | Value |');
201
+ lines.push('|---|---|');
202
+ lines.push(`| Brand | ${mdCell(data.cpu.brand)} |`);
203
+ lines.push(`| Cores | ${mdCell(`${data.cpu.physicalCores}/${data.cpu.cores}`)} |`);
204
+ lines.push(`| Frequency | ${mdCell(`${data.cpu.frequency} MHz`)} |`);
205
+ lines.push(`| Usage | ${mdCell(`${data.cpu.usage}%`)} |`);
206
+ lines.push(`| Load Avg | ${mdCell(data.cpu.loadAvg.map(l => l.toFixed(2)).join(' '))} |`);
207
+ if (data.cpu.temperature) lines.push(`| Temperature | ${mdCell(`${data.cpu.temperature.toFixed(1)}°C`)} |`);
208
+ lines.push('');
209
+
210
+ lines.push('## Memory');
211
+ lines.push('');
212
+ lines.push('| Property | Value |');
213
+ lines.push('|---|---|');
214
+ lines.push(`| Total | ${mdCell(formatBytes(data.memory.total))} |`);
215
+ lines.push(`| Used | ${mdCell(formatBytes(data.memory.used))} |`);
216
+ lines.push(`| Free | ${mdCell(formatBytes(data.memory.free))} |`);
217
+ lines.push(`| Usage | ${mdCell(`${data.memory.usagePercent}%`)} |`);
218
+ lines.push(`| Swap | ${mdCell(`${formatBytes(data.memory.swapUsed)} / ${formatBytes(data.memory.swapTotal)}`)} |`);
219
+ lines.push('');
220
+
221
+ if (data.gpu) {
222
+ lines.push('## GPU');
223
+ lines.push('');
224
+ lines.push('| Property | Value |');
225
+ lines.push('|---|---|');
226
+ lines.push(`| Model | ${mdCell(data.gpu.model)} |`);
227
+ lines.push(`| Memory | ${mdCell(formatBytes(data.gpu.memory))} |`);
228
+ lines.push(`| Utilization | ${mdCell(`${data.gpu.utilization}%`)} |`);
229
+ if (data.gpu.temperature) lines.push(`| Temperature | ${mdCell(`${data.gpu.temperature.toFixed(1)}°C`)} |`);
230
+ lines.push(`| Processes | ${mdCell(String(data.gpu.processes))} |`);
231
+ lines.push('');
232
+ }
233
+
234
+ if (data.power) {
235
+ const pw: string[] = [];
236
+ if (data.power.cpuWatts !== undefined) pw.push(`CPU ${data.power.cpuWatts.toFixed(2)} W`);
237
+ if (data.power.gpuWatts !== undefined) pw.push(`GPU ${data.power.gpuWatts.toFixed(2)} W`);
238
+ if (data.power.combinedWatts !== undefined) pw.push(`Combined ${data.power.combinedWatts.toFixed(2)} W`);
239
+ if (pw.length) {
240
+ lines.push('## Power');
241
+ lines.push('');
242
+ lines.push('| Metric | Value |');
243
+ lines.push('|---|---|');
244
+ if (data.power.cpuWatts !== undefined) lines.push(`| CPU | ${mdCell(`${data.power.cpuWatts.toFixed(2)} W`)} |`);
245
+ if (data.power.gpuWatts !== undefined) lines.push(`| GPU | ${mdCell(`${data.power.gpuWatts.toFixed(2)} W`)} |`);
246
+ if (data.power.combinedWatts !== undefined) lines.push(`| Combined | ${mdCell(`${data.power.combinedWatts.toFixed(2)} W`)} |`);
247
+ lines.push('');
248
+ }
249
+ }
250
+
251
+ if (data.battery) {
252
+ lines.push('## Battery');
253
+ lines.push('');
254
+ lines.push('| Property | Value |');
255
+ lines.push('|---|---|');
256
+ lines.push(`| Level | ${mdCell(`${data.battery.level}%`)} |`);
257
+ lines.push(`| State | ${mdCell(data.battery.state)} |`);
258
+ lines.push(`| Time Remaining | ${mdCell(data.battery.timeRemaining)} |`);
259
+ lines.push(`| Health | ${mdCell(data.battery.health)} |`);
260
+ lines.push(`| Power Source | ${mdCell(data.battery.powerSource)} |`);
261
+ if (data.battery.condition) lines.push(`| Condition | ${mdCell(data.battery.condition)} |`);
262
+ if (data.battery.maxCapacityPercent !== undefined) lines.push(`| Max Capacity | ${mdCell(`${data.battery.maxCapacityPercent}%`)} |`);
263
+ if (data.battery.cycles !== undefined) lines.push(`| Cycles | ${mdCell(String(data.battery.cycles))} |`);
264
+ if (data.battery.estimatedTimeToEmpty) lines.push(`| Est. Empty | ${mdCell(data.battery.estimatedTimeToEmpty)} |`);
265
+ if (data.battery.dischargeRatePerHour !== undefined) lines.push(`| Discharge Rate | ${mdCell(`${data.battery.dischargeRatePerHour}%/h`)} |`);
266
+ lines.push('');
267
+ }
268
+
269
+ lines.push('## Thermal');
270
+ lines.push('');
271
+ lines.push('| Property | Value |');
272
+ lines.push('|---|---|');
273
+ lines.push(`| State | ${mdCell(data.thermal.state)} |`);
274
+ lines.push(`| Pressure | ${mdCell(['Nominal', 'Fair', 'Serious', 'Critical'][data.thermal.pressureLevel] || 'Unknown')} |`);
275
+ if (data.thermal.temperatures) {
276
+ for (const [k, v] of Object.entries(data.thermal.temperatures)) {
277
+ if (v !== null && v !== undefined) lines.push(`| ${mdCell(k.replace(/_/g, ' '))} | ${mdCell(`${v.toFixed(1)}°C`)} |`);
278
+ }
279
+ }
280
+ lines.push('');
281
+
282
+ lines.push('## Network');
283
+ lines.push('');
284
+ lines.push('| Property | Value |');
285
+ lines.push('|---|---|');
286
+ lines.push(`| Interface | ${mdCell(`${data.network.interface} (${data.network.ip})`)} |`);
287
+ lines.push(`| RX | ${mdCell(`${formatBytes(data.network.rxBytes)} / ${data.network.rxPackets} pkt`)} |`);
288
+ lines.push(`| TX | ${mdCell(`${formatBytes(data.network.txBytes)} / ${data.network.txPackets} pkt`)} |`);
289
+ lines.push('');
290
+
291
+ if (data.packets) {
292
+ lines.push('## Packets');
293
+ lines.push('');
294
+ lines.push('| Property | Value |');
295
+ lines.push('|---|---|');
296
+ lines.push(`| Total | ${mdCell(`${data.packets.totalPackets} pkt`)} |`);
297
+ lines.push(`| RX | ${mdCell(`${data.packets.rxPackets} pkt`)} |`);
298
+ lines.push(`| TX | ${mdCell(`${data.packets.txPackets} pkt`)} |`);
299
+ lines.push(`| Connections | ${mdCell(`${data.packets.connections} TCP`)} |`);
300
+ lines.push('');
301
+ }
302
+
303
+ if (data.tasks.length) {
304
+ lines.push('## Tasks');
305
+ lines.push('');
306
+ lines.push('| PID | User | CPU% | MEM% | State | Command |');
307
+ lines.push('|---|---|---|---|---|---|');
308
+ for (const t of data.tasks.slice(0, 20)) {
309
+ lines.push(`| ${t.pid} | ${mdCell(t.user)} | ${t.cpu.toFixed(1)} | ${t.mem.toFixed(1)} | ${t.state} | ${mdCell(t.command)} |`);
310
+ }
311
+ lines.push('');
312
+ }
313
+
314
+ if (data.disk.length) {
315
+ lines.push('## Disk');
316
+ lines.push('');
317
+ lines.push('| Mountpoint | Used / Size | Capacity |');
318
+ lines.push('|---|---|---|');
319
+ for (const d of data.disk) {
320
+ lines.push(`| ${mdCell(d.mountpoint)} | ${mdCell(`${d.used} / ${d.size}`)} | ${mdCell(d.capacity)} |`);
321
+ }
322
+ lines.push('');
323
+ }
324
+
325
+ if (data.processes.length) {
326
+ lines.push('## Processes');
327
+ lines.push('');
328
+ lines.push('| PID | User | CPU% | MEM% | State | Command |');
329
+ lines.push('|---|---|---|---|---|---|');
330
+ for (const p of data.processes.slice(0, 50)) {
331
+ lines.push(`| ${p.pid} | ${mdCell(p.user)} | ${p.cpu.toFixed(1)} | ${p.mem.toFixed(1)} | ${p.state} | ${mdCell(p.command)} |`);
332
+ }
333
+ lines.push('');
334
+ }
335
+
336
+ return lines.join('\n');
337
+ }
338
+
339
+ function escapeHtml(s: string): string {
340
+ return s
341
+ .replace(/&/g, '&amp;')
342
+ .replace(/</g, '&lt;')
343
+ .replace(/>/g, '&gt;')
344
+ .replace(/"/g, '&quot;');
345
+ }
346
+
347
+ function mdCell(s: string): string {
348
+ return String(s).replace(/\|/g, '\\|');
349
+ }
350
+
20
351
  export function formatCsv(data: StatsData): string {
21
352
  const rows: string[][] = [
22
353
  ['property', 'key', 'value'],
@@ -25,6 +356,9 @@ export function formatCsv(data: StatsData): string {
25
356
  ['cpu', 'loadAvg_1m', String(data.cpu.loadAvg[0])],
26
357
  ['cpu', 'loadAvg_5m', String(data.cpu.loadAvg[1])],
27
358
  ['cpu', 'loadAvg_15m', String(data.cpu.loadAvg[2])],
359
+ ['cpu', 'cores', String(data.cpu.cores)],
360
+ ['cpu', 'physicalCores', String(data.cpu.physicalCores)],
361
+ ['cpu', 'frequency', String(data.cpu.frequency)],
28
362
  ['memory', 'total', String(data.memory.total)],
29
363
  ['memory', 'used', String(data.memory.used)],
30
364
  ['memory', 'usagePercent', String(data.memory.usagePercent)],
@@ -32,11 +366,7 @@ export function formatCsv(data: StatsData): string {
32
366
  ['memory', 'swapTotal', String(data.memory.swapTotal)],
33
367
  ['thermal', 'state', data.thermal.state],
34
368
  ['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
- ],
369
+ ['thermal', 'cpuTempF', data.thermal.temperatures?.cpu_die != null ? String(celsiusToFahrenheit(data.thermal.temperatures.cpu_die)) : ''],
40
370
  ['network', 'rxBytes', String(data.network.rxBytes)],
41
371
  ['network', 'txBytes', String(data.network.txBytes)],
42
372
  ['network', 'rxPackets', String(data.network.rxPackets)],
@@ -54,9 +384,12 @@ export function formatCsv(data: StatsData): string {
54
384
  ['power', 'gpuWatts', data.power?.gpuWatts !== undefined ? String(data.power.gpuWatts) : ''],
55
385
  ['power', 'combinedWatts', data.power?.combinedWatts !== undefined ? String(data.power.combinedWatts) : ''],
56
386
  ['system', 'hostname', data.header.hostname],
57
- ['system', 'uptime', data.header.uptime],
58
- ['system', 'timestamp', data.timestamp],
59
- ];
387
+ ['system', 'uptime', data.header.uptime],
388
+ ['system', 'timestamp', data.timestamp],
389
+ ];
390
+ if (data.cpu.coreUsage && data.cpu.coreUsage.length) {
391
+ data.cpu.coreUsage.forEach((u, i) => rows.push(['cpu', `coreUsage_${i}`, String(u)]));
392
+ }
60
393
 
61
394
  if (data.packets) {
62
395
  rows.push(['packets', 'totalPackets', String(data.packets.totalPackets)]);
@@ -12,7 +12,7 @@
12
12
  import chalk from 'chalk';
13
13
  import { sparkline } from '../sparkline.js';
14
14
  import type { History } from '../history.js';
15
- import type { StatsData, VisibleItems, TableOptions } from './types.js';
15
+ import type { StatsData, VisibleItems, TableOptions, AnomalyAlert } from './types.js';
16
16
  import { THEMES, type ThemeName } from './themes.js';
17
17
 
18
18
  // --- low-level box drawing ---------------------------------------------------
@@ -132,6 +132,14 @@ export function formatTemp(c: number): string {
132
132
  return `${c.toFixed(1)}°C / ${celsiusToFahrenheit(c).toFixed(1)}°F`;
133
133
  }
134
134
 
135
+ /** Render a Celsius reading in the requested unit. */
136
+ export function formatTempUnit(c: number, unit: 'c' | 'f' = 'c'): string {
137
+ if (unit === 'f') {
138
+ return `${(c * (9 / 5) + 32).toFixed(1)}°F`;
139
+ }
140
+ return `${c.toFixed(1)}°C`;
141
+ }
142
+
135
143
  // --- cards -------------------------------------------------------------------
136
144
 
137
145
  function cpuCard(data: StatsData, contentWidth: number): string[] {
@@ -142,6 +150,18 @@ function cpuCard(data: StatsData, contentWidth: number): string[] {
142
150
  gaugeRow('Usage', data.cpu.usage, contentWidth),
143
151
  statRow('Load', data.cpu.loadAvg.map(l => l.toFixed(2)).join(' ')),
144
152
  ];
153
+ if (data.cpu.coreUsage && data.cpu.coreUsage.length) {
154
+ const slotW = 2;
155
+ const maxCores = Math.max(1, Math.floor((contentWidth - 10) / (slotW + 1)));
156
+ const cores = data.cpu.coreUsage.slice(0, maxCores);
157
+ const bars = cores.map(u => {
158
+ const fill = Math.round((Math.min(100, u) / 100) * slotW);
159
+ const str = '█'.repeat(Math.max(0, fill)) + '░'.repeat(slotW - fill);
160
+ const color = u > 90 ? chalk.red : u > 70 ? chalk.yellow : chalk.green;
161
+ return color(str);
162
+ });
163
+ lines.push(statRow('Cores', bars.join(' ')));
164
+ }
145
165
  if (data.cpu.temperature) lines.push(statRow('Temp', formatTemp(data.cpu.temperature)));
146
166
  return lines;
147
167
  }
@@ -571,23 +591,40 @@ function activeDetailLines(data: StatsData, activePanel: string, width: number,
571
591
  }
572
592
  }
573
593
 
574
- export function formatTable(data: StatsData, opts: TableOptions = {}): string {
575
- const width = clampWidth(opts.width);
576
- const activePanel = opts.activePanel || 'grid';
577
- const columns = gridColumns(width);
578
- const gap = 2;
579
- const cardWidth = Math.floor((width - gap * (columns - 1)) / columns);
580
- const contentWidth = cardWidth - 4;
594
+ function anomalyPanel(anomalies: AnomalyAlert[], width: number, theme: ThemeColors): string[] {
595
+ const lines: string[] = [];
596
+ for (const a of anomalies) {
597
+ const color = a.severity === 'critical' ? chalk.red.bold : chalk.yellow;
598
+ const direction = a.zScore > 0 ? '↑ spike' : '↓ drop';
599
+ lines.push(color(` ⚠ ${a.metric}: ${a.value.toFixed(1)} (${direction}, σ=${a.zScore.toFixed(1)})`));
600
+ }
601
+ return lines;
602
+ }
581
603
 
582
- const themeName = opts.theme || 'default';
583
- const theme = THEMES[themeName] || THEMES.default;
584
- const vis = opts.visible ?? {};
604
+ export function formatTable(data: StatsData, opts: TableOptions = {}): string {
605
+ const width = clampWidth(opts.width);
606
+ const activePanel = opts.activePanel || 'grid';
607
+ const columns = gridColumns(width);
608
+ const gap = 2;
609
+ const cardWidth = Math.floor((width - gap * (columns - 1)) / columns);
610
+ const contentWidth = cardWidth - 4;
611
+
612
+ const themeName = opts.theme || 'default';
613
+ const theme = THEMES[themeName] || THEMES.default;
614
+ const vis = opts.visible ?? {};
615
+
616
+ const out: string[] = [];
617
+ out.push(...header(data, width, [], theme.border));
618
+ out.push('');
619
+
620
+ const anomalies = opts.anomalies ?? [];
621
+ if (anomalies.length && activePanel === 'grid') {
622
+ out.push(...anomalyPanel(anomalies, width, theme));
623
+ out.push('');
624
+ }
585
625
 
586
- const out: string[] = [];
587
- out.push(...header(data, width, [], theme.border));
588
- out.push('');
589
- out.push(tabBar(activePanel, width, theme));
590
- out.push('');
626
+ out.push(tabBar(activePanel, width, theme));
627
+ out.push('');
591
628
 
592
629
  if (activePanel !== 'grid') {
593
630
  const lines = activeDetailLines(data, activePanel, width, opts);
@@ -42,21 +42,32 @@ export interface VisibleItems {
42
42
  tree?: boolean;
43
43
  }
44
44
 
45
+ export interface AnomalyAlert {
46
+ metric: string;
47
+ value: number;
48
+ zScore: number;
49
+ severity: 'warning' | 'critical';
50
+ }
51
+
45
52
  export interface TableOptions {
46
- /** Terminal width in columns; controls layout, bar sizing, and column count. Defaults to 80. */
47
- width?: number;
48
- /** Process sort key. Defaults to 'cpu'. */
49
- sortBy?: 'cpu' | 'mem' | 'pid' | 'user' | 'command' | 'state' | 'threads' | 'runtime';
50
- /** Case-insensitive substring filter applied to the process command. */
51
- filter?: string;
52
- /** Max process rows to show (the live dashboard sizes this to available terminal height). */
53
- processLimit?: number;
54
- /** Selected visual theme. */
55
- theme?: ThemeName;
56
- /** Visibility toggle settings for individual cards. */
57
- visible?: VisibleItems;
58
- /** Show process tree view instead of flat list. */
59
- treeView?: boolean;
60
- /** Active panel for detail/focus view. 'grid' means show all panels. */
61
- activePanel?: 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process';
53
+ /** Terminal width in columns; controls layout, bar sizing, and column count. Defaults to 80. */
54
+ width?: number;
55
+ /** Process sort key. Defaults to 'cpu'. */
56
+ sortBy?: 'cpu' | 'mem' | 'pid' | 'user' | 'command' | 'state' | 'threads' | 'runtime';
57
+ /** Case-insensitive substring filter applied to the process command. */
58
+ filter?: string;
59
+ /** Max process rows to show (the live dashboard sizes this to available terminal height). */
60
+ processLimit?: number;
61
+ /** Selected visual theme. */
62
+ theme?: ThemeName;
63
+ /** Visibility toggle settings for individual cards. */
64
+ visible?: VisibleItems;
65
+ /** Show process tree view instead of flat list. */
66
+ treeView?: boolean;
67
+ /** Active panel for detail/focus view. 'grid' means show all panels. */
68
+ activePanel?: 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p';
69
+ /** Active anomaly alerts from the statistical detector. */
70
+ anomalies?: AnomalyAlert[];
71
+ /** Temperature display unit: 'c' or 'f'. */
72
+ tempUnit?: 'c' | 'f';
62
73
  }