pyre-cli 9.5.1 → 10.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.
Files changed (94) hide show
  1. package/README.md +84 -14
  2. package/dist/alertChannels-TPSQIJ7S.js +8 -0
  3. package/dist/blender-FGH3ASEQ.js +7 -0
  4. package/dist/brewHealth-P2GW5Y5C.js +176 -0
  5. package/dist/buildCmd-CDPZ2ZFA.js +38 -0
  6. package/dist/{checkCmd-EWFNLBWE.js → checkCmd-RKW6QGDR.js} +10 -11
  7. package/dist/{chunk-RVGKEJXT.js → chunk-53O2RGSL.js} +47 -17
  8. package/dist/{chunk-MOAEGMFP.js → chunk-6UHZP4QP.js} +204 -46
  9. package/dist/chunk-GHRDW646.js +2506 -0
  10. package/dist/{prometheus-LFYHF6VO.js → chunk-HT4VQ4OH.js} +1 -0
  11. package/dist/chunk-M73HCQD2.js +95 -0
  12. package/dist/chunk-MZO5QKUR.js +32 -0
  13. package/dist/chunk-VIACN2RU.js +133 -0
  14. package/dist/chunk-Z2YV5HKV.js +87 -0
  15. package/dist/chunk-ZLVIVFSO.js +110 -0
  16. package/dist/{collectors-WD6VKH6M.js → collectors-H43PN4PS.js} +8 -2
  17. package/dist/{completions-VQMHQCXE.js → completions-JKYEJNGR.js} +34 -1
  18. package/dist/{config-4ZLF4HTS.js → config-GYGJXU2Q.js} +9 -1
  19. package/dist/doctor-GYHFTNZQ.js +387 -0
  20. package/dist/explainCmd-MF6PIMLW.js +222 -0
  21. package/dist/extensions-XJGHG7X3.js +134 -0
  22. package/dist/index.js +841 -206
  23. package/dist/ioreport.py +72 -0
  24. package/dist/netUsageCmd-WKBTW2YG.js +90 -0
  25. package/dist/{pipeCmd-7THXEGDH.js → pipeCmd-7REDBLBU.js} +5 -3
  26. package/dist/profileProcCmd-N4H6MCBT.js +107 -0
  27. package/dist/prometheus-5BL5PVLC.js +6 -0
  28. package/dist/prometheusCmd-PL64BLN5.js +93 -0
  29. package/dist/replayCmd-Z3PYXY7V.js +164 -0
  30. package/dist/run-HOGQDDTA.js +8 -0
  31. package/dist/serveCmd-2H5VLF3P.js +153 -0
  32. package/dist/service-Q74BBILO.js +181 -0
  33. package/dist/smartCmd-ODWKSFNG.js +166 -0
  34. package/dist/syncCmd-A2W3QB3S.js +126 -0
  35. package/dist/topoCmd-DU2HHZNH.js +148 -0
  36. package/dist/watchdogCmd-UF53JPZB.js +161 -0
  37. package/dist/{wizard-GVIAHIVV.js → wizard-TD7BGX6O.js} +1 -1
  38. package/install.ps1 +29 -0
  39. package/install.sh +10 -9
  40. package/package.json +7 -3
  41. package/src/ai/models.ts +138 -0
  42. package/src/alertChannels.ts +119 -0
  43. package/src/anomalies.ts +114 -60
  44. package/src/brewHealth.ts +157 -40
  45. package/src/buildCmd.ts +48 -0
  46. package/src/checkCmd.ts +5 -5
  47. package/src/completions.ts +35 -0
  48. package/src/doctor.ts +235 -7
  49. package/src/easy_features.test.ts +40 -0
  50. package/src/explainCmd.ts +261 -0
  51. package/src/extensions.ts +71 -7
  52. package/src/formatters/index.ts +1 -1
  53. package/src/formatters/output.ts +100 -13
  54. package/src/formatters/render.ts +274 -38
  55. package/src/formatters/themes.ts +8 -0
  56. package/src/formatters/types.ts +6 -1
  57. package/src/index.ts +298 -56
  58. package/src/live/input.ts +122 -4
  59. package/src/live/render.ts +98 -0
  60. package/src/live/state.ts +70 -52
  61. package/src/live/types.ts +2 -2
  62. package/src/monitors/batteryPredictor.ts +143 -0
  63. package/src/monitors/blender.ts +144 -0
  64. package/src/monitors/buildTracker.ts +121 -0
  65. package/src/monitors/collectors.ts +558 -147
  66. package/src/monitors/dns_cache.ts +48 -0
  67. package/src/monitors/index.ts +3 -2
  68. package/src/monitors/ioreport.py +72 -0
  69. package/src/monitors/platform/linux.ts +756 -0
  70. package/src/monitors/platform/windows.ts +519 -0
  71. package/src/monitors/run.ts +27 -3
  72. package/src/monitors/smc.ts +21 -1
  73. package/src/monitors/types.ts +112 -14
  74. package/src/netUsageCmd.ts +121 -0
  75. package/src/platform.test.ts +99 -0
  76. package/src/plugins/index.ts +143 -0
  77. package/src/profileProcCmd.ts +133 -0
  78. package/src/prometheusCmd.ts +107 -0
  79. package/src/replayCmd.ts +186 -0
  80. package/src/serveCmd.ts +180 -0
  81. package/src/service.ts +125 -7
  82. package/src/smartCmd.ts +205 -0
  83. package/src/state/config.ts +64 -17
  84. package/src/syncCmd.ts +166 -0
  85. package/src/topoCmd.ts +176 -0
  86. package/src/watchdogCmd.ts +198 -0
  87. package/src/xbar.ts +22 -11
  88. package/dist/brewHealth-F2BXF5M6.js +0 -74
  89. package/dist/chunk-FH5WDJBF.js +0 -15
  90. package/dist/chunk-HGRBBPYX.js +0 -937
  91. package/dist/doctor-L6FZYA7F.js +0 -184
  92. package/dist/extensions-DVA5YLGH.js +0 -74
  93. package/dist/service-WTS3NBQU.js +0 -73
  94. /package/dist/{chunk-SG26Y3WF.js → chunk-IM3IQMMT.js} +0 -0
@@ -15,6 +15,9 @@ import { formatTable, formatGraphs, gridColumns, THEMES, type ThemeName, panel,
15
15
  import { detectAnomalies } from '../anomalies.js';
16
16
  import { state, setStatus, SIGNAL_OPTIONS, getToggleKey, MENU_OPTIONS } from './state.js';
17
17
  import type { StatsData } from '../monitors/index.js';
18
+ import { sendAlert, type AlertChannelConfig } from '../alertChannels.js';
19
+ import { readConfig } from '../state/config.js';
20
+ import { AVAILABLE_AI_MODELS } from '../ai/models.js';
18
21
 
19
22
  function formatTempForUnit(c: number): string {
20
23
  if (state.tempUnit === 'f') {
@@ -61,6 +64,7 @@ function tableCacheParams(): string {
61
64
  state.history.version,
62
65
  JSON.stringify(state.visiblePanels),
63
66
  JSON.stringify(state.panelLayout),
67
+ state.aiModel,
64
68
  ].join('|');
65
69
  }
66
70
 
@@ -142,6 +146,11 @@ function renderCustomizerOverlay(scrollOffset: number, maxVisibleItems: number):
142
146
  lines.push(`${prefix}${opt}: ${status}`);
143
147
  } else if (opt === 'Temperature Unit') {
144
148
  lines.push(`${prefix}${opt}: ${chalk.bold.green(state.tempUnit.toUpperCase())} [C, F]`);
149
+ } else if (opt === 'AI Model') {
150
+ const curModel = AVAILABLE_AI_MODELS.find(m => m.id === state.aiModel) || AVAILABLE_AI_MODELS[0];
151
+ lines.push(`${prefix}${opt}: ${chalk.bold.cyan(curModel.name)} ${chalk.dim(`[${curModel.badge}]`)}`);
152
+ } else if (opt === 'Grid Panel Order') {
153
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.panelLayout.join(' → '))}`);
145
154
  } else {
146
155
  const toggleKey = getToggleKey(opt);
147
156
  const isVisible = toggleKey ? (toggleKey === 'tree' ? state.treeView : state.visiblePanels[toggleKey] !== false) : true;
@@ -216,6 +225,14 @@ function render() {
216
225
  writeFrame(renderCredits(cols, rows));
217
226
  return;
218
227
  }
228
+ if (state.inputMode === 'docker-alert') {
229
+ writeFrame(renderDockerAlert(cols, rows));
230
+ return;
231
+ }
232
+ if (state.inputMode === 'ai-model') {
233
+ writeFrame(renderAiModelSelector(cols, rows));
234
+ return;
235
+ }
219
236
  const lines: string[] = [];
220
237
  const theme = THEMES[state.currentTheme] || THEMES.default;
221
238
 
@@ -258,6 +275,7 @@ function render() {
258
275
  selectedProcessIndex: state.processSelectionIndex,
259
276
  trackedPid: state.trackedPid,
260
277
  inspectProcess: state.inspectingProcess,
278
+ aiModel: state.aiModel,
261
279
  });
262
280
  _cachedTableData = state.lastData;
263
281
  _cachedTableParams = cacheParams;
@@ -373,6 +391,7 @@ function footerLine(): string[] {
373
391
  ['f', state.exportFormat],
374
392
  ['+/-', `${state.interval}s`],
375
393
  ['r', state.p2pServerRunning ? 'stop p2p' : 'start p2p'],
394
+ ['m', `ai:${state.aiModel.split('-')[0]}`],
376
395
  ];
377
396
  const fmt = (list: [string, string][]) => list.map(([k, label]) => `${chalk.hex('#50fa7b').bold(k)} ${chalk.dim(label)}`).join(chalk.dim(' | '));
378
397
  const str1 = fmt(row1);
@@ -383,6 +402,7 @@ function footerLine(): string[] {
383
402
  if (state.logging) badges.push(chalk.red.bold('● REC'));
384
403
  if (state.activePanel !== 'grid') badges.push(chalk.cyan.bold(`◉ ${state.activePanel.toUpperCase()}`));
385
404
  if (state.p2pServerRunning) badges.push(chalk.green.bold(`P2P ${state.p2pBind || '0.0.0.0'}:${state.p2pPort}`));
405
+ badges.push(chalk.magenta.bold(`AI:${state.aiModel}`));
386
406
  const badgeStr = badges.join(' ') || ' ';
387
407
 
388
408
  return [str1, str2, badgeStr];
@@ -454,6 +474,29 @@ function checkAlerts(data: StatsData) {
454
474
  if (state.alertCmd) {
455
475
  exec(state.alertCmd, { env: { ...process.env, PYRE_ALERT: alertMsg } }, () => {});
456
476
  }
477
+
478
+ // Multi-channel alert fan-out (Slack, Discord, Pushover, ntfy)
479
+ const cfg = readConfig();
480
+ const channelConfig: AlertChannelConfig = {
481
+ slackUrl: cfg.slackAlertUrl || undefined,
482
+ discordUrl: cfg.discordAlertUrl || undefined,
483
+ pushoverToken: cfg.pushoverToken || undefined,
484
+ pushoverUser: cfg.pushoverUser || undefined,
485
+ ntfyUrl: cfg.ntfyUrl || undefined,
486
+ };
487
+ const hasChannels = Object.values(channelConfig).some(v => !!v);
488
+ if (hasChannels) {
489
+ const severity = hasCriticalAnomaly ? 'critical' : 'warning';
490
+ sendAlert({
491
+ metric: reasons[0]?.split(' ')[0] ?? 'System',
492
+ value: data.cpu.usage,
493
+ threshold: state.CPU_ALERT_PCT,
494
+ unit: '%',
495
+ host: data.header.hostname,
496
+ timestamp: new Date().toISOString(),
497
+ severity,
498
+ }, channelConfig).catch(() => {});
499
+ }
457
500
  } else if (!hot && !anomalyTriggered && !watchdogTriggered) {
458
501
  state.alerted = false;
459
502
  }
@@ -564,4 +607,59 @@ function renderCredits(cols: number, rows: number): string[] {
564
607
  return out;
565
608
  }
566
609
 
610
+ function renderDockerAlert(cols: number, rows: number): string[] {
611
+ const out: string[] = [];
612
+ const content = [
613
+ chalk.bold.red('Docker Container Detected'),
614
+ '',
615
+ 'Pyre is running inside a container. Some hardware sensors',
616
+ '(battery, thermal, power) may not be available or relevant.',
617
+ '',
618
+ chalk.cyan('Do you wish to continue in Docker mode?'),
619
+ ''
620
+ ];
621
+
622
+ const options = ['Yes, use Docker mode (hide hardware sensors)', 'No, launch regular TUI with all features'];
623
+ for (let i = 0; i < options.length; i++) {
624
+ const isSelected = i === state.dockerSelectionIndex;
625
+ const prefix = isSelected ? chalk.cyan.bold(' ▶ ') : ' ';
626
+ const text = isSelected ? chalk.bgCyan.black(` ${options[i]} `) : chalk.dim(` ${options[i]} `);
627
+ content.push(prefix + text);
628
+ }
629
+
630
+ content.push('');
631
+ content.push(chalk.dim('Press ↑/↓ to select, Enter to confirm, Esc to skip.'));
632
+
633
+ out.push(...panel('DOCKER DETECTED', content, cols, chalk.red, chalk.dim, rows - 2));
634
+ return out;
635
+ }
636
+
637
+ function renderAiModelSelector(cols: number, rows: number): string[] {
638
+ const out: string[] = [];
639
+ const content: string[] = [
640
+ chalk.bold.hex('#ff6004')('Choose AI Anomaly Explanation Model'),
641
+ chalk.dim('Select your preferred diagnosis engine. Offline models run entirely in-process.'),
642
+ '',
643
+ ];
644
+
645
+ for (let i = 0; i < AVAILABLE_AI_MODELS.length; i++) {
646
+ const m = AVAILABLE_AI_MODELS[i];
647
+ const isSelected = i === state.modelSelectionIndex;
648
+ const isCurrent = m.id === state.aiModel;
649
+ const prefix = isSelected ? chalk.cyan.bold(' ▶ ') : ' ';
650
+ const curBadge = isCurrent ? chalk.bgGreen.black.bold(' ACTIVE ') : '';
651
+ const badgeStr = m.offline ? chalk.green(`[${m.badge}]`) : chalk.yellow(`[${m.badge}]`);
652
+
653
+ const nameStr = isSelected ? chalk.bgCyan.black.bold(` ${m.name} `) : chalk.bold.white(m.name);
654
+ content.push(`${prefix}${nameStr} ${badgeStr} ${curBadge}`);
655
+ content.push(chalk.dim(` ${m.description}`));
656
+ content.push('');
657
+ }
658
+
659
+ content.push(chalk.dim('Use ↑/↓ or J/K to navigate · Enter/Space to select · Esc to cancel'));
660
+
661
+ out.push(...panel('AI MODEL SELECTOR', content, cols, chalk.cyan, chalk.dim, Math.max(1, rows - 2)));
662
+ return out;
663
+ }
664
+
567
665
  export { render, footerLine, renderCustomizerOverlay, checkAlerts, processRowBudget, invalidateTableCache, invalidateFrame };
package/src/live/state.ts CHANGED
@@ -23,6 +23,8 @@ export const MENU_OPTIONS = ['Resume Dashboard', 'Settings (Customizer)', 'Readm
23
23
 
24
24
  const config = readConfig();
25
25
 
26
+ const isDockerEnv = fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv');
27
+
26
28
  const state = {
27
29
  intervalHandle: null as NodeJS.Timeout | null,
28
30
  uiIntervalHandle: null as NodeJS.Timeout | null,
@@ -37,19 +39,21 @@ const state = {
37
39
 
38
40
  exportDir: config.exportDir,
39
41
  currentTheme: config.theme as ThemeName,
40
- visiblePanels: {
41
- cpu: config.visiblePanels.cpu,
42
- mem: config.visiblePanels.mem,
43
- gpu: config.visiblePanels.gpu,
44
- power: config.visiblePanels.power,
45
- battery: config.visiblePanels.battery,
46
- thermal: config.visiblePanels.thermal,
47
- network: config.visiblePanels.network,
48
- packets: config.visiblePanels.packets,
49
- tasks: config.visiblePanels.tasks,
50
- disk: config.visiblePanels.disk,
51
- process: config.visiblePanels.process,
52
- } as VisibleItems,
42
+ visiblePanels: {
43
+ cpu: config.visiblePanels.cpu,
44
+ mem: config.visiblePanels.mem,
45
+ gpu: config.visiblePanels.gpu,
46
+ power: config.visiblePanels.power,
47
+ battery: config.visiblePanels.battery,
48
+ thermal: config.visiblePanels.thermal,
49
+ network: config.visiblePanels.network,
50
+ packets: config.visiblePanels.packets,
51
+ tasks: config.visiblePanels.tasks,
52
+ disk: config.visiblePanels.disk,
53
+ process: config.visiblePanels.process,
54
+ containers: config.visiblePanels.containers,
55
+ blender: config.visiblePanels.blender,
56
+ } as VisibleItems,
53
57
  panelLayout: config.panelLayout || ['mem', 'disk', 'net'],
54
58
  logging: false,
55
59
  logStream: null as fs.WriteStream | null,
@@ -66,34 +70,38 @@ const state = {
66
70
  processScrollOffset: 0,
67
71
  trackedPid: null as number | null,
68
72
  inspectingProcess: null as any | null,
69
- inputMode: null as InputMode,
73
+ inputMode: (isDockerEnv && !config.dockerModeConfirmed) ? 'docker-alert' as InputMode : null as InputMode,
70
74
  inputBuffer: '',
71
75
  targetPid: '',
72
76
  selectedSignal: 'SIGTERM' as typeof SIGNAL_OPTIONS[number],
73
77
  treeView: config.treeView,
74
78
  SIGNAL_OPTIONS,
75
79
  customizerIndex: 0,
76
- CUSTOMIZER_OPTIONS: [
77
- 'Theme',
78
- 'Graph Mode',
79
- 'Splash Screen',
80
- 'Splash Color',
81
- 'Splash Animation',
82
- 'Notifications',
83
- 'Temperature Unit',
84
- 'Toggle CPU',
85
- 'Toggle Memory',
86
- 'Toggle GPU',
87
- 'Toggle Power',
88
- 'Toggle Battery',
89
- 'Toggle Thermal',
90
- 'Toggle Network',
91
- 'Toggle Packets',
92
- 'Toggle Tasks',
93
- 'Toggle Disk',
94
- 'Toggle Processes',
95
- 'Toggle Tree View',
96
- ],
80
+ CUSTOMIZER_OPTIONS: [
81
+ 'Theme',
82
+ 'Graph Mode',
83
+ 'Splash Screen',
84
+ 'Splash Color',
85
+ 'Splash Animation',
86
+ 'Notifications',
87
+ 'Temperature Unit',
88
+ 'AI Model',
89
+ 'Grid Panel Order',
90
+ 'Toggle CPU',
91
+ 'Toggle Memory',
92
+ 'Toggle GPU',
93
+ 'Toggle Power',
94
+ 'Toggle Battery',
95
+ 'Toggle Thermal',
96
+ 'Toggle Network',
97
+ 'Toggle Conns',
98
+ 'Toggle Tasks',
99
+ 'Toggle Disk',
100
+ 'Toggle Processes',
101
+ 'Toggle Containers',
102
+ 'Toggle Blender',
103
+ 'Toggle Tree View',
104
+ ],
97
105
  CPU_ALERT_PCT: config.cpuAlertPct,
98
106
  TEMP_ALERT_C: config.tempAlertC,
99
107
  watchdogProcess: config.watchdogProcess || '',
@@ -103,23 +111,25 @@ const state = {
103
111
  webhookUrl: config.webhookUrl || '',
104
112
  alertCmd: config.alertCmd || '',
105
113
  alerted: false,
106
- activePanel: 'grid' as 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p' | 'anomalies',
114
+ activePanel: 'grid' as 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p' | 'anomalies' | 'containers' | 'blender',
107
115
  mouseEnabled: config.mouseEnabled,
108
- PANEL_TABS: [
109
- { id: 'cpu', label: 'CPU', key: '1' },
110
- { id: 'mem', label: 'Memory', key: '2' },
111
- { id: 'gpu', label: 'GPU', key: '3' },
112
- { id: 'power', label: 'Power', key: '4' },
113
- { id: 'battery', label: 'Battery', key: '5' },
114
- { id: 'thermal', label: 'Thermal', key: '6' },
115
- { id: 'network', label: 'Network', key: '7' },
116
- { id: 'packets', label: 'Packets', key: '8' },
117
- { id: 'tasks', label: 'Tasks', key: '9' },
118
- { id: 'disk', label: 'Disk', key: '0' },
119
- { id: 'process', label: 'Process', key: 'P' },
120
- { id: 'p2p', label: 'P2P', key: 'R' },
121
- { id: 'anomalies', label: 'Anomalies', key: 'A' },
122
- ],
116
+ PANEL_TABS: [
117
+ { id: 'cpu', label: 'CPU', key: '1' },
118
+ { id: 'mem', label: 'Memory', key: '2' },
119
+ { id: 'gpu', label: 'GPU', key: '3' },
120
+ { id: 'power', label: 'Power', key: '4' },
121
+ { id: 'battery', label: 'Battery', key: '5' },
122
+ { id: 'thermal', label: 'Thermal', key: '6' },
123
+ { id: 'network', label: 'Network', key: '7' },
124
+ { id: 'packets', label: 'Conns', key: '8' },
125
+ { id: 'tasks', label: 'Tasks', key: '9' },
126
+ { id: 'disk', label: 'Disk', key: '0' },
127
+ { id: 'process', label: 'Process', key: 'P' },
128
+ { id: 'containers', label: 'Containers', key: 'C' },
129
+ { id: 'p2p', label: 'P2P', key: 'R' },
130
+ { id: 'anomalies', label: 'Anomalies', key: 'A' },
131
+ { id: 'blender', label: 'Blender', key: 'B' },
132
+ ],
123
133
  p2pServer: null as import('../p2p/server.js').P2PServer | null,
124
134
  p2pClient: null as import('../p2p/client.js').P2PClient | null,
125
135
  p2pServerRunning: false,
@@ -134,6 +144,12 @@ const state = {
134
144
  splashAnimation: config.splashAnimation as SplashAnimation,
135
145
  menuSelectionIndex: 0,
136
146
  readmeScrollOffset: 0,
147
+ isDocker: isDockerEnv,
148
+ dockerModeConfirmed: config.dockerModeConfirmed || false,
149
+ dockerSelectionIndex: 0,
150
+ aiModel: config.aiModel || 'expert-rules-v1',
151
+ aiBackend: config.aiBackend || 'builtin',
152
+ modelSelectionIndex: 0,
137
153
  };
138
154
 
139
155
  function setStatus(msg: string, ms = 3000) {
@@ -153,10 +169,12 @@ function getToggleKey(opt: string): keyof VisibleItems | null {
153
169
  'Toggle Battery': 'battery',
154
170
  'Toggle Thermal': 'thermal',
155
171
  'Toggle Network': 'network',
156
- 'Toggle Packets': 'packets',
172
+ 'Toggle Conns': 'packets',
157
173
  'Toggle Tasks': 'tasks',
158
174
  'Toggle Disk': 'disk',
159
175
  'Toggle Processes': 'process',
176
+ 'Toggle Containers': 'containers',
177
+ 'Toggle Blender': 'blender',
160
178
  'Toggle Tree View': 'tree',
161
179
  };
162
180
  return map[opt] ?? null;
package/src/live/types.ts CHANGED
@@ -20,13 +20,13 @@ export interface LiveOptions {
20
20
 
21
21
  export type ExportFormat = 'json' | 'csv' | 'tsv' | 'html' | 'md';
22
22
 
23
- type InputMode = null | 'filter' | 'kill' | 'signal' | 'customizer' | 'p2p' | 'menu' | 'readme' | 'credits';
23
+ type InputMode = null | 'filter' | 'kill' | 'signal' | 'customizer' | 'p2p' | 'menu' | 'readme' | 'credits' | 'docker-alert' | 'ai-model';
24
24
 
25
25
  type SortMode = 'cpu' | 'mem' | 'pid' | 'user' | 'command' | 'state' | 'threads' | 'runtime';
26
26
 
27
27
  type GraphMode = 'spark' | 'bar';
28
28
 
29
- type ActivePanel = 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p';
29
+ type ActivePanel = 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p' | 'containers' | 'anomalies' | 'blender';
30
30
 
31
31
  export type { InputMode, SortMode, GraphMode, ActivePanel, ThemeName };
32
32
 
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Smart Battery Predictor.
3
+ *
4
+ * Predicts multi-scenario battery longevity by synthesizing:
5
+ * 1. Current instantaneous discharge rate and state of charge.
6
+ * 2. Historical discharge profiles parsed from local CSV log exports.
7
+ * 3. Machine load heuristics (idle vs. current compute vs. sustained high-power).
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import type { BatteryData, PowerData } from './types.js';
12
+
13
+ export interface BatteryPredictionScenario {
14
+ label: string;
15
+ ratePerHour: number;
16
+ timeRemaining: string;
17
+ hours: number;
18
+ }
19
+
20
+ export interface SmartBatteryPrediction {
21
+ currentLevel: number;
22
+ healthPercentage?: number;
23
+ cycleCount?: number;
24
+ powerWatts?: number;
25
+ scenarios: {
26
+ idle: BatteryPredictionScenario;
27
+ current: BatteryPredictionScenario;
28
+ heavy: BatteryPredictionScenario;
29
+ };
30
+ recommendation: string;
31
+ confidence: 'low' | 'medium' | 'high';
32
+ }
33
+
34
+ function formatHours(h: number): string {
35
+ if (!isFinite(h) || h <= 0) return 'calculating';
36
+ const hours = Math.floor(h);
37
+ const mins = Math.round((h - hours) * 60);
38
+ if (hours === 0 && mins === 0) return '< 1m';
39
+ if (hours === 0) return `${mins}m`;
40
+ return `${hours}h ${mins}m`;
41
+ }
42
+
43
+ export function computeSmartBatteryPrediction(
44
+ battery: BatteryData | null,
45
+ power: PowerData | null,
46
+ logDir: string = './pyre-exports'
47
+ ): SmartBatteryPrediction | null {
48
+ if (!battery) return null;
49
+
50
+ const level = battery.level;
51
+ const isCharging = battery.state.toLowerCase().includes('charg');
52
+
53
+ // Baseline assumption for typical MacBook battery Wh capacity
54
+ const assumedCapacityWh = 58;
55
+
56
+ // 1. Current discharge rate in %/hour
57
+ let currentRate = battery.dischargeRatePerHour || 12; // default fallback 12%/h
58
+ if (battery.dischargeRatePerHour === undefined && power && power.combinedWatts) {
59
+ currentRate = (power.combinedWatts / assumedCapacityWh) * 100;
60
+ }
61
+ currentRate = Math.max(2, Math.min(60, currentRate));
62
+
63
+ // 2. Scan historical logs for typical average discharge rate if available
64
+ let historicalSamples: number[] = [];
65
+ try {
66
+ if (fs.existsSync(logDir)) {
67
+ const files = fs.readdirSync(logDir).filter(f => f.startsWith('pyre-') && f.endsWith('.csv')).slice(-3);
68
+ for (const file of files) {
69
+ const content = fs.readFileSync(path.join(logDir, file), 'utf-8');
70
+ const lines = content.split('\n').slice(1);
71
+ for (const line of lines) {
72
+ const parts = line.split(',');
73
+ if (parts.length >= 4) {
74
+ const cpu = parseFloat(parts[1]);
75
+ if (!isNaN(cpu)) {
76
+ // Estimate power based on CPU history
77
+ const estRate = 5 + (cpu / 100) * 20;
78
+ historicalSamples.push(estRate);
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ } catch {
85
+ // fallback
86
+ }
87
+
88
+ const confidence: 'low' | 'medium' | 'high' = isCharging
89
+ ? 'low'
90
+ : historicalSamples.length > 50
91
+ ? 'high'
92
+ : battery.dischargeRatePerHour ? 'medium' : 'low';
93
+
94
+ // Compute scenarios
95
+ // Idle load scenario (light browser/reading, ~5-7% per hour)
96
+ const idleRate = 6.0;
97
+ const idleHours = level / idleRate;
98
+
99
+ // Current load scenario
100
+ const currentHours = level / currentRate;
101
+
102
+ // Heavy load scenario (compiling, 3D render, gaming: ~25-35% per hour)
103
+ const heavyRate = Math.max(28.0, currentRate * 1.5);
104
+ const heavyHours = level / heavyRate;
105
+
106
+ let recommendation = 'Power consumption is normal.';
107
+ if (isCharging) {
108
+ recommendation = `Device is currently ${battery.state}. Full capacity health: ${battery.health || 'Good'}.`;
109
+ } else if (currentRate > 25) {
110
+ recommendation = 'High power drain detected. Reducing display brightness and closing heavy background processes can extend battery significantly.';
111
+ } else if (currentRate < 10) {
112
+ recommendation = 'Excellent power efficiency. Machine is running under low-drain conditions.';
113
+ }
114
+
115
+ return {
116
+ currentLevel: level,
117
+ healthPercentage: battery.maxCapacityPercent,
118
+ cycleCount: battery.cycles,
119
+ powerWatts: battery.powerWatts || power?.combinedWatts,
120
+ scenarios: {
121
+ idle: {
122
+ label: 'Light / Reading Load',
123
+ ratePerHour: idleRate,
124
+ timeRemaining: formatHours(idleHours),
125
+ hours: idleHours,
126
+ },
127
+ current: {
128
+ label: 'Current Activity',
129
+ ratePerHour: Math.round(currentRate * 10) / 10,
130
+ timeRemaining: isCharging ? 'Charging' : formatHours(currentHours),
131
+ hours: currentHours,
132
+ },
133
+ heavy: {
134
+ label: 'Sustained Heavy Compute',
135
+ ratePerHour: Math.round(heavyRate * 10) / 10,
136
+ timeRemaining: formatHours(heavyHours),
137
+ hours: heavyHours,
138
+ },
139
+ },
140
+ recommendation,
141
+ confidence,
142
+ };
143
+ }
@@ -0,0 +1,144 @@
1
+ import { run } from './run.js';
2
+ import type { BlenderRenderData } from './types.js';
3
+
4
+ const RENDER_LOG_DIR = '/tmp/blender_render_logs';
5
+
6
+ function blendFileFromArgs(args: string[]): string {
7
+ const nonFlag = args.filter(a => !a.startsWith('-'));
8
+ for (const a of nonFlag) {
9
+ if (a.endsWith('.blend') || a.includes('.blend')) {
10
+ return a.split('/').pop() || a;
11
+ }
12
+ }
13
+ return 'unknown.blend';
14
+ }
15
+
16
+ function engineFromArgs(args: string[]): string {
17
+ const idx = args.indexOf('-E');
18
+ if (idx !== -1 && args[idx + 1]) return args[idx + 1];
19
+ const flag = args.find(a => a.startsWith('-E='));
20
+ if (flag) return flag.slice(3);
21
+ return 'unknown';
22
+ }
23
+
24
+ function outputPathFromArgs(args: string[]): string {
25
+ const oIdx = args.indexOf('-o');
26
+ if (oIdx !== -1 && args[oIdx + 1]) {
27
+ let p = args[oIdx + 1];
28
+ if (p.endsWith('/')) p = p.slice(0, -1);
29
+ return p.split('/').slice(0, -1).join('/') || '/tmp';
30
+ }
31
+ const fFlag = args.find(a => a.startsWith('-o='));
32
+ if (fFlag) {
33
+ const p = fFlag.slice(3);
34
+ return p.endsWith('/') ? p.slice(0, -1).split('/').slice(0, -1).join('/') || '/tmp' : p.split('/').slice(0, -1).join('/') || '/tmp';
35
+ }
36
+ return '/tmp';
37
+ }
38
+
39
+ function frameRangeFromArgs(args: string[]): { start: number; end: number } {
40
+ const fIdx = args.indexOf('-f');
41
+ if (fIdx !== -1 && args[fIdx + 1]) {
42
+ const raw = args[fIdx + 1];
43
+ if (raw.includes('..')) {
44
+ const [s, e] = raw.split('..').map(Number);
45
+ if (!isNaN(s) && !isNaN(e)) return { start: s, end: e };
46
+ }
47
+ const n = parseInt(raw, 10);
48
+ if (!isNaN(n)) return { start: n, end: n };
49
+ }
50
+ const sFlag = args.find(a => a.startsWith('-s='));
51
+ const eFlag = args.find(a => a.startsWith('-e='));
52
+ const start = sFlag ? parseInt(sFlag.slice(3), 10) : 1;
53
+ const end = eFlag ? parseInt(eFlag.slice(3), 10) : 250;
54
+ return { start: isNaN(start) ? 1 : start, end: isNaN(end) ? 250 : end };
55
+ }
56
+
57
+ async function parseRenderLog(blendName: string): Promise<{ framesRendered: number; totalFrames: number; startTime: number; samples?: number } | null> {
58
+ const baseName = blendName.replace(/\.blend$/i, '');
59
+ try {
60
+ const entries = (await run(`ls -t "${RENDER_LOG_DIR}" 2>/dev/null | grep "^${baseName}_" | head -5`, '')).trim().split('\n').filter(Boolean);
61
+ for (const entry of entries) {
62
+ const logPath = `${RENDER_LOG_DIR}/${entry}`;
63
+ try {
64
+ const content = await run(`cat "${logPath}" 2>/dev/null`, '', 500);
65
+ if (!content) continue;
66
+ const lines = content.split('\n');
67
+ const rendered = lines.filter(l => /Fra:(\d+)/i.test(l)).length;
68
+ const totalMatch = content.match(/Fra:\s*\d+\s*\/\s*(\d+)/);
69
+ const totalFrames = totalMatch ? parseInt(totalMatch[1], 10) : 0;
70
+ const startMatch = content.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/m);
71
+ const startTime = startMatch ? new Date(startMatch[1]).getTime() : Date.now() - 60000;
72
+ const sampleMatch = content.match(/Sample\s*(\d+)/i);
73
+ if (rendered > 0) {
74
+ return { framesRendered: rendered, totalFrames: totalFrames || rendered, startTime, samples: sampleMatch ? parseInt(sampleMatch[1], 10) : undefined };
75
+ }
76
+ } catch {
77
+ // try next log file
78
+ }
79
+ }
80
+ } catch {
81
+ // ignore
82
+ }
83
+ return null;
84
+ }
85
+
86
+ export async function collectBlenderRenders(): Promise<BlenderRenderData[]> {
87
+ const renders: BlenderRenderData[] = [];
88
+ try {
89
+ const psRaw = await run("ps -eo pid,pcpu,pmem,state,command 2>/dev/null | grep -iE '[b]lender'", '', 2000);
90
+ if (!psRaw) return renders;
91
+
92
+ const lines = psRaw.split('\n');
93
+ for (const line of lines) {
94
+ const parts = line.trim().match(/\s*(\d+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(.+)/);
95
+ if (!parts) continue;
96
+
97
+ const pid = parseInt(parts[1], 10);
98
+ const cpuPct = parseFloat(parts[2]);
99
+ const command = parts[5];
100
+ const args = command.split(/\s+/);
101
+
102
+ const isRender = /-b|--background|-f\s/.test(command) || /render/i.test(command);
103
+ if (!isRender && !command.includes('blender')) continue;
104
+
105
+ const blendFile = blendFileFromArgs(args);
106
+ const engine = engineFromArgs(args);
107
+ const outputPath = outputPathFromArgs(args);
108
+ const { start: frameStart, end: frameEnd } = frameRangeFromArgs(args);
109
+ const totalFrames = Math.max(1, frameEnd - frameStart + 1);
110
+
111
+ const logData = await parseRenderLog(blendFile);
112
+ const startTime = logData?.startTime ?? Date.now() - 60000;
113
+ const elapsedSec = Math.max(0, Math.round((Date.now() - startTime) / 1000));
114
+ let currentFrame = logData?.framesRendered ?? 0;
115
+ if (currentFrame === 0 && cpuPct > 0) currentFrame = 1;
116
+
117
+ let status: BlenderRenderData['status'] = 'unknown';
118
+ if (cpuPct > 0 && currentFrame > 0 && currentFrame < totalFrames) status = 'rendering';
119
+ else if (cpuPct > 0 && currentFrame >= totalFrames) status = 'finalizing';
120
+ else if (currentFrame === 0 && cpuPct > 0) status = 'loading';
121
+ else if (cpuPct === 0) status = 'idle';
122
+
123
+ const completionPercent = totalFrames > 0 ? Math.min(100, Math.round((currentFrame / totalFrames) * 100)) : 0;
124
+
125
+ renders.push({
126
+ pid,
127
+ blendFile,
128
+ renderEngine: engine,
129
+ frameStart,
130
+ frameEnd,
131
+ currentFrame,
132
+ totalFrames,
133
+ elapsedSec,
134
+ completionPercent,
135
+ status,
136
+ outputPath,
137
+ sampleCount: logData?.samples,
138
+ });
139
+ }
140
+ } catch {
141
+ // ignore
142
+ }
143
+ return renders;
144
+ }